Implement peekJson() for JsonValueReader

The JsonUtf8Reader variant relies on an update to Okio that we're
not quite ready for.

This one became straightforward after I changed out the iterators
to be cloneable. That's necessary to split one iterator into two.
It's too bad the platform's built-in iterators are not cloneable;
that would have been convenient and potentially more efficient.

Related to https://github.com/square/moshi/issues/672
This commit is contained in:
Jesse Wilson 2018-09-23 23:03:45 -04:00
parent d6ad1b8bad
commit 00dcac60d4
5 changed files with 239 additions and 20 deletions

View file

@ -180,10 +180,10 @@ public abstract class JsonReader implements Closeable {
// The nesting stack. Using a manual array rather than an ArrayList saves 20%. This stack will
// grow itself up to 256 levels of nesting including the top-level document. Deeper nesting is
// prone to trigger StackOverflowErrors.
int stackSize = 0;
int[] scopes = new int[32];
String[] pathNames = new String[32];
int[] pathIndices = new int[32];
int stackSize;
int[] scopes;
String[] pathNames;
int[] pathIndices;
/** True to accept non-spec compliant JSON. */
boolean lenient;
@ -196,8 +196,21 @@ public abstract class JsonReader implements Closeable {
return new JsonUtf8Reader(source);
}
// Package-private to control subclasses.
JsonReader() {
// Package-private to control subclasses.
scopes = new int[32];
pathNames = new String[32];
pathIndices = new int[32];
}
// Package-private to control subclasses.
JsonReader(JsonReader copyFrom) {
this.stackSize = copyFrom.stackSize;
this.scopes = copyFrom.scopes.clone();
this.pathNames = copyFrom.pathNames.clone();
this.pathIndices = copyFrom.pathIndices.clone();
this.lenient = copyFrom.lenient;
this.failOnUnknown = copyFrom.failOnUnknown;
}
final void pushScope(int newTop) {
@ -461,6 +474,32 @@ public abstract class JsonReader implements Closeable {
}
}
/**
* Returns a new {@code JsonReader} that can read data from this {@code JsonReader} without
* consuming it. The returned reader becomes invalid once this one is next read or closed.
*
* For example, we can use `peek()` to lookahead and read the same data multiple times.
*
* <pre> {@code
*
* Buffer buffer = new Buffer();
* buffer.writeUtf8("[123, 456, 789]")
*
* JsonReader jsonReader = JsonReader.of(buffer);
* jsonReader.beginArray();
* jsonReader.nextInt(); // Returns 123, reader contains 456, 789 and ].
*
* JsonReader peek = reader.peekReader();
* peek.nextInt() // Returns 456.
* peek.nextInt() // Returns 789.
* peek.endArray()
*
* jsonReader.nextInt() // Returns 456, reader contains 789 and ].
* }</pre>
*/
// TODO(jwilson): make this public once it's supported in JsonUtf8Reader.
abstract JsonReader peekJson();
/**
* Returns a <a href="http://goessner.net/articles/JsonPath/">JsonPath</a> to
* the current location in the JSON value.

View file

@ -1059,6 +1059,10 @@ final class JsonUtf8Reader extends JsonReader {
return false;
}
@Override JsonReader peekJson() {
throw new UnsupportedOperationException("TODO");
}
@Override public String toString() {
return "JsonReader(" + source + ")";
}

View file

@ -20,10 +20,11 @@ import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import javax.annotation.Nullable;
import static com.squareup.moshi.JsonScope.CLOSED;
/**
* This class reads a JSON document by traversing a Java object comprising maps, lists, and JSON
* primitives. It does depth-first traversal keeping a stack starting with the root object. During
@ -32,11 +33,11 @@ import javax.annotation.Nullable;
* <ul>
* <li>The next element to act upon is on the top of the stack.
* <li>When the top of the stack is a {@link List}, calling {@link #beginArray()} replaces the
* list with a {@link ListIterator}. The first element of the iterator is pushed on top of the
* list with a {@link JsonIterator}. The first element of the iterator is pushed on top of the
* iterator.
* <li>Similarly, when the top of the stack is a {@link Map}, calling {@link #beginObject()}
* replaces the map with an {@link Iterator} of its entries. The first element of the iterator
* is pushed on top of the iterator.
* replaces the map with an {@link JsonIterator} of its entries. The first element of the
* iterator is pushed on top of the iterator.
* <li>When the top of the stack is a {@link Map.Entry}, calling {@link #nextName()} returns the
* entry's key and replaces the entry with its value on the stack.
* <li>When an element is consumed it is popped. If the new top of the stack has a non-exhausted
@ -49,17 +50,31 @@ final class JsonValueReader extends JsonReader {
/** Sentinel object pushed on {@link #stack} when the reader is closed. */
private static final Object JSON_READER_CLOSED = new Object();
private Object[] stack = new Object[32];
private Object[] stack;
JsonValueReader(Object root) {
scopes[stackSize] = JsonScope.NONEMPTY_DOCUMENT;
stack = new Object[32];
stack[stackSize++] = root;
}
/** Copy-constructor makes a deep copy for peeking. */
JsonValueReader(JsonValueReader copyFrom) {
super(copyFrom);
stack = copyFrom.stack.clone();
for (int i = 0; i < stackSize; i++) {
if (stack[i] instanceof JsonIterator) {
stack[i] = ((JsonIterator) stack[i]).clone();
}
}
}
@Override public void beginArray() throws IOException {
List<?> peeked = require(List.class, Token.BEGIN_ARRAY);
ListIterator<?> iterator = peeked.listIterator();
JsonIterator iterator = new JsonIterator(
Token.END_ARRAY, peeked.toArray(new Object[peeked.size()]), 0);
stack[stackSize - 1] = iterator;
scopes[stackSize - 1] = JsonScope.EMPTY_ARRAY;
pathIndices[stackSize - 1] = 0;
@ -71,8 +86,8 @@ final class JsonValueReader extends JsonReader {
}
@Override public void endArray() throws IOException {
ListIterator<?> peeked = require(ListIterator.class, Token.END_ARRAY);
if (peeked.hasNext()) {
JsonIterator peeked = require(JsonIterator.class, Token.END_ARRAY);
if (peeked.endToken != Token.END_ARRAY || peeked.hasNext()) {
throw typeMismatch(peeked, Token.END_ARRAY);
}
remove();
@ -81,7 +96,8 @@ final class JsonValueReader extends JsonReader {
@Override public void beginObject() throws IOException {
Map<?, ?> peeked = require(Map.class, Token.BEGIN_OBJECT);
Iterator<?> iterator = peeked.entrySet().iterator();
JsonIterator iterator = new JsonIterator(
Token.END_OBJECT, peeked.entrySet().toArray(new Object[peeked.size()]), 0);
stack[stackSize - 1] = iterator;
scopes[stackSize - 1] = JsonScope.EMPTY_OBJECT;
@ -92,8 +108,8 @@ final class JsonValueReader extends JsonReader {
}
@Override public void endObject() throws IOException {
Iterator<?> peeked = require(Iterator.class, Token.END_OBJECT);
if (peeked instanceof ListIterator || peeked.hasNext()) {
JsonIterator peeked = require(JsonIterator.class, Token.END_OBJECT);
if (peeked.endToken != Token.END_OBJECT || peeked.hasNext()) {
throw typeMismatch(peeked, Token.END_OBJECT);
}
pathNames[stackSize - 1] = null;
@ -112,8 +128,7 @@ final class JsonValueReader extends JsonReader {
// If the top of the stack is an iterator, take its first element and push it on the stack.
Object peeked = stack[stackSize - 1];
if (peeked instanceof ListIterator) return Token.END_ARRAY;
if (peeked instanceof Iterator) return Token.END_OBJECT;
if (peeked instanceof JsonIterator) return ((JsonIterator) peeked).endToken;
if (peeked instanceof List) return Token.BEGIN_ARRAY;
if (peeked instanceof Map) return Token.BEGIN_OBJECT;
if (peeked instanceof Map.Entry) return Token.NAME;
@ -303,6 +318,10 @@ final class JsonValueReader extends JsonReader {
}
}
@Override JsonReader peekJson() {
return new JsonValueReader(this);
}
@Override void promoteNameToValue() throws IOException {
if (hasNext()) {
String name = nextName();
@ -313,7 +332,7 @@ final class JsonValueReader extends JsonReader {
@Override public void close() throws IOException {
Arrays.fill(stack, 0, stackSize, null);
stack[0] = JSON_READER_CLOSED;
scopes[0] = JsonScope.CLOSED;
scopes[0] = CLOSED;
stackSize = 1;
}
@ -374,4 +393,33 @@ final class JsonValueReader extends JsonReader {
}
}
}
static final class JsonIterator implements Iterator<Object>, Cloneable {
final Token endToken;
final Object[] array;
int next;
JsonIterator(Token endToken, Object[] array, int next) {
this.endToken = endToken;
this.array = array;
this.next = next;
}
@Override public boolean hasNext() {
return next < array.length;
}
@Override public Object next() {
return array[next++];
}
@Override public void remove() {
throw new UnsupportedOperationException();
}
@Override protected JsonIterator clone() {
// No need to copy the array; it's read-only.
return new JsonIterator(endToken, array, next);
}
}
}

View file

@ -96,9 +96,38 @@ abstract class JsonCodecFactory {
}
};
final JsonCodecFactory valuePeek = new JsonCodecFactory() {
@Override public JsonReader newReader(String json) throws IOException {
return value.newReader(json).peekJson();
}
// TODO(jwilson): fix precision checks and delete his method.
@Override boolean implementsStrictPrecision() {
return false;
}
@Override JsonWriter newWriter() {
return value.newWriter();
}
@Override String json() {
return value.json();
}
// TODO(jwilson): support BigDecimal and BigInteger and delete his method.
@Override boolean supportsBigNumbers() {
return false;
}
@Override public String toString() {
return "ValuePeek";
}
};
return Arrays.asList(
new Object[] { utf8 },
new Object[] { value });
new Object[] { value },
new Object[] { valuePeek });
}
abstract JsonReader newReader(String json) throws IOException;

View file

@ -985,4 +985,103 @@ public final class JsonReaderTest {
reader.readJsonValue();
assertThat(reader.hasNext()).isFalse();
}
@Test public void basicPeekJson() throws IOException {
JsonReader reader = newReader("{\"a\":12,\"b\":[34,56],\"c\":78}");
assumeTrue(reader instanceof JsonValueReader); // Not implemented for JsonUtf8Reader yet!
reader.beginObject();
assertThat(reader.nextName()).isEqualTo("a");
assertThat(reader.nextInt()).isEqualTo(12);
assertThat(reader.nextName()).isEqualTo("b");
reader.beginArray();
assertThat(reader.nextInt()).isEqualTo(34);
// Peek.
JsonReader peekReader = reader.peekJson();
assertThat(peekReader.nextInt()).isEqualTo(56);
peekReader.endArray();
assertThat(peekReader.nextName()).isEqualTo("c");
assertThat(peekReader.nextInt()).isEqualTo(78);
peekReader.endObject();
assertThat(peekReader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT);
// Read again.
assertThat(reader.nextInt()).isEqualTo(56);
reader.endArray();
assertThat(reader.nextName()).isEqualTo("c");
assertThat(reader.nextInt()).isEqualTo(78);
reader.endObject();
assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT);
}
/**
* We have a document that requires 12 operations to read. We read it step-by-step with one real
* reader. Before each of the real readers operations we create a peeking reader and let it read
* the rest of the document.
*/
@Test public void peekJsonReader() throws IOException {
JsonReader reader = newReader("[12,34,{\"a\":56,\"b\":78},90]");
assumeTrue(reader instanceof JsonValueReader); // Not implemented for JsonUtf8Reader yet!
for (int i = 0; i < 12; i++) {
readPeek12Steps(reader.peekJson(), i, 12);
readPeek12Steps(reader, i, i + 1);
}
}
/**
* Read a fragment of {@code reader}. This assumes the fixed document defined in {@link
* #peekJsonReader} and reads a range of it on each call.
*/
private void readPeek12Steps(JsonReader reader, int from, int until) throws IOException {
switch (from) {
case 0:
if (until == 0) break;
reader.beginArray();
assertThat(reader.getPath()).isEqualTo("$[0]");
case 1:
if (until == 1) break;
assertThat(reader.nextInt()).isEqualTo(12);
assertThat(reader.getPath()).isEqualTo("$[1]");
case 2:
if (until == 2) break;
assertThat(reader.nextInt()).isEqualTo(34);
assertThat(reader.getPath()).isEqualTo("$[2]");
case 3:
if (until == 3) break;
reader.beginObject();
assertThat(reader.getPath()).isEqualTo("$[2].");
case 4:
if (until == 4) break;
assertThat(reader.nextName()).isEqualTo("a");
assertThat(reader.getPath()).isEqualTo("$[2].a");
case 5:
if (until == 5) break;
assertThat(reader.nextInt()).isEqualTo(56);
assertThat(reader.getPath()).isEqualTo("$[2].a");
case 6:
if (until == 6) break;
assertThat(reader.nextName()).isEqualTo("b");
assertThat(reader.getPath()).isEqualTo("$[2].b");
case 7:
if (until == 7) break;
assertThat(reader.nextInt()).isEqualTo(78);
assertThat(reader.getPath()).isEqualTo("$[2].b");
case 8:
if (until == 8) break;
reader.endObject();
assertThat(reader.getPath()).isEqualTo("$[3]");
case 9:
if (until == 9) break;
assertThat(reader.nextInt()).isEqualTo(90);
assertThat(reader.getPath()).isEqualTo("$[4]");
case 10:
if (until == 10) break;
reader.endArray();
assertThat(reader.getPath()).isEqualTo("$");
case 11:
if (until == 11) break;
assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT);
assertThat(reader.getPath()).isEqualTo("$");
}
}
}