JSON flattening.

This is a new API that makes it possible to do more interesting things
with composition. It's currently write-only; doing composition on reads
is much more difficult.
This commit is contained in:
Jesse Wilson
2018-10-13 23:46:11 -04:00
parent 08bfedaeb2
commit f28bca609a
5 changed files with 477 additions and 4 deletions

View File

@@ -103,7 +103,7 @@ final class RuntimeJsonAdapterFactory<T> implements JsonAdapter.Factory {
return null;
}
List<JsonAdapter<Object>> jsonAdapters = new ArrayList<>();
List<JsonAdapter<Object>> jsonAdapters = new ArrayList<>(subtypes.size());
for (int i = 0, size = subtypes.size(); i < size; i++) {
jsonAdapters.add(moshi.adapter(subtypes.get(i)));
}

View File

@@ -83,7 +83,7 @@ final class JsonUtf8Writer extends JsonWriter {
"Array cannot be used as a map key in JSON at path " + getPath());
}
writeDeferredName();
return open(EMPTY_ARRAY, "[");
return open(EMPTY_ARRAY, NONEMPTY_ARRAY, "[");
}
@Override public JsonWriter endArray() throws IOException {
@@ -96,7 +96,7 @@ final class JsonUtf8Writer extends JsonWriter {
"Object cannot be used as a map key in JSON at path " + getPath());
}
writeDeferredName();
return open(EMPTY_OBJECT, "{");
return open(EMPTY_OBJECT, NONEMPTY_OBJECT, "{");
}
@Override public JsonWriter endObject() throws IOException {
@@ -108,7 +108,13 @@ final class JsonUtf8Writer extends JsonWriter {
* Enters a new scope by appending any necessary whitespace and the given
* bracket.
*/
private JsonWriter open(int empty, String openBracket) throws IOException {
private JsonWriter open(int empty, int nonempty, String openBracket) throws IOException {
if (stackSize == flattenStackSize
&& (scopes[stackSize - 1] == empty || scopes[stackSize - 1] == nonempty)) {
// Cancel this open. Invert the flatten stack size until this is closed.
flattenStackSize = ~flattenStackSize;
return this;
}
beforeValue();
checkStack();
pushScope(empty);
@@ -129,6 +135,11 @@ final class JsonUtf8Writer extends JsonWriter {
if (deferredName != null) {
throw new IllegalStateException("Dangling name: " + deferredName);
}
if (stackSize == ~flattenStackSize) {
// Cancel this close. Restore the flattenStackSize so we're ready to flatten again!
flattenStackSize = ~flattenStackSize;
return this;
}
stackSize--;
pathNames[stackSize] = null; // Free the last path name so that it can be garbage collected!

View File

@@ -52,6 +52,11 @@ final class JsonValueWriter extends JsonWriter {
throw new IllegalStateException(
"Array cannot be used as a map key in JSON at path " + getPath());
}
if (stackSize == flattenStackSize && scopes[stackSize - 1] == EMPTY_ARRAY) {
// Cancel this open. Invert the flatten stack size until this is closed.
flattenStackSize = ~flattenStackSize;
return this;
}
checkStack();
List<Object> list = new ArrayList<>();
add(list);
@@ -65,6 +70,11 @@ final class JsonValueWriter extends JsonWriter {
if (peekScope() != EMPTY_ARRAY) {
throw new IllegalStateException("Nesting problem.");
}
if (stackSize == ~flattenStackSize) {
// Cancel this close. Restore the flattenStackSize so we're ready to flatten again!
flattenStackSize = ~flattenStackSize;
return this;
}
stackSize--;
stack[stackSize] = null;
pathIndices[stackSize - 1]++;
@@ -76,6 +86,11 @@ final class JsonValueWriter extends JsonWriter {
throw new IllegalStateException(
"Object cannot be used as a map key in JSON at path " + getPath());
}
if (stackSize == flattenStackSize && scopes[stackSize - 1] == EMPTY_OBJECT) {
// Cancel this open. Invert the flatten stack size until this is closed.
flattenStackSize = ~flattenStackSize;
return this;
}
checkStack();
Map<String, Object> map = new LinkedHashTreeMap<>();
add(map);
@@ -91,6 +106,11 @@ final class JsonValueWriter extends JsonWriter {
if (deferredName != null) {
throw new IllegalStateException("Dangling name: " + deferredName);
}
if (stackSize == ~flattenStackSize) {
// Cancel this close. Restore the flattenStackSize so we're ready to flatten again!
flattenStackSize = ~flattenStackSize;
return this;
}
promoteValueToName = false;
stackSize--;
stack[stackSize] = null;

View File

@@ -24,7 +24,9 @@ import javax.annotation.Nullable;
import okio.BufferedSink;
import okio.BufferedSource;
import static com.squareup.moshi.JsonScope.EMPTY_ARRAY;
import static com.squareup.moshi.JsonScope.EMPTY_OBJECT;
import static com.squareup.moshi.JsonScope.NONEMPTY_ARRAY;
import static com.squareup.moshi.JsonScope.NONEMPTY_OBJECT;
/**
@@ -140,6 +142,26 @@ public abstract class JsonWriter implements Closeable, Flushable {
boolean serializeNulls;
boolean promoteValueToName;
/**
* Controls the deepest stack size that has begin/end pairs flattened:
*
* <ul>
* <li>If -1, no begin/end pairs are being suppressed.
* <li>If positive, this is the deepest stack size whose begin/end pairs are eligible to be
* flattened.
* <li>If negative, it is the bitwise inverse (~) of the deepest stack size whose begin/end
* pairs have been flattened.
* </ul>
*
* <p>We differentiate between what layer would be flattened (positive) from what layer is being
* flattened (negative) so that we don't double-flatten.
*
* <p>To accommodate nested flattening we require callers to track the previous state when they
* provide a new state. The previous state is returned from {@link #beginFlatten} and restored
* with {@link #endFlatten}.
*/
int flattenStackSize = -1;
/** Returns a new instance that writes UTF-8 encoded JSON to {@code sink}. */
@CheckReturnValue public static JsonWriter of(BufferedSink sink) {
return new JsonUtf8Writer(sink);
@@ -357,6 +379,88 @@ public abstract class JsonWriter implements Closeable, Flushable {
promoteValueToName = true;
}
/**
* Cancels immediately-nested calls to {@link #beginArray()} or {@link #beginObject()} and their
* matching calls to {@link #endArray} or {@link #endObject()}. Use this to compose JSON adapters
* without nesting.
*
* <p>For example, the following creates JSON with nested arrays: {@code [1,[2,3,4],5]}.
*
* <pre>{@code
*
* JsonAdapter<List<Integer>> integersAdapter = ...
*
* public void writeNumbers(JsonWriter writer) {
* writer.beginArray();
* writer.value(1);
* integersAdapter.toJson(writer, Arrays.asList(2, 3, 4));
* writer.value(5);
* writer.endArray();
* }
* }</pre>
*
* <p>With flattening we can create JSON with a single array {@code [1,2,3,4,5]}:
*
* <pre>{@code
*
* JsonAdapter<List<Integer>> integersAdapter = ...
*
* public void writeNumbers(JsonWriter writer) {
* writer.beginArray();
* int token = writer.beginFlatten();
* writer.value(1);
* integersAdapter.toJson(writer, Arrays.asList(2, 3, 4));
* writer.value(5);
* writer.endFlatten(token);
* writer.endArray();
* }
* }</pre>
*
* <p>This method flattens arrays within arrays:
*
* <pre>{@code
*
* Emit: [1, [2, 3, 4], 5]
* To produce: [1, 2, 3, 4, 5]
* }</pre>
*
* It also flattens objects within objects. Do not call {@link #name} before writing a flattened
* object.
*
* <pre>{@code
*
* Emit: {"a": 1, {"b": 2}, "c": 3}
* To Produce: {"a": 1, "b": 2, "c": 3}
* }</pre>
*
* Other combinations are permitted but do not perform flattening. For example, objects inside of
* arrays are not flattened:
*
* <pre>{@code
*
* Emit: [1, {"b": 2}, 3, [4, 5], 6]
* To Produce: [1, {"b": 2}, 3, 4, 5, 6]
* }</pre>
*
* <p>This method returns an opaque token. Callers must match all calls to this method with a call
* to {@link #endFlatten} with the matching token.
*/
public final int beginFlatten() {
int context = peekScope();
if (context != NONEMPTY_OBJECT && context != EMPTY_OBJECT
&& context != NONEMPTY_ARRAY && context != EMPTY_ARRAY) {
throw new IllegalStateException("Nesting problem.");
}
int token = flattenStackSize;
flattenStackSize = stackSize;
return token;
}
/** Ends nested call flattening created by {@link #beginFlatten}. */
public final void endFlatten(int token) {
flattenStackSize = token;
}
/**
* Returns a <a href="http://goessner.net/articles/JsonPath/">JsonPath</a> to
* the current location in the JSON value.

View File

@@ -0,0 +1,338 @@
/*
* Copyright (C) 2018 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.moshi;
import java.util.Arrays;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import org.junit.runners.Parameterized.Parameters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.fail;
/** Note that this test makes heavy use of nested blocks, but these are for readability only. */
@RunWith(Parameterized.class)
public final class FlattenTest {
@Parameter public JsonCodecFactory factory;
@Parameters(name = "{0}")
public static List<Object[]> parameters() {
return JsonCodecFactory.factories();
}
@Test public void flattenExample() throws Exception {
Moshi moshi = new Moshi.Builder().build();
JsonAdapter<List<Integer>> integersAdapter =
moshi.adapter(Types.newParameterizedType(List.class, Integer.class));
JsonWriter writer = factory.newWriter();
writer.beginArray();
int token = writer.beginFlatten();
writer.value(1);
integersAdapter.toJson(writer, Arrays.asList(2, 3, 4));
writer.value(5);
writer.endFlatten(token);
writer.endArray();
assertThat(factory.json()).isEqualTo("[1,2,3,4,5]");
}
@Test public void flattenObject() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginObject();
{
writer.name("a");
writer.value("aaa");
int token = writer.beginFlatten();
{
writer.beginObject();
{
writer.name("b");
writer.value("bbb");
}
writer.endObject();
}
writer.endFlatten(token);
writer.name("c");
writer.value("ccc");
}
writer.endObject();
assertThat(factory.json()).isEqualTo("{\"a\":\"aaa\",\"b\":\"bbb\",\"c\":\"ccc\"}");
}
@Test public void flattenArray() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginArray();
{
writer.value("a");
int token = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("b");
}
writer.endArray();
}
writer.endFlatten(token);
writer.value("c");
}
writer.endArray();
assertThat(factory.json()).isEqualTo("[\"a\",\"b\",\"c\"]");
}
@Test public void recursiveFlatten() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginArray();
{
writer.value("a");
int token1 = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("b");
int token2 = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("c");
}
writer.endArray();
}
writer.endFlatten(token2);
writer.value("d");
}
writer.endArray();
}
writer.endFlatten(token1);
writer.value("e");
}
writer.endArray();
assertThat(factory.json()).isEqualTo("[\"a\",\"b\",\"c\",\"d\",\"e\"]");
}
@Test public void flattenMultipleNested() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginArray();
{
writer.value("a");
int token = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("b");
}
writer.endArray();
writer.beginArray();
{
writer.value("c");
}
writer.endArray();
}
writer.endFlatten(token);
writer.value("d");
}
writer.endArray();
assertThat(factory.json()).isEqualTo("[\"a\",\"b\",\"c\",\"d\"]");
}
@Test public void flattenIsOnlyOneLevelDeep() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginArray();
{
writer.value("a");
int token = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("b");
writer.beginArray();
{
writer.value("c");
}
writer.endArray();
writer.value("d");
}
writer.endArray();
}
writer.endFlatten(token);
writer.value("e");
}
writer.endArray();
assertThat(factory.json()).isEqualTo("[\"a\",\"b\",[\"c\"],\"d\",\"e\"]");
}
@Test public void flattenOnlySomeChildren() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginArray();
{
writer.value("a");
int token = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("b");
}
writer.endArray();
}
writer.endFlatten(token);
writer.beginArray();
{
writer.value("c");
}
writer.endArray();
writer.value("d");
}
writer.endArray();
assertThat(factory.json()).isEqualTo("[\"a\",\"b\",[\"c\"],\"d\"]");
}
@Test public void multipleCallsToFlattenSameNesting() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginArray();
{
writer.value("a");
int token1 = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("b");
}
writer.endArray();
int token2 = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("c");
}
writer.endArray();
}
writer.endFlatten(token2);
writer.beginArray();
{
writer.value("d");
}
writer.endArray();
}
writer.endFlatten(token1);
writer.value("e");
}
writer.endArray();
assertThat(factory.json()).isEqualTo("[\"a\",\"b\",\"c\",\"d\",\"e\"]");
}
@Test public void deepFlatten() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginArray();
{
int token1 = writer.beginFlatten();
{
writer.beginArray();
{
int token2 = writer.beginFlatten();
{
writer.beginArray();
{
int token3 = writer.beginFlatten();
{
writer.beginArray();
{
writer.value("a");
}
writer.endArray();
}
writer.endFlatten(token3);
}
writer.endArray();
}
writer.endFlatten(token2);
}
writer.endArray();
}
writer.endFlatten(token1);
}
writer.endArray();
assertThat(factory.json()).isEqualTo("[\"a\"]");
}
@Test public void flattenTopLevel() {
JsonWriter writer = factory.newWriter();
try {
writer.beginFlatten();
fail();
} catch (IllegalStateException e) {
assertThat(e).hasMessage("Nesting problem.");
}
}
@Test public void flattenDoesNotImpactOtherTypesInObjects() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginObject();
{
int token = writer.beginFlatten();
writer.name("a");
writer.beginArray();
writer.value("aaa");
writer.endArray();
writer.beginObject();
{
writer.name("b");
writer.value("bbb");
}
writer.endObject();
writer.name("c");
writer.beginArray();
writer.value("ccc");
writer.endArray();
writer.endFlatten(token);
}
writer.endObject();
assertThat(factory.json()).isEqualTo("{\"a\":[\"aaa\"],\"b\":\"bbb\",\"c\":[\"ccc\"]}");
}
@Test public void flattenDoesNotImpactOtherTypesInArrays() throws Exception {
JsonWriter writer = factory.newWriter();
writer.beginArray();
{
int token = writer.beginFlatten();
{
writer.beginObject();
{
writer.name("a");
writer.value("aaa");
}
writer.endObject();
writer.beginArray();
{
writer.value("bbb");
}
writer.endArray();
writer.value("ccc");
writer.beginObject();
{
writer.name("d");
writer.value("ddd");
}
writer.endObject();
}
writer.endFlatten(token);
}
writer.endArray();
assertThat(factory.json()).isEqualTo("[{\"a\":\"aaa\"},\"bbb\",\"ccc\",{\"d\":\"ddd\"}]");
}
}