diff --git a/server/src/main/java/org/elasticsearch/common/xcontent/XContentFieldFilter.java b/server/src/main/java/org/elasticsearch/common/xcontent/XContentFieldFilter.java new file mode 100644 index 0000000000000..56a5dfa2faf25 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/common/xcontent/XContentFieldFilter.java @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.common.xcontent; + +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.util.CollectionUtils; +import org.elasticsearch.common.xcontent.support.XContentMapValues; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.core.Tuple; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.XContentType; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * A filter that filter fields away from source + */ +public interface XContentFieldFilter { + /** + * filter source in {@link BytesReference} format and in {@link XContentType} content type + * note that xContentType may be null in some case, we should guess xContentType from sourceBytes in such cases + */ + BytesReference apply(BytesReference sourceBytes, @Nullable XContentType xContentType) throws IOException; + + /** + * Construct {@link XContentFieldFilter} using given includes and excludes + * + * @param includes fields to keep, wildcard supported + * @param excludes fields to remove, wildcard supported + * @return filter that filter {@link org.elasticsearch.xcontent.XContent} with given includes and excludes + */ + static XContentFieldFilter newFieldFilter(String[] includes, String[] excludes) { + final CheckedFunction emptyValueSupplier = xContentType -> { + BytesStreamOutput bStream = new BytesStreamOutput(); + XContentBuilder builder = XContentFactory.contentBuilder(xContentType, bStream).map(Collections.emptyMap()); + builder.close(); + return bStream.bytes(); + }; + // Use the old map-based filtering mechanism if there are wildcards in the excludes. + // TODO: Remove this if block once: https://github.com/elastic/elasticsearch/pull/80160 is merged + if ((CollectionUtils.isEmpty(excludes) == false) && Arrays.stream(excludes).filter(field -> field.contains("*")).count() > 0) { + return (originalSource, contentType) -> { + if (originalSource == null || originalSource.length() <= 0) { + if (contentType == null) { + throw new IllegalStateException("originalSource and contentType can not be null at the same time"); + } + return emptyValueSupplier.apply(contentType); + } + Function, Map> mapFilter = XContentMapValues.filter(includes, excludes); + Tuple> mapTuple = XContentHelper.convertToMap(originalSource, true, contentType); + Map filteredSource = mapFilter.apply(mapTuple.v2()); + BytesStreamOutput bStream = new BytesStreamOutput(); + XContentType actualContentType = mapTuple.v1(); + XContentBuilder builder = XContentFactory.contentBuilder(actualContentType, bStream).map(filteredSource); + builder.close(); + return bStream.bytes(); + }; + } else { + final XContentParserConfiguration parserConfig = XContentParserConfiguration.EMPTY.withFiltering( + Set.of(includes), + Set.of(excludes), + true + ); + return (originalSource, contentType) -> { + if (originalSource == null || originalSource.length() <= 0) { + if (contentType == null) { + throw new IllegalStateException("originalSource and contentType can not be null at the same time"); + } + return emptyValueSupplier.apply(contentType); + } + if (contentType == null) { + contentType = XContentHelper.xContentTypeMayCompressed(originalSource); + } + BytesStreamOutput streamOutput = new BytesStreamOutput(Math.min(1024, originalSource.length())); + XContentBuilder builder = new XContentBuilder(contentType.xContent(), streamOutput); + XContentParser parser = contentType.xContent().createParser(parserConfig, originalSource.streamInput()); + if ((parser.currentToken() == null) && (parser.nextToken() == null)) { + return emptyValueSupplier.apply(contentType); + } + builder.copyCurrentStructure(parser); + return BytesReference.bytes(builder); + }; + } + } +} diff --git a/server/src/main/java/org/elasticsearch/common/xcontent/XContentHelper.java b/server/src/main/java/org/elasticsearch/common/xcontent/XContentHelper.java index d120983ce562c..580ef7c930694 100644 --- a/server/src/main/java/org/elasticsearch/common/xcontent/XContentHelper.java +++ b/server/src/main/java/org/elasticsearch/common/xcontent/XContentHelper.java @@ -524,6 +524,32 @@ public static BytesReference toXContent(ToXContent toXContent, XContentType xCon } } + /** + * Guesses the content type based on the provided bytes which may be compressed. + * + * @deprecated the content type should not be guessed except for few cases where we effectively don't know the content type. + * The REST layer should move to reading the Content-Type header instead. There are other places where auto-detection may be needed. + * This method is deprecated to prevent usages of it from spreading further without specific reasons. + */ + @Deprecated + public static XContentType xContentTypeMayCompressed(BytesReference bytes) { + Compressor compressor = CompressorFactory.compressor(bytes); + if (compressor != null) { + try { + InputStream compressedStreamInput = compressor.threadLocalInputStream(bytes.streamInput()); + if (compressedStreamInput.markSupported() == false) { + compressedStreamInput = new BufferedInputStream(compressedStreamInput); + } + return XContentFactory.xContentType(compressedStreamInput); + } catch (IOException e) { + assert false : "Should not happen, we're just reading bytes from memory"; + throw new UncheckedIOException(e); + } + } else { + return XContentHelper.xContentType(bytes); + } + } + /** * Guesses the content type based on the provided bytes. * diff --git a/server/src/main/java/org/elasticsearch/index/get/ShardGetService.java b/server/src/main/java/org/elasticsearch/index/get/ShardGetService.java index 8153e3e406dfb..992afdeb99881 100644 --- a/server/src/main/java/org/elasticsearch/index/get/ShardGetService.java +++ b/server/src/main/java/org/elasticsearch/index/get/ShardGetService.java @@ -16,10 +16,8 @@ import org.elasticsearch.common.metrics.CounterMetric; import org.elasticsearch.common.metrics.MeanMetric; import org.elasticsearch.common.util.set.Sets; -import org.elasticsearch.common.xcontent.XContentHelper; -import org.elasticsearch.common.xcontent.support.XContentMapValues; +import org.elasticsearch.common.xcontent.XContentFieldFilter; import org.elasticsearch.core.Nullable; -import org.elasticsearch.core.Tuple; import org.elasticsearch.index.IndexSettings; import org.elasticsearch.index.VersionType; import org.elasticsearch.index.engine.Engine; @@ -33,8 +31,6 @@ import org.elasticsearch.index.shard.AbstractIndexShardComponent; import org.elasticsearch.index.shard.IndexShard; import org.elasticsearch.search.fetch.subphase.FetchSourceContext; -import org.elasticsearch.xcontent.XContentFactory; -import org.elasticsearch.xcontent.XContentType; import java.io.IOException; import java.util.HashMap; @@ -253,15 +249,11 @@ private GetResult innerGetLoadFromStoredFields( if (fetchSourceContext.fetchSource() == false) { source = null; } else if (fetchSourceContext.includes().length > 0 || fetchSourceContext.excludes().length > 0) { - Map sourceAsMap; // TODO: The source might be parsed and available in the sourceLookup but that one uses unordered maps so different. // Do we care? - Tuple> typeMapTuple = XContentHelper.convertToMap(source, true); - XContentType sourceContentType = typeMapTuple.v1(); - sourceAsMap = typeMapTuple.v2(); - sourceAsMap = XContentMapValues.filter(sourceAsMap, fetchSourceContext.includes(), fetchSourceContext.excludes()); try { - source = BytesReference.bytes(XContentFactory.contentBuilder(sourceContentType).map(sourceAsMap)); + source = XContentFieldFilter.newFieldFilter(fetchSourceContext.includes(), fetchSourceContext.excludes()) + .apply(source, null); } catch (IOException e) { throw new ElasticsearchException("Failed to get id [" + id + "] with includes/excludes set", e); } diff --git a/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java b/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java index 75bcd1eab5432..d30c24925a6c3 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java @@ -16,32 +16,24 @@ import org.apache.lucene.util.BytesRef; import org.elasticsearch.common.Strings; import org.elasticsearch.common.bytes.BytesReference; -import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.util.CollectionUtils; -import org.elasticsearch.common.xcontent.XContentHelper; -import org.elasticsearch.common.xcontent.support.XContentMapValues; +import org.elasticsearch.common.xcontent.XContentFieldFilter; import org.elasticsearch.core.Nullable; -import org.elasticsearch.core.Tuple; import org.elasticsearch.index.query.QueryShardException; import org.elasticsearch.index.query.SearchExecutionContext; -import org.elasticsearch.xcontent.XContentBuilder; -import org.elasticsearch.xcontent.XContentFactory; import org.elasticsearch.xcontent.XContentType; import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Map; -import java.util.function.Function; public class SourceFieldMapper extends MetadataFieldMapper { - public static final String NAME = "_source"; public static final String RECOVERY_SOURCE_NAME = "_recovery_source"; public static final String CONTENT_TYPE = "_source"; - private final Function, Map> filter; + private final XContentFieldFilter filter; private static final SourceFieldMapper DEFAULT = new SourceFieldMapper(Defaults.ENABLED, Strings.EMPTY_ARRAY, Strings.EMPTY_ARRAY); @@ -145,7 +137,9 @@ private SourceFieldMapper(boolean enabled, String[] includes, String[] excludes) this.includes = includes; this.excludes = excludes; final boolean filtered = CollectionUtils.isEmpty(includes) == false || CollectionUtils.isEmpty(excludes) == false; - this.filter = enabled && filtered ? XContentMapValues.filter(includes, excludes) : null; + this.filter = enabled && filtered + ? XContentFieldFilter.newFieldFilter(includes, excludes) + : (sourceBytes, contentType) -> sourceBytes; this.complete = enabled && CollectionUtils.isEmpty(includes) && CollectionUtils.isEmpty(excludes); } @@ -180,18 +174,7 @@ public void preParse(DocumentParserContext context) throws IOException { public BytesReference applyFilters(@Nullable BytesReference originalSource, @Nullable XContentType contentType) throws IOException { if (enabled && originalSource != null) { // Percolate and tv APIs may not set the source and that is ok, because these APIs will not index any data - if (filter != null) { - // we don't update the context source if we filter, we want to keep it as is... - Tuple> mapTuple = XContentHelper.convertToMap(originalSource, true, contentType); - Map filteredSource = filter.apply(mapTuple.v2()); - BytesStreamOutput bStream = new BytesStreamOutput(); - XContentType actualContentType = mapTuple.v1(); - XContentBuilder builder = XContentFactory.contentBuilder(actualContentType, bStream).map(filteredSource); - builder.close(); - return bStream.bytes(); - } else { - return originalSource; - } + return filter.apply(originalSource, contentType); } else { return null; } diff --git a/server/src/test/java/org/elasticsearch/common/xcontent/support/XContentFieldFilterTests.java b/server/src/test/java/org/elasticsearch/common/xcontent/support/XContentFieldFilterTests.java new file mode 100644 index 0000000000000..47ea35b15b8e6 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/common/xcontent/support/XContentFieldFilterTests.java @@ -0,0 +1,549 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.common.xcontent.support; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.xcontent.XContentFieldFilter; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.xcontent.ToXContentObject; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.XContentType; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static org.elasticsearch.common.xcontent.XContentHelper.convertToMap; +import static org.elasticsearch.common.xcontent.XContentHelper.toXContent; +import static org.elasticsearch.test.MapMatcher.assertMap; +import static org.elasticsearch.test.MapMatcher.matchesMap; + +public class XContentFieldFilterTests extends AbstractFilteringTestCase { + @Override + protected void testFilter(Builder expected, Builder actual, Set includes, Set excludes) throws IOException { + final XContentType xContentType = randomFrom(XContentType.values()); + final boolean humanReadable = randomBoolean(); + String[] sourceIncludes; + if (includes == null) { + sourceIncludes = randomBoolean() ? Strings.EMPTY_ARRAY : null; + } else { + sourceIncludes = includes.toArray(new String[includes.size()]); + } + String[] sourceExcludes; + if (excludes == null) { + sourceExcludes = randomBoolean() ? Strings.EMPTY_ARRAY : null; + } else { + sourceExcludes = excludes.toArray(new String[excludes.size()]); + } + XContentFieldFilter filter = XContentFieldFilter.newFieldFilter(sourceIncludes, sourceExcludes); + BytesReference ref = filter.apply(toBytesReference(actual, xContentType, humanReadable), xContentType); + assertMap(XContentHelper.convertToMap(ref, true, xContentType).v2(), matchesMap(toMap(expected, xContentType, humanReadable))); + } + + private void testFilter(String expectedJson, String actualJson, Set includes, Set excludes) throws IOException { + CheckedFunction toBuilder = json -> { + XContentParser parser = XContentHelper.createParser(XContentParserConfiguration.EMPTY, new BytesArray(json), XContentType.JSON); + if ((parser.currentToken() == null) && (parser.nextToken() == null)) { + return builder -> builder; + } + return builder -> builder.copyCurrentStructure(parser); + }; + testFilter(toBuilder.apply(expectedJson), toBuilder.apply(actualJson), includes, excludes); + } + + public void testPrefixedNamesFilteringTest() throws IOException { + String actual = """ + { + "obj": "value", + "obj_name": "value_name" + } + """; + String expected = """ + { + "obj_name": "value_name" + } + """; + testFilter(expected, actual, singleton("obj_name"), emptySet()); + } + + public void testNestedFiltering() throws IOException { + String actual = """ + { + "field": "value", + "array": [{ + "nested": 2, + "nested_2": 3 + }] + } + """; + String expected = """ + { + "array": [{ + "nested": 2 + }] + } + """; + testFilter(expected, actual, singleton("array.nested"), emptySet()); + + expected = """ + { + "array": [{ + "nested": 2, + "nested_2": 3 + }] + } + """; + testFilter(expected, actual, singleton("array.*"), emptySet()); + + actual = """ + { + "field": "value", + "obj": { + "field": "value", + "field2": "value2" + } + } + """; + + expected = """ + { + "obj": { + "field": "value" + } + } + """; + testFilter(expected, actual, singleton("obj.field"), emptySet()); + + expected = """ + { + "obj": { + "field": "value", + "field2": "value2" + } + } + """; + testFilter(expected, actual, singleton("obj.*"), emptySet()); + } + + public void testCompleteObjectFiltering() throws IOException { + String actual = """ + { + "field": "value", + "obj": { + "field": "value", + "field2": "value2" + }, + "array": [{ + "field": "value", + "field2": "value2" + }] + } + """; + String expected = """ + { + "obj": { + "field": "value", + "field2": "value2" + } + } + """; + testFilter(expected, actual, singleton("obj"), emptySet()); + + expected = """ + { + "obj": { + "field": "value" + } + } + """; + testFilter(expected, actual, singleton("obj"), singleton("*.field2")); + + expected = """ + { + "array": [{ + "field": "value", + "field2": "value2" + }] + } + """; + testFilter(expected, actual, singleton("array"), emptySet()); + + expected = """ + { + "array": [{ + "field": "value" + }] + } + """; + testFilter(expected, actual, singleton("array"), singleton("*.field2")); + } + + public void testFilterIncludesUsingStarPrefix() throws IOException { + String actual = """ + { + "field": "value", + "obj": { + "field": "value", + "field2": "value2" + }, + "n_obj": { + "n_field": "value", + "n_field2": "value2" + } + } + """; + String expected = """ + { + "obj": { + "field2": "value2" + } + } + """; + testFilter(expected, actual, singleton("*.field2"), emptySet()); + + // only objects + expected = """ + { + "obj": { + "field": "value", + "field2": "value2" + }, + "n_obj": { + "n_field": "value", + "n_field2": "value2" + } + } + """; + testFilter(expected, actual, singleton("*.*"), emptySet()); + + expected = """ + { + "field": "value", + "obj": { + "field": "value" + }, + "n_obj": { + "n_field": "value" + } + } + """; + testFilter(expected, actual, singleton("*"), singleton("*.*2")); + } + + public void testFilterWithEmptyIncludesExcludes() throws IOException { + String actual = """ + { + "field": "value" + } + """; + testFilter(actual, actual, emptySet(), emptySet()); + } + + public void testThatFilterIncludesEmptyObjectWhenUsingIncludes() throws IOException { + String actual = """ + { + "obj": {} + } + """; + testFilter(actual, actual, singleton("obj"), emptySet()); + } + + public void testThatFilterIncludesEmptyObjectWhenUsingExcludes() throws IOException { + String actual = """ + { + "obj": {} + } + """; + testFilter(actual, actual, emptySet(), singleton("nonExistingField")); + } + + // wait for PR https://github.com/FasterXML/jackson-core/pull/729 to be introduced + @AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/pull/80160") + public void testNotOmittingObjectsWithExcludedProperties() throws IOException { + String actual = """ + { + "obj": { + "f1": "f2" + } + } + """; + String expected = """ + { + "obj": {} + } + """; + testFilter(expected, actual, emptySet(), singleton("obj.f1")); + } + + public void testNotOmittingObjectWithNestedExcludedObject() throws IOException { + String actual = """ + { + "obj1": { + "obj2": { + "obj3": {} + } + } + } + """; + String expected = """ + { + "obj1": {} + } + """; + // implicit include + testFilter(expected, actual, emptySet(), singleton("*.obj2")); + + // explicit include + testFilter(expected, actual, singleton("obj1"), singleton("*.obj2")); + + // wildcard include + expected = """ + { + "obj1": { + "obj2": {} + } + } + """; + testFilter(expected, actual, singleton("*.obj2"), singleton("*.obj3")); + } + + public void testIncludingObjectWithNestedIncludedObject() throws IOException { + String actual = """ + { + "obj1": { + "obj2": {} + } + } + """; + testFilter(actual, actual, singleton("*.obj2"), emptySet()); + } + + public void testDotsInFieldNames() throws IOException { + String actual = """ + { + "foo.bar": 2, + "foo": { + "baz": 3 + }, + "quux": 5 + } + """; + String expected = """ + { + "foo.bar": 2, + "foo": { + "baz": 3 + } + } + """; + testFilter(expected, actual, singleton("foo"), emptySet()); + + // dots in field names in excludes + expected = """ + { + "quux": 5 + } + """; + testFilter(expected, actual, emptySet(), singleton("foo")); + } + + /** + * Tests that we can extract paths containing non-ascii characters. + * See {@link AbstractFilteringTestCase#testFilterSupplementaryCharactersInPaths()} + * for a similar test but for XContent. + */ + public void testSupplementaryCharactersInPaths() throws IOException { + String actual = """ + { + "搜索": 2, + "指数": 3 + } + """; + String expected = """ + { + "搜索": 2 + } + """; + testFilter(expected, actual, singleton("搜索"), emptySet()); + expected = """ + { + "指数": 3 + } + """; + testFilter(expected, actual, emptySet(), singleton("搜索")); + } + + /** + * Tests that we can extract paths which share a prefix with other paths. + * See {@link AbstractFilteringTestCase#testFilterSharedPrefixes()} + * for a similar test but for XContent. + */ + public void testSharedPrefixes() throws IOException { + String actual = """ + { + "foobar": 2, + "foobaz": 3 + } + """; + String expected = """ + { + "foobar": 2 + } + """; + testFilter(expected, actual, singleton("foobar"), emptySet()); + expected = """ + { + "foobaz": 3 + } + """; + testFilter(expected, actual, emptySet(), singleton("foobar")); + } + + // wait for PR https://github.com/FasterXML/jackson-core/pull/729 to be introduced + @AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/pull/80160") + public void testArraySubFieldExclusion() throws IOException { + String actual = """ + { + "field": "value", + "array": [{ + "exclude": "bar" + }] + } + """; + String expected = """ + { + "field": "value", + "array": [] + } + """; + testFilter(expected, actual, emptySet(), singleton("array.exclude")); + } + + // wait for PR https://github.com/FasterXML/jackson-core/pull/729 to be introduced + @AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/pull/80160") + public void testEmptyArraySubFieldsExclusion() throws IOException { + String actual = """ + { + "field": "value", + "array": [] + } + """; + testFilter(actual, actual, emptySet(), singleton("array.exclude")); + } + + public void testEmptyArraySubFieldsInclusion() throws IOException { + String actual = """ + { + "field": "value", + "array": [] + } + """; + String expected = "{}"; + testFilter(expected, actual, singleton("array.include"), emptySet()); + + expected = """ + { + "array": [] + } + """; + testFilter(expected, actual, Set.of("array", "array.include"), emptySet()); + } + + public void testEmptyObjectsSubFieldsInclusion() throws IOException { + String actual = """ + { + "field": "value", + "object": {} + } + """; + String expected = "{}"; + testFilter(expected, actual, singleton("object.include"), emptySet()); + + expected = """ + { + "object": {} + } + """; + testFilter(expected, actual, Set.of("object", "object.include"), emptySet()); + } + + /** + * Tests that we can extract paths which have another path as a prefix. + * See {@link AbstractFilteringTestCase#testFilterPrefix()} + * for a similar test but for XContent. + */ + public void testPrefix() throws IOException { + String actual = """ + { + "photos": ["foo", "bar"], + "photosCount": 2 + } + """; + String expected = """ + { + "photosCount": 2 + } + """; + testFilter(expected, actual, singleton("photosCount"), emptySet()); + } + + public void testEmptySource() throws IOException { + final CheckedFunction emptyValueSupplier = xContentType -> { + BytesStreamOutput bStream = new BytesStreamOutput(); + XContentBuilder builder = XContentFactory.contentBuilder(xContentType, bStream).map(Collections.emptyMap()); + builder.close(); + return bStream.bytes(); + }; + final XContentType xContentType = randomFrom(XContentType.values()); + // null value for parser filter + assertEquals( + emptyValueSupplier.apply(xContentType), + XContentFieldFilter.newFieldFilter(new String[0], new String[0]).apply(null, xContentType) + ); + // empty bytes for parser filter + assertEquals( + emptyValueSupplier.apply(xContentType), + XContentFieldFilter.newFieldFilter(new String[0], new String[0]).apply(BytesArray.EMPTY, xContentType) + ); + // null value for map filter + assertEquals( + emptyValueSupplier.apply(xContentType), + XContentFieldFilter.newFieldFilter(new String[0], new String[] { "test*" }).apply(null, xContentType) + ); + // empty bytes for map filter + assertEquals( + emptyValueSupplier.apply(xContentType), + XContentFieldFilter.newFieldFilter(new String[0], new String[] { "test*" }).apply(BytesArray.EMPTY, xContentType) + ); + } + + private BytesReference toBytesReference(Builder builder, XContentType xContentType, boolean humanReadable) throws IOException { + return toXContent((ToXContentObject) (xContentBuilder, params) -> builder.apply(xContentBuilder), xContentType, humanReadable); + } + + private static Map toMap(Builder test, XContentType xContentType, boolean humanReadable) throws IOException { + ToXContentObject toXContent = (builder, params) -> test.apply(builder); + return convertToMap(toXContent(toXContent, xContentType, humanReadable), true, xContentType).v2(); + } + + @Override + protected boolean removesEmptyArrays() { + return false; + } +}