ソースを参照

Automatically flatten objects when subobjects:false (#97972)

While ingesting documents that contain nested objects and the
mapping property subobjects is set to false instead of throwing
a mapping exception and dropping the document(s), we map only
leaf field(s) with their full path as their name separated by dots.
Matteo Piergiovanni 2 年 前
コミット
392c497551

+ 6 - 0
docs/changelog/97972.yaml

@@ -0,0 +1,6 @@
+pr: 97972
+summary: Automatically flatten objects when subobjects:false
+area: Mapping
+type: enhancement
+issues:
+ - 88934

+ 47 - 0
libs/x-content/src/main/java/org/elasticsearch/xcontent/FlatteningXContentParser.java

@@ -0,0 +1,47 @@
+/*
+ * 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.xcontent;
+
+import java.io.IOException;
+
+/**
+ * A subclass of XContentSubParser that provides the functionality to flatten
+ * the field names by prefixing them with the provided parent name.
+ */
+public class FlatteningXContentParser extends XContentSubParser {
+    private final String parentName;
+    private static final char DELIMITER = '.';
+
+    /**
+     * Constructs a FlatteningXContentParser with the given parent name and wraps an existing XContentParser.
+     *
+     * @param parser The XContentParser to be wrapped and extended with flattening functionality.
+     * @param parentName The parent name to be used as a prefix for immediate children.
+     */
+    public FlatteningXContentParser(XContentParser parser, String parentName) {
+        super(parser);
+        this.parentName = parentName;
+    }
+
+    /**
+     * Retrieves the name of the current field being parsed. If the current parsing level is 1,
+     * the returned field name will be constructed by prepending the parent name to the
+     * delegate's currentFieldName, otherwise just delegate.
+     *
+     * @return The current field name, potentially modified by prepending the parent name as a prefix.
+     * @throws IOException If an I/O error occurs during parsing.
+     */
+    @Override
+    public String currentName() throws IOException {
+        if (level() == 1) {
+            return new StringBuilder(parentName).append(DELIMITER).append(delegate().currentName()).toString();
+        }
+        return delegate().currentName();
+    }
+}

+ 4 - 0
libs/x-content/src/main/java/org/elasticsearch/xcontent/XContentSubParser.java

@@ -77,4 +77,8 @@ public class XContentSubParser extends FilterXContentParserWrapper {
             }
         }
     }
+
+    int level() {
+        return level;
+    }
 }

+ 35 - 0
libs/x-content/src/test/java/org/elasticsearch/xcontent/XContentParserTests.java

@@ -475,6 +475,41 @@ public class XContentParserTests extends ESTestCase {
         }
     }
 
