diff --git a/docs/troubleshooting/index.asciidoc b/docs/troubleshooting/index.asciidoc index 6be99e1b1..25435df68 100644 --- a/docs/troubleshooting/index.asciidoc +++ b/docs/troubleshooting/index.asciidoc @@ -7,6 +7,7 @@ .Exceptions * <> +* <> // [[debugging]] @@ -16,3 +17,4 @@ // === Elasticsearch deprecation warnings include::missing-required-property.asciidoc[] +include::serialize-without-typed-keys.asciidoc[] diff --git a/docs/troubleshooting/serialize-without-typed-keys.asciidoc b/docs/troubleshooting/serialize-without-typed-keys.asciidoc new file mode 100644 index 000000000..0d39c178d --- /dev/null +++ b/docs/troubleshooting/serialize-without-typed-keys.asciidoc @@ -0,0 +1,24 @@ +[[serialize-without-typed-keys]] +=== Serializing aggregations and suggestions without typed keys + +{es} search requests accept a `typed_key` parameter that allow returning type information along with the name in aggregation and suggestion results (see the {es-docs}/search-aggregations.html#return-agg-type[aggregations documentation] for additional details). + +The {java-client} always adds this parameter to search requests, as type information is needed to know the concrete class that should be used to deserialize aggregation and suggestion results. + +Symmetrically, the {java-client} also serializes aggregation and suggestion results using this `typed_keys` format, so that it can correctly deserialize the results of its own serialization. + +["source","java"] +-------------------------------------------------- +ElasticsearchClient esClient = ... +include-tagged::{doc-tests-src}/troubleshooting/TroubleShootingTests.java[aggregation-typed-keys] +-------------------------------------------------- + +However, in some use cases serializing objects in the `typed_keys` format may not be desirable, for example when the {java-client} is used in an application that acts as a front-end to other services that expect the default format for aggregations and suggestions. + +You can disable `typed_keys` serialization by setting the `JsonpMapperFeatures.SERIALIZE_TYPED_KEYS` attribute to `false` on your mapper object: + +["source","java"] +-------------------------------------------------- +ElasticsearchClient esClient = ... +include-tagged::{doc-tests-src}/troubleshooting/TroubleShootingTests.java[aggregation-no-typed-keys] +-------------------------------------------------- diff --git a/java-client/src/main/java/co/elastic/clients/json/ExternallyTaggedUnion.java b/java-client/src/main/java/co/elastic/clients/json/ExternallyTaggedUnion.java index ec5d52111..5dde8e5e8 100644 --- a/java-client/src/main/java/co/elastic/clients/json/ExternallyTaggedUnion.java +++ b/java-client/src/main/java/co/elastic/clients/json/ExternallyTaggedUnion.java @@ -153,7 +153,10 @@ public void deserializeEntry(String key, JsonParser parser, JsonpMapper mapper, } /** - * Serialize an externally tagged union using the typed keys encoding. + * Serialize a map of externally tagged union objects. + *

