Skip to content

Commit dc7de95

Browse files
Add buffered lookahead for Jackson (#489) (#491)
Co-authored-by: Sylvain Wallez <[email protected]>
1 parent 85ebc5d commit dc7de95

File tree

7 files changed

+313
-46
lines changed

7 files changed

+313
-46
lines changed

java-client/src/main/java/co/elastic/clients/json/JsonpUtils.java

+34-23
Original file line numberDiff line numberDiff line change
@@ -163,42 +163,53 @@ public static <T> void serialize(T value, JsonGenerator generator, @Nullable Jso
163163
/**
164164
* Looks ahead a field value in the Json object from the upcoming object in a parser, which should be on the
165165
* START_OBJECT event.
166-
*
166+
* <p>
167167
* Returns a pair containing that value and a parser that should be used to actually parse the object
168168
* (the object has been consumed from the original one).
169169
*/
170170
public static Map.Entry<String, JsonParser> lookAheadFieldValue(
171171
String name, String defaultValue, JsonParser parser, JsonpMapper mapper
172172
) {
173-
// FIXME: need a buffering parser wrapper so that we don't roundtrip through a JsonObject and a String
174173
JsonLocation location = parser.getLocation();
175-
JsonObject object = parser.getObject();
176-
String result = object.getString(name, null);
177174

178-
if (result == null) {
179-
result = defaultValue;
180-
}
175+
if (parser instanceof LookAheadJsonParser) {
176+
// Fast buffered path
177+
Map.Entry<String, JsonParser> result = ((LookAheadJsonParser) parser).lookAheadFieldValue(name, defaultValue);
178+
if (result.getKey() == null) {
179+
throw new JsonpMappingException("Property '" + name + "' not found", location);
180+
}
181+
return result;
181182

182-
if (result == null) {
183-
throw new JsonpMappingException("Property '" + name + "' not found", location);
184-
}
183+
} else {
184+
// Unbuffered path: parse the object into a JsonObject, then extract the value and parse it again
185+
JsonObject object = parser.getObject();
186+
String result = object.getString(name, null);
185187

186-
JsonParser newParser = objectParser(object, mapper);
188+
if (result == null) {
189+
result = defaultValue;
190+
}
187191

188-
// Pin location to the start of the look ahead, as the new parser will return locations in its own buffer
189-
newParser = new DelegatingJsonParser(newParser) {
190-
@Override
191-
public JsonLocation getLocation() {
192-
return new JsonLocationImpl(location.getLineNumber(), location.getColumnNumber(), location.getStreamOffset()) {
193-
@Override
194-
public String toString() {
195-
return "(in object at " + super.toString().substring(1);
196-
}
197-
};
192+
if (result == null) {
193+
throw new JsonpMappingException("Property '" + name + "' not found", location);
198194
}
199-
};
200195

201-
return new AbstractMap.SimpleImmutableEntry<>(result, newParser);
196+
JsonParser newParser = objectParser(object, mapper);
197+
198+
// Pin location to the start of the look ahead, as the new parser will return locations in its own buffer
199+
newParser = new DelegatingJsonParser(newParser) {
200+
@Override
201+
public JsonLocation getLocation() {
202+
return new JsonLocationImpl(location.getLineNumber(), location.getColumnNumber(), location.getStreamOffset()) {
203+
@Override
204+
public String toString() {
205+
return "(in object at " + super.toString().substring(1);
206+
}
207+
};
208+
}
209+
};
210+
211+
return new AbstractMap.SimpleImmutableEntry<>(result, newParser);
212+
}
202213
}
203214

204215
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package co.elastic.clients.json;
21+
22+
import jakarta.json.stream.JsonParser;
23+
24+
import java.util.Map;
25+
26+
public interface LookAheadJsonParser extends JsonParser {
27+
28+
/**
29+
* Look ahead the value of a text property in the JSON stream. The parser must be on the {@code START_OBJECT} event.
30+
*
31+
* @param name the field name to look up.
32+
* @param defaultValue default value if the field is not found.
33+
* @return a pair containing the field value (or {@code null} if not found), and a parser to be used to read the JSON object.
34+
*/
35+
Map.Entry<String, JsonParser> lookAheadFieldValue(String name, String defaultValue);
36+
37+
/**
38+
* In union types, find the variant to be used by looking up property names in the JSON stream until we find one that
39+
* uniquely identifies the variant.
40+
*
41+
* @param <Variant> the type of variant descriptors used by the caller.
42+
* @param variants a map of variant descriptors, keyed by the property name that uniquely identifies the variant.
43+
* @return a pair containing the variant descriptor (or {@code null} if not found), and a parser to be used to read the JSON object.
44+
*/
45+
<Variant> Map.Entry<Variant, JsonParser> findVariant(Map<String, Variant> variants);
46+
}

java-client/src/main/java/co/elastic/clients/json/UnionDeserializer.java

+28-15
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import co.elastic.clients.util.ObjectBuilder;
2323
import jakarta.json.JsonObject;
24+
import jakarta.json.stream.JsonLocation;
2425
import jakarta.json.stream.JsonParser;
2526
import jakarta.json.stream.JsonParser.Event;
2627

@@ -203,12 +204,12 @@ public JsonpDeserializer<Union> build() {
203204
private final BiFunction<Kind, Member, Union> buildFn;
204205
private final EnumSet<Event> nativeEvents;
205206
private final Map<String, EventHandler<Union, Kind, Member>> objectMembers;
206-
private final Map<Event, EventHandler<Union, Kind, Member>> otherMembers;
207+
private final Map<Event, EventHandler<Union, Kind, Member>> nonObjectMembers;
207208
private final EventHandler<Union, Kind, Member> fallbackObjectMember;
208209

209210
public UnionDeserializer(
210211
List<SingleMemberHandler<Union, Kind, Member>> objectMembers,
211-
Map<Event, EventHandler<Union, Kind, Member>> otherMembers,
212+
Map<Event, EventHandler<Union, Kind, Member>> nonObjectMembers,
212213
BiFunction<Kind, Member, Union> buildFn
213214
) {
214215
this.buildFn = buildFn;
@@ -225,17 +226,17 @@ public UnionDeserializer(
225226
}
226227
}
227228

228-
this.otherMembers = otherMembers;
229+
this.nonObjectMembers = nonObjectMembers;
229230

230231
this.nativeEvents = EnumSet.noneOf(Event.class);
231-
for (EventHandler<Union, Kind, Member> member: otherMembers.values()) {
232+
for (EventHandler<Union, Kind, Member> member: nonObjectMembers.values()) {
232233
this.nativeEvents.addAll(member.nativeEvents());
233234
}
234235

235236
if (objectMembers.isEmpty()) {
236237
fallbackObjectMember = null;
237238
} else {
238-
fallbackObjectMember = this.otherMembers.remove(Event.START_OBJECT);
239+
fallbackObjectMember = this.nonObjectMembers.remove(Event.START_OBJECT);
239240
this.nativeEvents.add(Event.START_OBJECT);
240241
}
241242
}
@@ -260,32 +261,44 @@ public Union deserialize(JsonParser parser, JsonpMapper mapper) {
260261

261262
@Override
262263
public Union deserialize(JsonParser parser, JsonpMapper mapper, Event event) {
263-
EventHandler<Union, Kind, Member> member = otherMembers.get(event);
264+
EventHandler<Union, Kind, Member> member = nonObjectMembers.get(event);
265+
JsonLocation location = parser.getLocation();
264266

265267
if (member == null && event == Event.START_OBJECT && !objectMembers.isEmpty()) {
266-
// Parse as an object to find matching field names
267-
JsonObject object = parser.getObject();
268+
if (parser instanceof LookAheadJsonParser) {
269+
Map.Entry<EventHandler<Union, Kind, Member>, JsonParser> memberAndParser =
270+
((LookAheadJsonParser) parser).findVariant(objectMembers);
268271

269-
for (String field: object.keySet()) {
270-
member = objectMembers.get(field);
271-
if (member != null) {
272-
break;
272+
member = memberAndParser.getKey();
273+
// Parse the buffered parser
274+
parser = memberAndParser.getValue();
275+
276+
} else {
277+
// Parse as an object to find matching field names
278+
JsonObject object = parser.getObject();
279+
280+
for (String field: object.keySet()) {
281+
member = objectMembers.get(field);
282+
if (member != null) {
283+
break;
284+
}
273285
}
286+
287+
// Traverse the object we have inspected
288+
parser = JsonpUtils.objectParser(object, mapper);
274289
}
275290

276291
if (member == null) {
277292
member = fallbackObjectMember;
278293
}
279294

280295
if (member != null) {
281-
// Traverse the object we have inspected
282-
parser = JsonpUtils.objectParser(object, mapper);
283296
event = parser.next();
284297
}
285298
}
286299

287300
if (member == null) {
288-
throw new JsonpMappingException("Cannot determine what union member to deserialize", parser.getLocation());
301+
throw new JsonpMappingException("Cannot determine what union member to deserialize", location);
289302
}
290303

291304
return member.deserialize(parser, mapper, event, buildFn);

java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpParser.java

+100-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919

2020
package co.elastic.clients.json.jackson;
2121

22+
import co.elastic.clients.json.LookAheadJsonParser;
23+
import co.elastic.clients.json.UnexpectedJsonEventException;
2224
import com.fasterxml.jackson.core.JsonToken;
25+
import com.fasterxml.jackson.core.util.JsonParserSequence;
26+
import com.fasterxml.jackson.databind.util.TokenBuffer;
2327
import jakarta.json.JsonArray;
2428
import jakarta.json.JsonObject;
2529
import jakarta.json.JsonValue;
@@ -29,6 +33,7 @@
2933

3034
import java.io.IOException;
3135
import java.math.BigDecimal;
36+
import java.util.AbstractMap;
3237
import java.util.EnumMap;
3338
import java.util.Map;
3439
import java.util.NoSuchElementException;
@@ -42,7 +47,7 @@
4247
* getter method (e.g. {@link #getInt()} or {@link #getString()} should be called until the next call to {@link #next()}.
4348
* Such calls will throw an {@code IllegalStateException}.
4449
*/
45-
public class JacksonJsonpParser implements JsonParser {
50+
public class JacksonJsonpParser implements LookAheadJsonParser {
4651

4752
private final com.fasterxml.jackson.core.JsonParser parser;
4853

@@ -306,7 +311,100 @@ public Stream<JsonValue> getArrayStream() {
306311
*/
307312
@Override
308313
public Stream<JsonValue> getValueStream() {
309-
return JsonParser.super.getValueStream();
314+
return LookAheadJsonParser.super.getValueStream();
315+
}
316+
317+
//----- Look ahead methods
318+
319+
public Map.Entry<String, JsonParser> lookAheadFieldValue(String name, String defaultValue) {
320+
321+
TokenBuffer tb = new TokenBuffer(parser, null);
322+
323+
try {
324+
// The resulting parser must contain the full object, including START_EVENT
325+
tb.copyCurrentEvent(parser);
326+
while (parser.nextToken() != JsonToken.END_OBJECT) {
327+
328+
expectEvent(JsonToken.FIELD_NAME);
329+
// Do not copy current event here, each branch will take care of it
330+
331+
String fieldName = parser.getCurrentName();
332+
if (fieldName.equals(name)) {
333+
// Found
334+
tb.copyCurrentEvent(parser);
335+
expectNextEvent(JsonToken.VALUE_STRING);
336+
tb.copyCurrentEvent(parser);
337+
338+
return new AbstractMap.SimpleImmutableEntry<>(
339+
parser.getText(),
340+
new JacksonJsonpParser(JsonParserSequence.createFlattened(false, tb.asParser(), parser))
341+
);
342+
} else {
343+
tb.copyCurrentStructure(parser);
344+
}
345+
}
346+
// Copy ending END_OBJECT
347+
tb.copyCurrentEvent(parser);
348+
} catch (IOException e) {
349+
throw JacksonUtils.convertException(e);
350+
}
351+
352+
// Field not found
353+
return new AbstractMap.SimpleImmutableEntry<>(
354+
defaultValue,
355+
new JacksonJsonpParser(JsonParserSequence.createFlattened(false, tb.asParser(), parser))
356+
);
357+
}
358+
359+
@Override
360+
public <Variant> Map.Entry<Variant, JsonParser> findVariant(Map<String, Variant> variants) {
361+
// We're on a START_OBJECT event
362+
TokenBuffer tb = new TokenBuffer(parser, null);
363+
364+
try {
365+
// The resulting parser must contain the full object, including START_EVENT
366+
tb.copyCurrentEvent(parser);
367+
while (parser.nextToken() != JsonToken.END_OBJECT) {
368+
369+
expectEvent(JsonToken.FIELD_NAME);
370+
String fieldName = parser.getCurrentName();
371+
372+
Variant variant = variants.get(fieldName);
373+
if (variant != null) {
374+
tb.copyCurrentEvent(parser);
375+
return new AbstractMap.SimpleImmutableEntry<>(
376+
variant,
377+
new JacksonJsonpParser(JsonParserSequence.createFlattened(false, tb.asParser(), parser))
378+
);
379+
} else {
380+
tb.copyCurrentStructure(parser);
381+
}
382+
}
383+
// Copy ending END_OBJECT
384+
tb.copyCurrentEvent(parser);
385+
} catch (IOException e) {
386+
throw JacksonUtils.convertException(e);
387+
}
388+
389+
// No variant found: return the buffered parser and let the caller decide what to do.
390+
return new AbstractMap.SimpleImmutableEntry<>(
391+
null,
392+
new JacksonJsonpParser(JsonParserSequence.createFlattened(false, tb.asParser(), parser))
393+
);
394+
}
395+
396+
private void expectNextEvent(JsonToken expected) throws IOException {
397+
JsonToken event = parser.nextToken();
398+
if (event != expected) {
399+
throw new UnexpectedJsonEventException(this, tokenToEvent.get(event), tokenToEvent.get(expected));
400+
}
401+
}
402+
403+
private void expectEvent(JsonToken expected) {
404+
JsonToken event = parser.currentToken();
405+
if (event != expected) {
406+
throw new UnexpectedJsonEventException(this, tokenToEvent.get(event), tokenToEvent.get(expected));
407+
}
310408
}
311409
}
312410

java-client/src/test/java/co/elastic/clients/elasticsearch/model/VariantsTest.java

+8
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ public void testNestedTaggedUnionWithDefaultTag() {
185185
assertEquals(256, mappings.properties().get("id").text().fields().get("keyword").keyword().ignoreAbove().longValue());
186186
}
187187

188+
@Test
189+
public void testEmptyProperty() {
190+
// Edge case where we have a property with no fields and no type
191+
String json = "{}";
192+
Property property = fromJson(json, Property.class);
193+
assertEquals(Property.Kind.Object, property._kind());
194+
}
195+
188196
@Test
189197
public void testNestedVariantsWithContainerProperties() {
190198

java-client/src/test/java/co/elastic/clients/json/JsonpMappingExceptionTest.java

+1-3
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public void testLookAhead() {
9595
"}";
9696

9797
// Error deserializing co.elastic.clients.elasticsearch._types.mapping.TextProperty:
98-
// Unknown field 'baz' (JSON path: properties['foo-bar'].baz) (in object at line no=1, column no=36, offset=35)
98+
// Unknown field 'baz' (JSON path: properties['foo-bar'].baz) (...line no=1, column no=36, offset=35)
9999

100100
JsonpMappingException e = assertThrows(JsonpMappingException.class, () -> {
101101
fromJson(json, TypeMapping.class);
@@ -106,7 +106,5 @@ public void testLookAhead() {
106106

107107
String msg = e.getMessage();
108108
assertTrue(msg.contains("Unknown field 'baz'"));
109-
// Check look ahead position (see JsonpUtils.lookAheadFieldValue)
110-
assertTrue(msg.contains("(in object at line no="));
111109
}
112110
}

0 commit comments

Comments
 (0)