+    public void testFlatteningParserObject() throws IOException {
+        String content = """
+            {
+              "parent": {
+                "child1" : 1,
+                "child2": {
+                  "grandChild" : 1
+                },
+                "child3" : 1
+              }
+            }
+            """;
+        XContentParser parser = createParser(JsonXContent.jsonXContent, content);
+        assertEquals(XContentParser.Token.START_OBJECT, parser.nextToken());
+        assertEquals(XContentParser.Token.FIELD_NAME, parser.nextToken());
+        assertEquals("parent", parser.currentName());
+        assertEquals(XContentParser.Token.START_OBJECT, parser.nextToken());
+        XContentParser subParser = new FlatteningXContentParser(parser, parser.currentName());
+        assertEquals(XContentParser.Token.FIELD_NAME, subParser.nextToken());
+        assertEquals("parent.child1", subParser.currentName());
+        assertEquals(XContentParser.Token.VALUE_NUMBER, subParser.nextToken());
+        assertEquals(XContentParser.Token.FIELD_NAME, subParser.nextToken());
+        String secondChildName = subParser.currentName();
+        assertEquals("parent.child2", secondChildName);
+        assertEquals(XContentParser.Token.START_OBJECT, subParser.nextToken());
+        assertEquals(XContentParser.Token.FIELD_NAME, subParser.nextToken());
+        assertEquals("grandChild", subParser.currentName());
+        assertEquals(XContentParser.Token.VALUE_NUMBER, subParser.nextToken());
+        assertEquals(XContentParser.Token.END_OBJECT, subParser.nextToken());
+        assertEquals(XContentParser.Token.FIELD_NAME, subParser.nextToken());
+        assertEquals("parent.child3", subParser.currentName());
+        assertEquals(XContentParser.Token.VALUE_NUMBER, subParser.nextToken());
+
+    }
+
     public void testSubParserArray() throws IOException {
         XContentBuilder builder = XContentFactory.jsonBuilder();
         int numberOfArrayElements = randomInt(10);

+ 24 - 10
server/src/main/java/org/elasticsearch/index/mapper/DocumentParser.java

@@ -396,7 +396,15 @@ public final class DocumentParser {
             context = context.createChildContext(objectMapper);
             parseObjectOrNested(context);
         } else if (mapper instanceof FieldMapper fieldMapper) {
-            fieldMapper.parse(context);
+            if (shouldFlattenObject(context, fieldMapper)) {
+                // we pass the mapper's simpleName as parentName to the new DocumentParserContext
+                String currentFieldName = fieldMapper.simpleName();
+                context.path().remove();
+                parseObjectOrNested(context.createFlattenContext(currentFieldName));
+                context.path().add(currentFieldName);
+            } else {
+                fieldMapper.parse(context);
+            }
             if (context.isWithinCopyTo() == false) {
                 List<String> copyToFields = fieldMapper.copyTo().copyToFields();
                 if (copyToFields.isEmpty() == false) {
@@ -415,6 +423,12 @@ public final class DocumentParser {
         }
     }
 
+    private static boolean shouldFlattenObject(DocumentParserContext context, FieldMapper fieldMapper) {
+        return context.parser().currentToken() == XContentParser.Token.START_OBJECT
+            && context.parent().subobjects() == false
+            && fieldMapper.supportsParsingObject() == false;
+    }
+
     private static void throwOnUnrecognizedMapperType(Mapper mapper) {
         throw new IllegalStateException(
             "The provided mapper [" + mapper.name() + "] has an unrecognized type [" + mapper.getClass().getSimpleName() + "]."
@@ -472,7 +486,6 @@ public final class DocumentParser {
                 dynamicObjectMapper = new NoOpObjectMapper(currentFieldName, context.path().pathAsText(currentFieldName));
             } else {
                 dynamicObjectMapper = DynamicFieldsBuilder.createDynamicObjectMapper(context, currentFieldName);
-                context.addDynamicMapper(dynamicObjectMapper);
             }
             if (context.parent().subobjects() == false) {
                 if (dynamicObjectMapper instanceof NestedObjectMapper) {
@@ -486,15 +499,16 @@ public final class DocumentParser {
                     );
                 }
                 if (dynamicObjectMapper instanceof ObjectMapper) {
-                    throw new DocumentParsingException(
-                        context.parser().getTokenLocation(),
-                        "Tried to add subobject ["
-                            + dynamicObjectMapper.simpleName()
-                            + "] to object ["
-                            + context.parent().name()
-                            + "] which does not support subobjects"
-                    );
+                    // We have an ObjectMapper but subobjects are disallowed
+                    // therefore we create a new DocumentParserContext that
+                    // prepends currentFieldName to any immediate children.
+                    parseObjectOrNested(context.createFlattenContext(currentFieldName));
+                    return;
                 }
+
+            }
+            if (context.dynamic() != ObjectMapper.Dynamic.RUNTIME) {
+                context.addDynamicMapper(dynamicObjectMapper);
             }
             if (dynamicObjectMapper instanceof NestedObjectMapper && context.isWithinCopyTo()) {
                 throwOnCreateDynamicNestedViaCopyTo(dynamicObjectMapper, context);

+ 15 - 0
server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java

@@ -15,6 +15,7 @@ import org.elasticsearch.common.time.DateFormatter;
 import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.index.analysis.IndexAnalyzers;
 import org.elasticsearch.xcontent.FilterXContentParserWrapper;
+import org.elasticsearch.xcontent.FlatteningXContentParser;
 import org.elasticsearch.xcontent.XContentParser;
 
 import java.io.IOException;
@@ -446,6 +447,20 @@ public abstract class DocumentParserContext {
         };
     }
 
+    /**
+     * Return a context for flattening subobjects
+     * @param fieldName   the name of the field to be flattened
+     */
+    public final DocumentParserContext createFlattenContext(String fieldName) {
+        XContentParser flatteningParser = new FlatteningXContentParser(parser(), fieldName);
+        return new Wrapper(this.parent(), this) {
+            @Override
+            public XContentParser parser() {
+                return flatteningParser;
+            }
+        };
+    }
+
     /**
      *  @deprecated we are actively deprecating and removing the ability to pass
      *              complex objects to multifields, so try and avoid using this method

+ 624 - 29
server/src/test/java/org/elasticsearch/index/mapper/DocumentParserTests.java

@@ -11,6 +11,7 @@ package org.elasticsearch.index.mapper;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.LatLonDocValuesField;
 import org.apache.lucene.document.LatLonPoint;
+import org.apache.lucene.document.LongField;
 import org.apache.lucene.document.StringField;
 import org.apache.lucene.index.IndexableField;
 import org.apache.lucene.util.BytesRef;
@@ -33,6 +34,7 @@ import java.io.IOException;
 import java.math.BigDecimal;
 import java.math.BigInteger;
 import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -2001,7 +2003,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
         DocumentMapper mapper = createDocumentMapper(
             mapping(b -> b.startObject("metrics.service").field("type", "object").field("subobjects", false).endObject())
         );
-        DocumentParsingException err = expectThrows(DocumentParsingException.class, () -> mapper.parse(source("""
+        ParsedDocument doc = mapper.parse(source("""
             {
               "metrics": {
                 "service": {
@@ -2011,18 +2013,23 @@ public class DocumentParserTests extends MapperServiceTestCase {
                 }
               }
             }
-            """)));
-        assertEquals(
-            "[4:16] Tried to add subobject [time] to object [metrics.service] which does not support subobjects",
-            err.getMessage()
-        );
+            """));
+        Mapping mappingsUpdate = doc.dynamicMappingsUpdate();
+        assertNotNull(mappingsUpdate);
+        Mapper metricsMapper = mappingsUpdate.getRoot().getMapper("metrics");
+        assertNotNull(metricsMapper);
+        Mapper serviceMapper = ((ObjectMapper) metricsMapper).getMapper("service");
+        assertNotNull(serviceMapper);
+        assertNull(((ObjectMapper) serviceMapper).getMapper("time"));
+        assertNotNull(((ObjectMapper) serviceMapper).getMapper("time.max"));
+        assertNotNull(doc.rootDoc().getField("metrics.service.time.max"));
     }
 
     public void testSubobjectsFalseWithInnerDottedObject() throws Exception {
         DocumentMapper mapper = createDocumentMapper(
             mapping(b -> b.startObject("metrics.service").field("type", "object").field("subobjects", false).endObject())
         );
-        DocumentParsingException err = expectThrows(DocumentParsingException.class, () -> mapper.parse(source("""
+        ParsedDocument doc = mapper.parse(source("""
             {
               "metrics": {
                 "service": {
@@ -2032,25 +2039,16 @@ public class DocumentParserTests extends MapperServiceTestCase {
                 }
               }
             }
-            """)));
-        assertEquals(
-            "[4:26] Tried to add subobject [test.with.dots] to object [metrics.service] which does not support subobjects",
-            err.getMessage()
-        );
-    }
+            """));
+        Mapping mappingsUpdate = doc.dynamicMappingsUpdate();
 
-    public void testSubobjectsFalseRootWithInnerObject() throws Exception {
-        DocumentMapper mapper = createDocumentMapper(mappingNoSubobjects(xContentBuilder -> {}));
-        DocumentParsingException err = expectThrows(DocumentParsingException.class, () -> mapper.parse(source("""
-            {
-              "metrics": {
-                "service": {
-                  "time.max" : 10
-                }
-              }
-            }
-            """)));
-        assertEquals("[2:14] Tried to add subobject [metrics] to object [_doc] which does not support subobjects", err.getMessage());
+        assertNotNull(mappingsUpdate);
+        Mapper metricsMapper = mappingsUpdate.getRoot().getMapper("metrics");
+        assertNotNull(metricsMapper);
+        Mapper serviceMapper = ((ObjectMapper) metricsMapper).getMapper("service");
+        assertNotNull(serviceMapper);
+        assertNotNull(((ObjectMapper) serviceMapper).getMapper("test.with.dots.max"));
+        assertNotNull(doc.rootDoc().getField("metrics.service.test.with.dots.max"));
     }
 
     public void testSubobjectsFalseRoot() throws Exception {
@@ -2190,16 +2188,25 @@ public class DocumentParserTests extends MapperServiceTestCase {
         DocumentMapper mapper = createDocumentMapper(
             mapping(b -> b.startObject("metrics").field("type", "object").field("subobjects", false).endObject())
         );
-        DocumentParsingException err = expectThrows(DocumentParsingException.class, () -> mapper.parse(source("""
+        ParsedDocument parsedDocument = mapper.parse(source("""
             {
               "metrics.service.time": [
                 {
-                  "max" : 1000
+                  "max" : 1
+                },
+                {
+                  "max" : 2
                 }
               ]
             }
-            """)));
-        assertEquals("[3:5] Tried to add subobject [service.time] to object [metrics] which does not support subobjects", err.getMessage());
+            """));
+        List<IndexableField> fields = parsedDocument.rootDoc().getFields("metrics.service.time.max");
+        assertEquals(2, fields.size());
+        String[] fieldStrings = fields.stream().map(Object::toString).toArray(String[]::new);
+        assertArrayEquals(
+            new String[] { "LongField <metrics.service.time.max:1>", "LongField <metrics.service.time.max:2>" },
+            fieldStrings
+        );
     }
 
     public void testSubobjectsFalseParseGeoPoint() throws Exception {
@@ -2415,6 +2422,49 @@ public class DocumentParserTests extends MapperServiceTestCase {
         assertNotNull(doc.dynamicMappingsUpdate());
     }
 
+    public void testRuntimeSubfieldsWithObjectsAndSubobjectsFalse() throws IOException {
+
+        // Create mappings with a runtime field called 'obj' that produces two subfields,
+        // 'obj.foo' and 'obj.bar'
+
+        DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+            b.field("subobjects", false);
+            b.startObject("runtime");
+            b.startObject("obj").field("type", "test-composite").endObject();
+            b.endObject();
+        }));
+
+        // Incoming documents should not create mappings for 'obj.foo' fields, as they will
+        // be shadowed by the runtime fields; but other subfields are fine and should be
+        // indexed
+
+        ParsedDocument doc = mapper.parse(source(b -> {
+            b.startObject("obj");
+            b.field("foo", "ignored");
+            b.field("baz", "indexed");
+            b.field("bar", "ignored");
+            b.startObject("sub");
+            b.startObject("foo").field("bar", "baz");
+            b.endObject();
+            b.endObject();
+            b.endObject();
+            b.startObject("sub");
+            b.startObject("foo").field("bar", "baz");
+            b.endObject();
+            b.endObject();
+        }));
+
+        assertNull(doc.rootDoc().getField("obj.foo"));
+        assertNotNull(doc.rootDoc().getField("obj.baz"));
+        assertNull(doc.rootDoc().getField("obj.bar"));
+        assertNotNull(doc.rootDoc().getField("obj.sub.foo.bar"));
+        assertNotNull(doc.rootDoc().getField("sub.foo.bar"));
+        assertNotNull(doc.dynamicMappingsUpdate());
+        assertNotNull(doc.dynamicMappingsUpdate().getRoot().getMapper("obj.baz"));
+        assertNotNull(doc.dynamicMappingsUpdate().getRoot().getMapper("obj.sub.foo.bar"));
+        assertNotNull(doc.dynamicMappingsUpdate().getRoot().getMapper("sub.foo.bar"));
+    }
+
     public void testDynamicFalseMatchesRoutingPath() throws IOException {
         DocumentMapper mapper = createMapperService(
             Settings.builder()
@@ -2574,6 +2624,551 @@ public class DocumentParserTests extends MapperServiceTestCase {
         docMapper2.parse(source(b -> { b.field(sb.toString(), 10); }));
     }
 
+    public void testSubobjectsFalseDocWithInnerObject() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(
+            mapping(b -> b.startObject("metrics.service").field("type", "object").field("subobjects", false).endObject())
+        );
+        // service cannot hold subobjects, yet incoming docs may have objects in it, which are treated as if flat paths were provided
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "metrics": {
+                "service": {
+                  "time" : {
+                    "max" : 10,
+                    "min" : 1
+                  }
+                },
+                "object" : {
+                  "field" : 5000
+                }
+              }
+            }
+            """));
+        assertNull(parsedDocument.rootDoc().getField("metrics.service.time"));
+        for (String s : Arrays.asList("metrics.service.time.max", "metrics.service.time.min", "metrics.object.field")) {
+            assertThat(parsedDocument.rootDoc().getField(s), instanceOf(LongField.class));
+        }
+        ObjectMapper metrics = (ObjectMapper) parsedDocument.dynamicMappingsUpdate().getRoot().getMapper("metrics");
+        assertEquals(2, metrics.mappers.size());
+        ObjectMapper object = (ObjectMapper) metrics.getMapper("object");
+        assertThat(object.getMapper("field"), instanceOf(NumberFieldMapper.class));
+        ObjectMapper service = (ObjectMapper) metrics.getMapper("service");
+        assertNull(service.getMapper("time"));
+        assertThat(service.getMapper("time.max"), instanceOf(NumberFieldMapper.class));
+        assertThat(service.getMapper("time.min"), instanceOf(NumberFieldMapper.class));
+    }
+
+    public void testSubobjectsFalseRootWithInnerObject() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+            b.field("subobjects", false);
+            b.startObject("properties");
+            {
+                b.startObject("host.name");
+                b.field("type", "keyword");
+                b.endObject();
+            }
+            b.endObject();
+        }));
+        // the root cannot hold subobjects, yet incoming docs may have objects in it, which means that no intermediate objects are mapped
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "host" : {
+                "name" : "localhost",
+                "id" : "test"
+              },
+              "time" : 10,
+              "time.max" : 1000,
+              "time.min" : 1
+            }
+            """));
+        assertThat(parsedDocument.rootDoc().getField("host.name"), instanceOf(KeywordFieldMapper.KeywordField.class));
+        assertThat(parsedDocument.rootDoc().getField("host.id"), instanceOf(Field.class));
+        for (String s : Arrays.asList("time", "time.min", "time.max")) {
+            assertThat(parsedDocument.rootDoc().getField(s), instanceOf(LongField.class));
+        }
+        RootObjectMapper root = parsedDocument.dynamicMappingsUpdate().getRoot();
+        assertEquals(4, root.mappers.size());
+        assertThat(root.getMapper("host.id"), instanceOf(TextFieldMapper.class));
+        for (String s : Arrays.asList("time", "time.min", "time.max")) {
+            assertThat(root.getMapper(s), instanceOf(NumberFieldMapper.class));
+        }
+    }
+
+    public void testSubobjectsFalseRootAndChildWithInnerObject() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(mappingNoSubobjects(xContentBuilder -> {}));
+        ParsedDocument doc = mapper.parse(source("""
+            {
+              "time" : {
+                "measured" : 10,
+                "range" : {
+                    "max" : 500,
+                    "min" : 1
+                },
+                "all.time.high": 499
+              }
+            }
+            """));
+
+        Mapping mappingsUpdate = doc.dynamicMappingsUpdate();
+        assertNotNull(mappingsUpdate);
+        assertNull(mappingsUpdate.getRoot().getMapper("time"));
+        assertNull(mappingsUpdate.getRoot().getMapper("time.range"));
+        for (String s : Arrays.asList("time.measured", "time.range.min", "time.range.max", "time.all.time.high")) {
+            assertThat(doc.rootDoc().getField(s), instanceOf(LongField.class));
+        }
+        for (String s : Arrays.asList("time.measured", "time.range.min", "time.range.max", "time.all.time.high")) {
+            assertThat(mappingsUpdate.getRoot().getMapper(s), instanceOf(NumberFieldMapper.class));
+        }
+    }
+
+    public void testSubobjectsFalseRootAndChildWithInnerObjectAndDottedNames() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(mappingNoSubobjects(xContentBuilder -> {}));
+        ParsedDocument doc = mapper.parse(source("""
+            {
+              "time.foo" : {
+                "measured" : 10,
+                "old.measure" : 9,
+                "range.values" : {
+                    "max" : 500,
+                    "min" : 1,
+                    "legacy.min": 2
+                }
+              }
+            }
+            """));
+        for (String s : Arrays.asList(
+            "time.foo.measured",
+            "time.foo.old.measure",
+            "time.foo.range.values.min",
+            "time.foo.range.values.max",
+            "time.foo.range.values.legacy.min"
+        )) {
+            assertThat(doc.rootDoc().getField(s), instanceOf(LongField.class));
+        }
+
+        Mapping mappingsUpdate = doc.dynamicMappingsUpdate();
+        assertNotNull(mappingsUpdate);
+
+        for (String s : Arrays.asList(
+            "time.foo.measured",
+            "time.foo.old.measure",
+            "time.foo.range.values.min",
+            "time.foo.range.values.max",
+            "time.foo.range.values.legacy.min"
+        )) {
+            assertThat(mappingsUpdate.getRoot().getMapper(s), instanceOf(NumberFieldMapper.class));
+        }
+
+    }
+
+    public void testSubobjectsFalseRootMixedPaths() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+            b.field("subobjects", false);
+            b.startObject("properties");
+            {
+                b.startObject("service.host.name");
+                b.field("type", "keyword");
+                b.endObject();
+            }
+            b.endObject();
+        }));
+        // the root cannot hold subobjects, yet incoming docs may have objects in it. This verifies that different ways of
+        // specifying the same path in a document are treated in the same way
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "service.time" : 100,
+              "service" : {
+                "time" : {
+                  "min" : 1
+                },
+                "time.max" : 1000,
+                "host" : {
+                  "name" : "localhost"
+                }
+              },
+              "service.time.avg" : 500
+            }
+            """));
+
+        assertThat(parsedDocument.rootDoc().getField("service.host.name"), instanceOf(KeywordFieldMapper.KeywordField.class));
+        for (String s : Arrays.asList("service.time", "service.time.min", "service.time.max", "service.time.avg")) {
+            assertThat(parsedDocument.rootDoc().getField(s), instanceOf(LongField.class));
+        }
+        assertNotNull(parsedDocument.dynamicMappingsUpdate());
+        RootObjectMapper root = parsedDocument.dynamicMappingsUpdate().getRoot();
+        assertEquals(4, root.mappers.size());
+        for (String s : Arrays.asList("service.time", "service.time.min", "service.time.max", "service.time.avg")) {
+            assertThat(root.getMapper(s), instanceOf(NumberFieldMapper.class));
+        }
+    }
+
+    public void testSubobjectsFalseDocWithInnerObjectsNullValues() throws Exception {
+        // null values are handled separately while parsing hence we want to make sure that the field paths are propagated correctly
+        DocumentMapper mapper = createDocumentMapper(mapping(b -> {
+            b.startObject("metrics");
+            {
+                b.field("type", "object").field("subobjects", false);
+                b.startObject("properties");
+                {
+                    b.startObject("service.time");
+                    b.field("type", "long");
+                    b.field("null_value", -1);
+                    b.endObject();
+                }
+                {
+                    b.startObject("service.time.max");
+                    b.field("type", "long");
+                    b.field("null_value", -1);
+                    b.endObject();
+                }
+                {
+                    b.startObject("service.time.min");
+                    b.field("type", "long");
+                    b.field("null_value", -1);
+                    b.endObject();
+                }
+                {
+                    b.startObject("service.time.avg");
+                    b.field("type", "long");
+                    b.field("null_value", -1);
+                    b.endObject();
+                }
+                b.endObject();
+            }
+            b.endObject();
+        }));
+
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "metrics": {
+                "service.time" : null,
+                "service": {
+                  "time" : {
+                    "min" : null
+                  },
+                  "time.max" : null
+                },
+                "service.time.avg" : null
+              }
+            }
+            """));
+
+        for (String s : Arrays.asList(
+            "metrics.service.time",
+            "metrics.service.time.min",
+            "metrics.service.time.max",
+            "metrics.service.time.avg"
+        )) {
+            assertThat(parsedDocument.rootDoc().getField(s), instanceOf(LongField.class));
+        }
+        assertNull(parsedDocument.dynamicMappingsUpdate());
+    }
+
+    public void testSubobjectsFalseDocsWithInnerObjectMappedAsFieldThatCanParseNativalyObjects() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(mapping(b -> {
+            b.startObject("metrics");
+            {
+                b.field("type", "object").field("subobjects", false);
+                b.startObject("properties");
+                {
+                    b.startObject("service.location");
+                    b.field("type", "geo_point");
+                    b.endObject();
+                }
+                b.endObject();
+            }
+            b.endObject();
+        }));
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "metrics": {
+                "service.location" : {
+                    "lat": 41.12,
+                    "lon": -71.34
+                  }
+              }
+            }
+            """));
+        IndexableField location = parsedDocument.rootDoc().getField("metrics.service.location");
+        assertNotNull(location);
+        assertNull(parsedDocument.rootDoc().getField("metrics.service.location.lat"));
+        assertNull(parsedDocument.rootDoc().getField("metrics.service.location.lon"));
+        assertTrue(location instanceof LatLonPoint);
+        Mapper locationMapper = mapper.mappers().getMapper("metrics.service.location");
+        assertNotNull(locationMapper);
+        assertTrue(locationMapper instanceof GeoPointFieldMapper);
+    }
+
+    public void testSubobjectsFalseDocsWithInnerObjectMappedAsNonObject() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(mapping(b -> {
+            b.startObject("metrics");
+            {
+                b.field("type", "object").field("subobjects", false);
+                b.startObject("properties");
+                {
+                    b.startObject("service.time");
+                    b.field("type", "long");
+                    b.endObject();
+                }
+                b.endObject();
+            }
+            b.endObject();
+        }));
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "metrics": {
+                "service.time" : {
+                  "min" : 1
+                }
+              }
+            }
+            """));
+        assertThat(parsedDocument.rootDoc().getField("metrics.service.time.min"), instanceOf(LongField.class));
+        assertNotNull(parsedDocument.dynamicMappingsUpdate());
+        RootObjectMapper root = parsedDocument.dynamicMappingsUpdate().getRoot();
+        assertEquals(1, root.mappers.size());
+        ObjectMapper metrics = (ObjectMapper) root.getMapper("metrics");
+        assertNotNull(metrics);
+        assertThat((FieldMapper) metrics.getMapper("service.time.min"), instanceOf(NumberFieldMapper.class));
+    }
+
+    public void testSubobjectsFalseDocsWithMultipleInnerObjectMappedAsNonObject() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(mapping(b -> {
+            b.startObject("metrics");
+            {
+                b.field("type", "object").field("subobjects", false);
+                b.startObject("properties");
+                {
+                    b.startObject("service.time");
+                    b.field("type", "long");
+                    b.endObject();
+                }
+                b.endObject();
+            }
+            b.endObject();
+        }));
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "metrics": {
+                "service.time" : {
+                  "current" : {
+                    "min" : 1,
+                    "max" : 10
+                  }
+                }
+              }
+            }
+            """));
+        assertNull(parsedDocument.rootDoc().getField("metrics.service.time"));
+        assertThat(parsedDocument.rootDoc().getField("metrics.service.time.current.min"), instanceOf(LongField.class));
+        assertThat(parsedDocument.rootDoc().getField("metrics.service.time.current.max"), instanceOf(LongField.class));
+        assertNotNull(parsedDocument.dynamicMappingsUpdate());
+        RootObjectMapper root = parsedDocument.dynamicMappingsUpdate().getRoot();
+        assertEquals(1, root.mappers.size());
+        Mapper metrics = root.getMapper("metrics");
+        assertNotNull(metrics);
+        assertThat(((ObjectMapper) metrics).getMapper("service.time.current.min"), instanceOf(NumberFieldMapper.class));
+        assertThat(((ObjectMapper) metrics).getMapper("service.time.current.max"), instanceOf(NumberFieldMapper.class));
+    }
+
+    public void testSubobjectsFalseDocsWithInnerObjectThatCanBeParsedNatively() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(mapping(b -> {
+            b.startObject("metrics");
+            {
+                b.field("type", "object").field("subobjects", false);
+                b.startObject("properties");
+                {
+                    b.startObject("service.location");
+                    b.field("type", "geo_point");
+                    b.endObject();
+                }
+                b.endObject();
+            }
+            b.endObject();
+        }));
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "metrics": {
+                "service.location" : {
+                  "lat" : 42.3,
+                  "lon" : 71.2
+                }
+              }
+            }
+            """));
+        assertNull(parsedDocument.rootDoc().getField("metrics.service.location.lat"));
+        assertNull(parsedDocument.rootDoc().getField("metrics.service.location.lon"));
+        assertThat(parsedDocument.rootDoc().getField("metrics.service.location"), instanceOf(LatLonPoint.class));
+        assertThat(mapper.mappers().getMapper("metrics.service.location"), instanceOf(GeoPointFieldMapper.class));
+    }
+
+    public void testSubobjectsFalseArrayOfObjectsMixedPaths() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(
+            mapping(b -> b.startObject("metrics").field("type", "object").field("subobjects", false).endObject())
+        );
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "metrics": [
+                {
+                  "service" : {
+                    "time" : {
+                      "max" : 1000
+                    }
+                  }
+                },
+                {
+                  "service.time" : {
+                    "min" : 1
+                  }
+                },
+                {
+                  "service.time" : 100
+                },
+                {
+                  "service" : {
+                    "time.avg" : 500
+                  }
+                }
+              ]
+            }
+            """));
+
+        for (String s : Arrays.asList(
+            "metrics.service.time",
+            "metrics.service.time.min",
+            "metrics.service.time.max",
+            "metrics.service.time.avg"
+        )) {
+            assertThat(parsedDocument.rootDoc().getField(s), instanceOf(LongField.class));
+        }
+
+        assertNotNull(parsedDocument.dynamicMappingsUpdate());
+        RootObjectMapper root = parsedDocument.dynamicMappingsUpdate().getRoot();
+        assertEquals(1, root.mappers.size());
+        ObjectMapper metrics = (ObjectMapper) root.getMapper("metrics");
+        assertEquals(4, metrics.mappers.size());
+        for (String s : Arrays.asList("service.time", "service.time.min", "service.time.max", "service.time.avg")) {
+            assertThat(metrics.getMapper(s), instanceOf(NumberFieldMapper.class));
+        }
+    }
+
+    public void testSubobjectsFalseArrayMixedContent() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(
+            mapping(b -> b.startObject("metrics").field("type", "object").field("subobjects", false).endObject())
+        );
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "metrics.service.time": [
+                1,
+                {
+                  "max" : 2
+                }
+              ]
+            }
+            """));
+
+        for (String s : Arrays.asList("metrics.service.time", "metrics.service.time.max")) {
+            assertThat(parsedDocument.rootDoc().getField(s), instanceOf(LongField.class));
+        }
+
+        assertNotNull(parsedDocument.dynamicMappingsUpdate());
+        RootObjectMapper root = parsedDocument.dynamicMappingsUpdate().getRoot();
+        assertEquals(1, root.mappers.size());
+        ObjectMapper metrics = (ObjectMapper) root.getMapper("metrics");
+        assertEquals(2, metrics.mappers.size());
+        for (String s : Arrays.asList("service.time", "service.time.max")) {
+            assertThat(metrics.getMapper(s), instanceOf(NumberFieldMapper.class));
+        }
+    }
+
+    public void testSubobjectsFalseDocsWithGeoPointFromDynamicTemplate() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+            b.startArray("dynamic_templates");
+            {
+                b.startObject();
+                b.startObject("location");
+                {
+                    b.field("match", "location");
+                    b.startObject("mapping");
+                    {
+                        b.field("type", "geo_point");
+                    }
+                    b.endObject();
+                }
+                b.endObject();
+                b.endObject();
+            }
+            b.endArray();
+            b.field("subobjects", false);
+
+        }));
+
+        ParsedDocument parsedDocument = mapper.parse(source("""
+            {
+              "location" : {
+                "lat": 41.12,
+                "lon": -71.34
+              }
+            }
+            """));
+
+        assertThat(parsedDocument.rootDoc().getField("location"), instanceOf(LatLonPoint.class));
+        RootObjectMapper root = parsedDocument.dynamicMappingsUpdate().getRoot();
+        assertEquals(1, root.mappers.size());
+        assertThat(root.getMapper("location"), instanceOf(GeoPointFieldMapper.class));
+        assertNotNull(parsedDocument.dynamicMappingsUpdate());
+    }
+
+    public void testSubobjectsFalseIngestDifferentObjectsRepresentation() throws Exception {
+        DocumentMapper mapper = createDocumentMapper(mappingNoSubobjects(b -> {}));
+
+        ParsedDocument doc1 = mapper.parse(new SourceToParse("1", new BytesArray("""
+            {
+              "foo": {
+                "bar": {
+                  "baz" : {
+                    "max" : 10,
+                    "min" : 1
+                  }
+                }
+              }
+            }
+            """), XContentType.JSON));
+        ParsedDocument doc2 = mapper.parse(new SourceToParse("2", new BytesArray("""
+            {
+              "foo": {
+                "bar.baz" : {
+                  "max" : 10,
+                  "min" : 1
+                }
+              }
+            }
+            """), XContentType.JSON));
+        ParsedDocument doc3 = mapper.parse(new SourceToParse("3", new BytesArray("""
+            {
+              "foo.bar.baz": {
+                "max" : 10,
+                "min" : 1
+              }
+            }
+            """), XContentType.JSON));
+        ParsedDocument doc4 = mapper.parse(new SourceToParse("4", new BytesArray("""
+            {
+              "foo.bar.baz.max" : 10,
+              "foo.bar.baz.min" : 1
+            }
+            """), XContentType.JSON));
+
+        for (ParsedDocument doc : Arrays.asList(doc1, doc2, doc3, doc4)) {
+            assertNotNull(doc.dynamicMappingsUpdate());
+            for (String s : Arrays.asList("foo.bar.baz.max", "foo.bar.baz.min")) {
+                assertThat(doc.rootDoc().getField(s), instanceOf(LongField.class));
+                assertThat(doc.dynamicMappingsUpdate().getRoot().getMapper(s), instanceOf(NumberFieldMapper.class));
+
+            }
+        }
+    }
+
     /**
      * Mapper plugin providing a mock metadata field mapper implementation that supports setting its value
      */