diff --git a/adapters/src/main/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactory.java b/adapters/src/main/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactory.java index 7ff47b1..0cb5f69 100644 --- a/adapters/src/main/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactory.java +++ b/adapters/src/main/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactory.java @@ -103,7 +103,7 @@ final class RuntimeJsonAdapterFactory implements JsonAdapter.Factory { return null; } - List> jsonAdapters = new ArrayList<>(); + List> jsonAdapters = new ArrayList<>(subtypes.size()); for (int i = 0, size = subtypes.size(); i < size; i++) { jsonAdapters.add(moshi.adapter(subtypes.get(i))); } diff --git a/moshi/src/main/java/com/squareup/moshi/JsonUtf8Writer.java b/moshi/src/main/java/com/squareup/moshi/JsonUtf8Writer.java index 7a7a727..5aa749a 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonUtf8Writer.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonUtf8Writer.java @@ -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! diff --git a/moshi/src/main/java/com/squareup/moshi/JsonValueWriter.java b/moshi/src/main/java/com/squareup/moshi/JsonValueWriter.java index 64878ef..6ab79ee 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonValueWriter.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonValueWriter.java @@ -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 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 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; diff --git a/moshi/src/main/java/com/squareup/moshi/JsonWriter.java b/moshi/src/main/java/com/squareup/moshi/JsonWriter.java index 678171c..fa4137b 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonWriter.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonWriter.java @@ -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: + * + *
    + *
  • If -1, no begin/end pairs are being suppressed. + *
  • If positive, this is the deepest stack size whose begin/end pairs are eligible to be + * flattened. + *
  • If negative, it is the bitwise inverse (~) of the deepest stack size whose begin/end + * pairs have been flattened. + *
+ * + *

We differentiate between what layer would be flattened (positive) from what layer is being + * flattened (negative) so that we don't double-flatten. + * + *

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. + * + *

For example, the following creates JSON with nested arrays: {@code [1,[2,3,4],5]}. + * + *

{@code
+   *
+   *   JsonAdapter> integersAdapter = ...
+   *
+   *   public void writeNumbers(JsonWriter writer) {
+   *     writer.beginArray();
+   *     writer.value(1);
+   *     integersAdapter.toJson(writer, Arrays.asList(2, 3, 4));
+   *     writer.value(5);
+   *     writer.endArray();
+   *   }
+   * }
+ * + *

With flattening we can create JSON with a single array {@code [1,2,3,4,5]}: + * + *

{@code
+   *
+   *   JsonAdapter> 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();
+   *   }
+   * }
+ * + *

This method flattens arrays within arrays: + * + *

{@code
+   *
+   *   Emit:       [1, [2, 3, 4], 5]
+   *   To produce: [1, 2, 3, 4, 5]
+   * }
+ * + * It also flattens objects within objects. Do not call {@link #name} before writing a flattened + * object. + * + *
{@code
+   *
+   *   Emit:       {"a": 1, {"b": 2}, "c": 3}
+   *   To Produce: {"a": 1, "b": 2, "c": 3}
+   * }
+ * + * Other combinations are permitted but do not perform flattening. For example, objects inside of + * arrays are not flattened: + * + *
{@code
+   *
+   *   Emit:       [1, {"b": 2}, 3, [4, 5], 6]
+   *   To Produce: [1, {"b": 2}, 3, 4, 5, 6]
+   * }
+ * + *

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 JsonPath to * the current location in the JSON value. diff --git a/moshi/src/test/java/com/squareup/moshi/FlattenTest.java b/moshi/src/test/java/com/squareup/moshi/FlattenTest.java new file mode 100644 index 0000000..d69e2fa --- /dev/null +++ b/moshi/src/test/java/com/squareup/moshi/FlattenTest.java @@ -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 parameters() { + return JsonCodecFactory.factories(); + } + + @Test public void flattenExample() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter> 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\"}]"); + } +}