Sfoglia il codice sorgente

Add XContentFieldFilter (#83152)

This commit introduces XContentFieldFilter, which applies field includes/excludes to 
XContent without having to realise the xcontent itself as a java map.  SourceFieldMapper
and ShardGetService are cut over to use this class.
mushaoqiong 3 anni fa
parent
commit
919ed3a8dd

+ 101 - 0
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<XContentType, BytesReference, IOException> 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<String, ?>, Map<String, Object>> mapFilter = XContentMapValues.filter(includes, excludes);
+                Tuple<XContentType, Map<String, Object>> mapTuple = XContentHelper.convertToMap(originalSource, true, contentType);
+                Map<String, Object> 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);
+            };
+        }
+    }
+}

+ 26 - 0
server/src/main/java/org/elasticsearch/common/xcontent/XContentHelper.java

@@ -524,6 +524,32 @@ public class XContentHelper {
         }
     }
 
+    /**
+     * 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.
      *

+ 3 - 11
server/src/main/java/org/elasticsearch/index/get/ShardGetService.java

@@ -16,10 +16,8 @@ import org.elasticsearch.common.lucene.uid.VersionsAndSeqNoResolver.DocIdAndVers
 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.mapper.SourceFieldMapper;
 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 @@ public final class ShardGetService extends AbstractIndexShardComponent {
             if (fetchSourceContext.fetchSource() == false) {
                 source = null;
             } else if (fetchSourceContext.includes().length > 0 || fetchSourceContext.excludes().length > 0) {
-                Map<String, Object> sourceAsMap;
                 // TODO: The source might be parsed and available in the sourceLookup but that one uses unordered maps so different.
                 // Do we care?
-                Tuple<XContentType, Map<String, Object>> 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);
                 }

+ 6 - 23
server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java

@@ -16,32 +16,24 @@ import org.apache.lucene.search.Query;
 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<String, ?>, Map<String, Object>> filter;
+    private final XContentFieldFilter filter;
 
     private static final SourceFieldMapper DEFAULT = new SourceFieldMapper(Defaults.ENABLED, Strings.EMPTY_ARRAY, Strings.EMPTY_ARRAY);
 
@@ -145,7 +137,9 @@ public class SourceFieldMapper extends MetadataFieldMapper {
         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 class SourceFieldMapper extends MetadataFieldMapper {
     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<XContentType, Map<String, Object>> mapTuple = XContentHelper.convertToMap(originalSource, true, contentType);
-                Map<String, Object> 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;
         }

+ 549 - 0
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<String> includes, Set<String> 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<String> includes, Set<String> excludes) throws IOException {
+        CheckedFunction<String, Builder, IOException> 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<XContentType, BytesReference, IOException> 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<String, Object> 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;
+    }
+}