+ * If {@link JsonpMapperFeatures#SERIALIZE_TYPED_KEYS} is true (the default), the typed keys encoding + * (type#name) is used. */ static > void serializeTypedKeys( Map map, JsonGenerator generator, JsonpMapper mapper @@ -163,36 +166,65 @@ public void deserializeEntry(String key, JsonParser parser, JsonpMapper mapper, generator.writeEnd(); } + /** + * Serialize a map of externally tagged union object arrays. + *

+ * If {@link JsonpMapperFeatures#SERIALIZE_TYPED_KEYS} is true (the default), the typed keys encoding + * (type#name) is used. + */ static > void serializeTypedKeysArray( Map> map, JsonGenerator generator, JsonpMapper mapper ) { generator.writeStartObject(); - for (Map.Entry> entry: map.entrySet()) { - List list = entry.getValue(); - if (list.isEmpty()) { - continue; // We can't know the kind, skip this entry - } - generator.writeKey(list.get(0)._kind().jsonValue() + "#" + entry.getKey()); - generator.writeStartArray(); - for (T value: list) { - value.serialize(generator, mapper); + if (mapper.attribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, true)) { + for (Map.Entry> entry: map.entrySet()) { + List list = entry.getValue(); + if (list.isEmpty()) { + continue; // We can't know the kind, skip this entry + } + + generator.writeKey(list.get(0)._kind().jsonValue() + "#" + entry.getKey()); + generator.writeStartArray(); + for (T value: list) { + value.serialize(generator, mapper); + } + generator.writeEnd(); + } + } else { + for (Map.Entry> entry: map.entrySet()) { + generator.writeKey(entry.getKey()); + generator.writeStartArray(); + for (T value: entry.getValue()) { + value.serialize(generator, mapper); + } + generator.writeEnd(); } - generator.writeEnd(); } + generator.writeEnd(); } /** - * Serialize an externally tagged union using the typed keys encoding, without the enclosing start/end object. + * Serialize a map of externally tagged union objects, without the enclosing start/end object. + *

+ * If {@link JsonpMapperFeatures#SERIALIZE_TYPED_KEYS} is true (the default), the typed keys encoding + * (type#name) is used. */ static > void serializeTypedKeysInner( Map map, JsonGenerator generator, JsonpMapper mapper ) { - for (Map.Entry entry: map.entrySet()) { - T value = entry.getValue(); - generator.writeKey(value._kind().jsonValue() + "#" + entry.getKey()); - value.serialize(generator, mapper); + if (mapper.attribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, true)) { + for (Map.Entry entry: map.entrySet()) { + T value = entry.getValue(); + generator.writeKey(value._kind().jsonValue() + "#" + entry.getKey()); + value.serialize(generator, mapper); + } + } else { + for (Map.Entry entry: map.entrySet()) { + generator.writeKey(entry.getKey()); + entry.getValue().serialize(generator, mapper); + } } } } diff --git a/java-client/src/main/java/co/elastic/clients/json/JsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/JsonpMapper.java index a47992b9b..a4a259875 100644 --- a/java-client/src/main/java/co/elastic/clients/json/JsonpMapper.java +++ b/java-client/src/main/java/co/elastic/clients/json/JsonpMapper.java @@ -77,9 +77,12 @@ default T attribute(String name, T defaultValue) { } /** - * Create a new mapper with a named attribute that delegates to this one. + * Create a new mapper with an additional attribute. + *

+ * The {@link JsonpMapperFeatures} class contains the names of attributes that all implementations of + * JsonpMapper must implement. + * + * @see JsonpMapperFeatures */ - default JsonpMapper withAttribute(String name, T value) { - return new AttributedJsonpMapper(this, name, value); - } + JsonpMapper withAttribute(String name, T value); } diff --git a/java-client/src/main/java/co/elastic/clients/json/JsonpMapperBase.java b/java-client/src/main/java/co/elastic/clients/json/JsonpMapperBase.java index b3204170d..f24b3daf5 100644 --- a/java-client/src/main/java/co/elastic/clients/json/JsonpMapperBase.java +++ b/java-client/src/main/java/co/elastic/clients/json/JsonpMapperBase.java @@ -25,12 +25,41 @@ import javax.annotation.Nullable; import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; public abstract class JsonpMapperBase implements JsonpMapper { /** Get a serializer when none of the builtin ones are applicable */ protected abstract JsonpDeserializer getDefaultDeserializer(Class clazz); + private Map attributes; + + @Nullable + @Override + @SuppressWarnings("unchecked") + public T attribute(String name) { + return attributes == null ? null : (T)attributes.get(name); + } + + /** + * Updates attributes to a copy of the current ones with an additional key/value pair. + * + * Mutates the current mapper, intended to be used in implementations of {@link #withAttribute(String, Object)} + */ + protected JsonpMapperBase addAttribute(String name, Object value) { + if (attributes == null) { + this.attributes = Collections.singletonMap(name, value); + } else { + Map newAttrs = new HashMap<>(attributes.size() + 1); + newAttrs.putAll(attributes); + newAttrs.put(name, value); + this.attributes = newAttrs; + } + return this; + } + @Override public T deserialize(JsonParser parser, Class clazz) { JsonpDeserializer deserializer = findDeserializer(clazz); diff --git a/java-client/src/main/java/co/elastic/clients/json/AttributedJsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/JsonpMapperFeatures.java similarity index 59% rename from java-client/src/main/java/co/elastic/clients/json/AttributedJsonpMapper.java rename to java-client/src/main/java/co/elastic/clients/json/JsonpMapperFeatures.java index c3dec38d8..fad745c9d 100644 --- a/java-client/src/main/java/co/elastic/clients/json/AttributedJsonpMapper.java +++ b/java-client/src/main/java/co/elastic/clients/json/JsonpMapperFeatures.java @@ -19,27 +19,11 @@ package co.elastic.clients.json; -import javax.annotation.Nullable; - -class AttributedJsonpMapper extends DelegatingJsonpMapper { - - private final String name; - private final Object value; +/** + * Defines attribute names for {@link JsonpMapper} features. + */ +public class JsonpMapperFeatures { - AttributedJsonpMapper(JsonpMapper mapper, String name, Object value) { - super(mapper); - this.name = name; - this.value = value; - } + public static final String SERIALIZE_TYPED_KEYS = JsonpMapperFeatures.class.getName() + ":SERIALIZE_TYPED_KEYS"; - @Override - @Nullable - @SuppressWarnings("unchecked") - public T attribute(String name) { - if (this.name.equals(name)) { - return (T)this.value; - } else { - return mapper.attribute(name); - } - } } diff --git a/java-client/src/main/java/co/elastic/clients/json/SimpleJsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/SimpleJsonpMapper.java index 7faf59a0b..98591b3d7 100644 --- a/java-client/src/main/java/co/elastic/clients/json/SimpleJsonpMapper.java +++ b/java-client/src/main/java/co/elastic/clients/json/SimpleJsonpMapper.java @@ -77,6 +77,11 @@ public SimpleJsonpMapper() { this(true); } + @Override + public JsonpMapper withAttribute(String name, T value) { + return new SimpleJsonpMapper(this.ignoreUnknownFields).addAttribute(name, value); + } + @Override public boolean ignoreUnknownFields() { return ignoreUnknownFields; diff --git a/java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpMapper.java index 4a8410c70..3a6e827ff 100644 --- a/java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpMapper.java +++ b/java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpMapper.java @@ -39,18 +39,30 @@ public class JacksonJsonpMapper extends JsonpMapperBase { private final JacksonJsonProvider provider; private final ObjectMapper objectMapper; + private JacksonJsonpMapper(ObjectMapper objectMapper, JacksonJsonProvider provider) { + this.objectMapper = objectMapper; + this.provider = provider; + } + public JacksonJsonpMapper(ObjectMapper objectMapper) { - this.objectMapper = objectMapper - .configure(SerializationFeature.INDENT_OUTPUT, false) - .setSerializationInclusion(JsonInclude.Include.NON_NULL); - // Creating the json factory from the mapper ensures it will be returned by JsonParser.getCodec() - this.provider = new JacksonJsonProvider(this.objectMapper.getFactory()); + this( + objectMapper + .configure(SerializationFeature.INDENT_OUTPUT, false) + .setSerializationInclusion(JsonInclude.Include.NON_NULL), + // Creating the json factory from the mapper ensures it will be returned by JsonParser.getCodec() + new JacksonJsonProvider(objectMapper.getFactory()) + ); } public JacksonJsonpMapper() { this(new ObjectMapper()); } + @Override + public JsonpMapper withAttribute(String name, T value) { + return new JacksonJsonpMapper(this.objectMapper, this.provider).addAttribute(name, value); + } + /** * Returns the underlying Jackson mapper. */ diff --git a/java-client/src/main/java/co/elastic/clients/json/jsonb/JsonbJsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/jsonb/JsonbJsonpMapper.java index 823d3616a..d5f198fdd 100644 --- a/java-client/src/main/java/co/elastic/clients/json/jsonb/JsonbJsonpMapper.java +++ b/java-client/src/main/java/co/elastic/clients/json/jsonb/JsonbJsonpMapper.java @@ -54,6 +54,11 @@ public JsonbJsonpMapper() { this(JsonpUtils.provider(), JsonbProvider.provider()); } + @Override + public JsonpMapper withAttribute(String name, T value) { + return new JsonbJsonpMapper(this.jsonProvider, this.jsonb).addAttribute(name, value); + } + @Override protected JsonpDeserializer getDefaultDeserializer(Class clazz) { return new Deserializer<>(clazz); diff --git a/java-client/src/main/java/co/elastic/clients/util/WithJsonObjectBuilderBase.java b/java-client/src/main/java/co/elastic/clients/util/WithJsonObjectBuilderBase.java index 224407961..fc40dc3dc 100644 --- a/java-client/src/main/java/co/elastic/clients/util/WithJsonObjectBuilderBase.java +++ b/java-client/src/main/java/co/elastic/clients/util/WithJsonObjectBuilderBase.java @@ -47,22 +47,33 @@ public B withJson(JsonParser parser, JsonpMapper mapper) { } // Generic parameters are always deserialized to JsonData unless the parent mapper can provide a deserializer - mapper = new DelegatingJsonpMapper(mapper) { - @Override - public T attribute(String name) { - T attr = mapper.attribute(name); - if (attr == null && name.startsWith("co.elastic.clients:Deserializer")) { - @SuppressWarnings("unchecked") - T result = (T)JsonData._DESERIALIZER; - return result; - } else { - return attr; - } - } - }; + mapper = new WithJsonMapper(mapper); @SuppressWarnings("unchecked") ObjectDeserializer builderDeser = (ObjectDeserializer) DelegatingDeserializer.unwrap(classDeser); return builderDeser.deserialize(self(), parser, mapper, parser.next()); } + + private static class WithJsonMapper extends DelegatingJsonpMapper { + WithJsonMapper(JsonpMapper parent) { + super(parent); + } + + @Override + public T attribute(String name) { + T attr = mapper.attribute(name); + if (attr == null && name.startsWith("co.elastic.clients:Deserializer")) { + @SuppressWarnings("unchecked") + T result = (T)JsonData._DESERIALIZER; + return result; + } else { + return attr; + } + } + + @Override + public JsonpMapper withAttribute(String name, T value) { + return new WithJsonMapper(this.mapper.withAttribute(name, value)); + } + } } diff --git a/java-client/src/test/java/co/elastic/clients/documentation/troubleshooting/TroubleShootingTests.java b/java-client/src/test/java/co/elastic/clients/documentation/troubleshooting/TroubleShootingTests.java new file mode 100644 index 000000000..7c1478244 --- /dev/null +++ b/java-client/src/test/java/co/elastic/clients/documentation/troubleshooting/TroubleShootingTests.java @@ -0,0 +1,93 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 co.elastic.clients.documentation.troubleshooting; + +import co.elastic.clients.documentation.DocTestsTransport; +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.TotalHitsRelation; +import co.elastic.clients.json.JsonpMapper; +import co.elastic.clients.json.JsonpMapperFeatures; +import jakarta.json.stream.JsonGenerator; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; +import java.util.Collections; + +public class TroubleShootingTests extends Assertions { + + @Test + public void testMapProperty() { + + SearchResponse searchResponse = new SearchResponse.Builder() + .aggregations( + "price", _2 -> _2 + .avg(_3 -> _3.value(3.14)) + ) + // Required properties on a SearchResponse + .took(1) + .shards(_1 -> _1.successful(1).failed(0).total(1)) + .hits(_1 -> _1 + .total(_2 -> _2.value(0).relation(TotalHitsRelation.Eq)) + .hits(Collections.emptyList()) + ) + .timedOut(false) + .build(); + + String json = "{\"took\":1,\"timed_out\":false,\"_shards\":{\"failed\":0.0,\"successful\":1.0,\"total\":1.0}," + + "\"hits\":{\"total\":{\"relation\":\"eq\",\"value\":0},\"hits\":[]},\"aggregations\":{\"avg#price\":{\"value\":3.14}}}"; + + DocTestsTransport transport = new DocTestsTransport(); + ElasticsearchClient esClient = new ElasticsearchClient(transport); + + { + //tag::aggregation-typed-keys + JsonpMapper mapper = esClient._jsonpMapper(); + + StringWriter writer = new StringWriter(); + try (JsonGenerator generator = mapper.jsonProvider().createGenerator(writer)) { + mapper.serialize(searchResponse, generator); + } + String result = writer.toString(); + + // The aggregation property provides the "avg" type and "price" name + assertTrue(result.contains("\"aggregations\":{\"avg#price\":{\"value\":3.14}}}")); + //end::aggregation-typed-keys + } + + { + //tag::aggregation-no-typed-keys + // Create a new mapper with the typed_keys feature disabled + JsonpMapper mapper = esClient._jsonpMapper() + .withAttribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, false); + + StringWriter writer = new StringWriter(); + try (JsonGenerator generator = mapper.jsonProvider().createGenerator(writer)) { + mapper.serialize(searchResponse, generator); + } + String result = writer.toString(); + + // The aggregation only provides the "price" name + assertTrue(result.contains("\"aggregations\":{\"price\":{\"value\":3.14}}}")); + //end::aggregation-no-typed-keys + } + } +} diff --git a/java-client/src/test/java/co/elastic/clients/elasticsearch/model/TypedKeysTest.java b/java-client/src/test/java/co/elastic/clients/elasticsearch/model/TypedKeysTest.java index 6af52daba..463917025 100644 --- a/java-client/src/test/java/co/elastic/clients/elasticsearch/model/TypedKeysTest.java +++ b/java-client/src/test/java/co/elastic/clients/elasticsearch/model/TypedKeysTest.java @@ -26,10 +26,15 @@ import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.TotalHitsRelation; import co.elastic.clients.json.JsonpDeserializer; +import co.elastic.clients.json.JsonpMapper; +import co.elastic.clients.json.JsonpMapperFeatures; import co.elastic.clients.util.ListBuilder; import co.elastic.clients.util.MapBuilder; +import jakarta.json.spi.JsonProvider; +import jakarta.json.stream.JsonGenerator; import org.junit.jupiter.api.Test; +import java.io.StringWriter; import java.util.Collections; public class TypedKeysTest extends ModelTestCase { @@ -64,6 +69,34 @@ public void testMapProperty() { } + @Test + public void testMapPropertyWithoutTypedKeys() { + + SearchResponse resp = new SearchResponse.Builder() + .aggregations( + "foo", _2 -> _2 + .avg(_3 -> _3.value(3.14)) + ) + // Required properties on a SearchResponse + .took(1) + .shards(_1 -> _1.successful(1).failed(0).total(1)) + .hits(_1 -> _1 + .total(_2 -> _2.value(0).relation(TotalHitsRelation.Eq)) + .hits(Collections.emptyList()) + ) + .timedOut(false) + .build(); + + // Note "foo" and not "avg#foo" below + String json = "{\"took\":1,\"timed_out\":false,\"_shards\":{\"failed\":0.0,\"successful\":1.0,\"total\":1.0}," + + "\"hits\":{\"total\":{\"relation\":\"eq\",\"value\":0},\"hits\":[]},\"aggregations\":{\"foo\":{\"value\":3.14}}}"; + + JsonpMapper newMapper = mapper.withAttribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, false); + + assertEquals(json, toJson(resp, newMapper)); + } + + @Test public void testAdditionalProperties() { @@ -105,8 +138,8 @@ public void testAdditionalProperties() { String json = "{\"took\":1,\"timed_out\":false,\"_shards\":{\"failed\":0.0,\"successful\":1.0,\"total\":1.0}," + "\"hits\":{\"total\":{\"relation\":\"eq\",\"value\":0},\"hits\":[]}," + "\"aggregations\":{\"sterms#foo\":{\"buckets\":[" + - "{\"avg#bar\":{\"value\":1.0},\"doc_count\":1,\"key\":\"key_1\"}," + - "{\"avg#bar\":{\"value\":2.0},\"doc_count\":2,\"key\":\"key_2\"}" + + "{\"avg#bar\":{\"value\":1.0},\"doc_count\":1,\"key\":\"key_1\"}," + + "{\"avg#bar\":{\"value\":2.0},\"doc_count\":2,\"key\":\"key_2\"}" + "],\"sum_other_doc_count\":1}}}"; assertEquals(json, toJson(resp)); @@ -120,4 +153,146 @@ public void testAdditionalProperties() { assertEquals("key_2", foo.buckets().array().get(1).key()); assertEquals(2.0, foo.buckets().array().get(1).aggregations().get("bar").avg().value(), 0.01); } + + // Example taken from + // https://www.elastic.co/guide/en/elasticsearch/reference/8.2/search-aggregations-bucket-reverse-nested-aggregation.html + private static final String nestedJsonWithTypedKeys = "{\n" + + " \"took\": 0," + + " \"timed_out\": false," + + " \"_shards\": {\n" + + " \"successful\": 1,\n" + + " \"failed\": 0,\n" + + " \"skipped\": 0,\n" + + " \"total\": 1\n" + + " },\n" + + " \"hits\": {\n" + + " \"hits\": [],\n" + + " \"total\": {\n" + + " \"relation\": \"eq\",\n" + + " \"value\": 5\n" + + " },\n" + + " \"max_score\": null\n" + + " }," + + " \"aggregations\" : {\n" + + " \"nested#comments\" : {\n" + + " \"doc_count\" : 3,\n" + + " \"sterms#top_usernames\" : {\n" + + " \"doc_count_error_upper_bound\" : 0,\n" + + " \"sum_other_doc_count\" : 0,\n" + + " \"buckets\" : [\n" + + " {\n" + + " \"key\" : \"dan\",\n" + + " \"doc_count\" : 3,\n" + + " \"reverse_nested#comment_to_issue\" : {\n" + + " \"doc_count\" : 1,\n" + + " \"sterms#top_tags_per_comment\" : {\n" + + " \"doc_count_error_upper_bound\" : 0,\n" + + " \"sum_other_doc_count\" : 0,\n" + + " \"buckets\" : [\n" + + " {\n" + + " \"key\" : \"tag1\",\n" + + " \"doc_count\" : 1\n" + + " },\n" + + " {\n" + + " \"key\" : \"tag2\",\n" + + " \"doc_count\" : 1\n" + + " }\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + @Test + public void testSerializeNested() { + + SearchResponse response = fromJson(nestedJsonWithTypedKeys, SearchResponse.class); + + // Check some deeply nested properties + StringTermsBucket bucket = response + .aggregations().get("comments").nested() + .aggregations().get("top_usernames").sterms() + .buckets().array().get(0) + .aggregations().get("comment_to_issue").reverseNested() + .aggregations().get("top_tags_per_comment").sterms() + .buckets().array().get(0); + + assertEquals("tag1", bucket.key()); + assertEquals(1, bucket.docCount()); + + // Check that it's typed_keys encoded + String serialized = toJson(response); + assertTrue(serialized.contains("nested#comments")); + assertTrue(serialized.contains("sterms#top_usernames")); + assertTrue(serialized.contains("reverse_nested#comment_to_issue")); + assertTrue(serialized.contains("sterms#top_tags_per_comment")); + + { + // Test direct serialization + JsonProvider jsonProvider = mapper.jsonProvider(); + StringWriter stringWriter = new StringWriter(); + JsonGenerator generator = jsonProvider.createGenerator(stringWriter); + response.serialize(generator, mapper); + generator.close(); + + String directSerialized = stringWriter.toString(); + assertTrue(directSerialized.contains("nested#comments")); + assertTrue(directSerialized.contains("sterms#top_usernames")); + assertTrue(directSerialized.contains("reverse_nested#comment_to_issue")); + assertTrue(directSerialized.contains("sterms#top_tags_per_comment")); + + } + + // Re-parse and re-check + response = fromJson(serialized, SearchResponse.class); + + bucket = response + .aggregations().get("comments").nested() + .aggregations().get("top_usernames").sterms() + .buckets().array().get(0) + .aggregations().get("comment_to_issue").reverseNested() + .aggregations().get("top_tags_per_comment").sterms() + .buckets().array().get(0); + + assertEquals("tag1", bucket.key()); + assertEquals(1, bucket.docCount()); + + + JsonProvider jsonProvider = mapper.jsonProvider(); + StringWriter stringWriter = new StringWriter(); + JsonGenerator generator = jsonProvider.createGenerator(stringWriter); + response.serialize(generator, mapper); + generator.close(); + + System.out.println(stringWriter.toString()); + } + + @Test + public void testSerializeNestedWithoutTypedKeys() { + + SearchResponse response = fromJson(nestedJsonWithTypedKeys, SearchResponse.class); + + // Check that it's typed_keys encoded + String serialized = toJson(response); + assertTrue(serialized.contains("nested#comments")); + assertTrue(serialized.contains("sterms#top_usernames")); + assertTrue(serialized.contains("reverse_nested#comment_to_issue")); + assertTrue(serialized.contains("sterms#top_tags_per_comment")); + + // Build the non-typed_keys version (replace 'type#' with 'type#name') + serialized = serialized.replaceAll("\"[^\"]*#", "\""); + assertFalse(serialized.contains("nested#comments")); + assertFalse(serialized.contains("sterms#top_usernames")); + assertFalse(serialized.contains("reverse_nested#comment_to_issue")); + assertFalse(serialized.contains("sterms#top_tags_per_comment")); + + // Serialize without typed keys + JsonpMapper newMapper = mapper.withAttribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, false); + assertEquals(serialized, toJson(response, newMapper)); + + } }