瀏覽代碼

Flatten object mappings when subobjects is false (#103542)

Felix Barnsteiner 1 年之前
父節點
當前提交
dee0be589c
共有 19 個文件被更改,包括 701 次插入139 次删除
  1. 7 0
      docs/changelog/103542.yaml
  2. 73 1
      docs/reference/mapping/params/subobjects.asciidoc
  3. 24 4
      modules/data-streams/src/yamlRestTest/resources/rest-api-spec/test/data_stream/150_tsdb.yml
  4. 1 1
      modules/percolator/src/main/java/org/elasticsearch/percolator/PercolatorFieldMapper.java
  5. 1 1
      server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java
  6. 2 2
      server/src/main/java/org/elasticsearch/index/mapper/FieldMapper.java
  7. 6 2
      server/src/main/java/org/elasticsearch/index/mapper/Mapper.java
  8. 49 6
      server/src/main/java/org/elasticsearch/index/mapper/MapperBuilderContext.java
  9. 2 2
      server/src/main/java/org/elasticsearch/index/mapper/MapperMergeContext.java
  10. 14 6
      server/src/main/java/org/elasticsearch/index/mapper/NestedObjectMapper.java
  11. 112 28
      server/src/main/java/org/elasticsearch/index/mapper/ObjectMapper.java
  12. 1 1
      server/src/main/java/org/elasticsearch/index/mapper/PassThroughObjectMapper.java
  13. 2 2
      server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapper.java
  14. 15 15
      server/src/test/java/org/elasticsearch/cluster/metadata/MetadataIndexTemplateServiceTests.java
  15. 1 1
      server/src/test/java/org/elasticsearch/index/mapper/DynamicTemplatesTests.java
  16. 294 43
      server/src/test/java/org/elasticsearch/index/mapper/MapperServiceTests.java
  17. 18 0
      server/src/test/java/org/elasticsearch/index/mapper/ObjectMapperMergeTests.java
  18. 72 16
      server/src/test/java/org/elasticsearch/index/mapper/ObjectMapperTests.java
  19. 7 8
      server/src/test/java/org/elasticsearch/index/mapper/PassThroughObjectMapperTests.java

+ 7 - 0
docs/changelog/103542.yaml

@@ -0,0 +1,7 @@
+pr: 103542
+summary: Flatten object mappings when subobjects is false
+area: Mapping
+type: feature
+issues:
+ - 99860
+ - 103497

+ 73 - 1
docs/reference/mapping/params/subobjects.asciidoc

@@ -24,7 +24,12 @@ PUT my-index-000001
     "properties": {
       "metrics": {
         "type":  "object",
-        "subobjects": false <1>
+        "subobjects": false, <1>
+        "properties": {
+          "time": { "type": "long" },
+          "time.min": { "type": "long" },
+          "time.max": { "type": "long" }
+        }
       }
     }
   }
@@ -105,3 +110,70 @@ PUT my-index-000001/_doc/metric_1
 <2> The document does not support objects
 
 The `subobjects` setting for existing fields and the top-level mapping definition cannot be updated.
+
+==== Auto-flattening object mappings
+
+It is generally recommended to define the properties of an object that is configured with `subobjects: false` with dotted field names
+(as shown in the first example).
+However, it is also possible to define these properties as sub-objects in the mappings.
+In that case, the mapping will be automatically flattened before it is stored.
+This makes it easier to re-use existing mappings without having to re-write them.
+
+Note that auto-flattening will not work when certain <<mapping-params, mapping parameters>> are set
+on object mappings that are defined under an object configured with `subobjects: false`:
+
+* The <<enabled, `enabled`>> mapping parameter must not be `false`.
+* The <<dynamic, `dynamic`>> mapping parameter must not contradict the implicit or explicit value of the parent. For example, when `dynamic` is set to `false` in the root of the mapping, object mappers that set `dynamic` to `true` can't be auto-flattened.
+* The <<subobjects, `subobjects`>> mapping parameter must not be set to `true` explicitly.
+
+[source,console]
+--------------------------------------------------
+PUT my-index-000002
+{
+  "mappings": {
+    "properties": {
+      "metrics": {
+        "subobjects": false,
+        "properties": {
+          "time": {
+            "type": "object", <1>
+            "properties": {
+              "min": { "type": "long" }, <2>
+              "max": { "type": "long" }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+GET my-index-000002/_mapping
+--------------------------------------------------
+
+[source,console-result]
+--------------------------------------------------
+{
+  "my-index-000002" : {
+    "mappings" : {
+      "properties" : {
+        "metrics" : {
+          "subobjects" : false,
+          "properties" : {
+            "time.min" : { <3>
+              "type" : "long"
+            },
+            "time.max" : {
+              "type" : "long"
+            }
+          }
+        }
+      }
+    }
+  }
+}
+--------------------------------------------------
+
+<1> The metrics object can contain further object mappings that will be auto-flattened.
+ Object mappings at this level must not set certain mapping parameters as explained above.
+<2> This field will be auto-flattened to `"time.min"` before the mapping is stored.
+<3> The auto-flattened `"time.min"` field can be inspected by looking at the index mapping.

+ 24 - 4
modules/data-streams/src/yamlRestTest/resources/rest-api-spec/test/data_stream/150_tsdb.yml

@@ -556,14 +556,13 @@ dynamic templates with nesting:
   - match: { aggregations.filterA.tsids.buckets.0.doc_count: 2 }
 
 ---
-dynamic templates - subobject in passthrough object error:
+subobject in passthrough object auto flatten:
   - skip:
       version: " - 8.12.99"
-      reason: "Support for dynamic fields was added in 8.13"
+      reason: "Support for passthrough fields was added in 8.13"
   - do:
-      catch: /Tried to add subobject \[subcategory\] to object \[attributes\] which does not support subobjects/
       indices.put_index_template:
-        name: my-dynamic-template
+        name: my-passthrough-template
         body:
           index_patterns: [k9s*]
           data_stream: {}
@@ -576,13 +575,34 @@ dynamic templates - subobject in passthrough object error:
               properties:
                 attributes:
                   type: passthrough
+                  time_series_dimension: true
                   properties:
                     subcategory:
                       type: object
                       properties:
                         dim:
                           type: keyword
+  - do:
+      indices.create_data_stream:
+        name: k9s
+  - is_true: acknowledged
+  # save the backing index names for later use
+  - do:
+      indices.get_data_stream:
+        name: k9s
+  - set: { data_streams.0.indices.0.index_name: idx0name }
 
+  - do:
+      indices.get_mapping:
+        index: $idx0name
+        expand_wildcards: hidden
+  - match: { .$idx0name.mappings.properties.attributes.properties.subcategory\.dim.type: 'keyword' }
+
+---
+enable subobjects in passthrough object:
+  - skip:
+      version: " - 8.12.99"
+      reason: "Support for passthrough fields was added in 8.13"
   - do:
       catch: /Mapping definition for \[attributes\] has unsupported parameters:\  \[subobjects \:\ true\]/
       indices.put_index_template:

+ 1 - 1
modules/percolator/src/main/java/org/elasticsearch/percolator/PercolatorFieldMapper.java

@@ -138,7 +138,7 @@ public class PercolatorFieldMapper extends FieldMapper {
             PercolatorFieldType fieldType = new PercolatorFieldType(context.buildFullName(name()), meta.getValue());
             // TODO should percolator even allow multifields?
             MultiFields multiFields = multiFieldsBuilder.build(this, context);
-            context = context.createChildContext(name());
+            context = context.createChildContext(name(), null);
             KeywordFieldMapper extractedTermsField = createExtractQueryFieldBuilder(
                 EXTRACTED_TERMS_FIELD_NAME,
                 context,

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

@@ -613,7 +613,7 @@ public abstract class DocumentParserContext {
         if (objectMapper instanceof PassThroughObjectMapper passThroughObjectMapper) {
             containsDimensions = passThroughObjectMapper.containsDimensions();
         }
-        return new MapperBuilderContext(p, mappingLookup().isSourceSynthetic(), false, containsDimensions);
+        return new MapperBuilderContext(p, mappingLookup().isSourceSynthetic(), false, containsDimensions, dynamic);
     }
 
     public abstract XContentParser parser();

+ 2 - 2
server/src/main/java/org/elasticsearch/index/mapper/FieldMapper.java

@@ -479,7 +479,7 @@ public abstract class FieldMapper extends Mapper {
                     return empty();
                 } else {
                     FieldMapper[] mappers = new FieldMapper[mapperBuilders.size()];
-                    context = context.createChildContext(mainFieldBuilder.name());
+                    context = context.createChildContext(mainFieldBuilder.name(), null);
                     int i = 0;
                     for (Map.Entry<String, Function<MapperBuilderContext, FieldMapper>> entry : this.mapperBuilders.entrySet()) {
                         mappers[i++] = entry.getValue().apply(context);
@@ -1230,7 +1230,7 @@ public abstract class FieldMapper extends Mapper {
             for (Parameter<?> param : getParameters()) {
                 param.merge(in, conflicts);
             }
-            MapperMergeContext childContext = mapperMergeContext.createChildContext(in.simpleName());
+            MapperMergeContext childContext = mapperMergeContext.createChildContext(in.simpleName(), null);
             for (FieldMapper newSubField : in.multiFields.mappers) {
                 multiFieldsBuilder.update(newSubField, childContext);
             }

+ 6 - 2
server/src/main/java/org/elasticsearch/index/mapper/Mapper.java

@@ -24,10 +24,10 @@ public abstract class Mapper implements ToXContentFragment, Iterable<Mapper> {
 
     public abstract static class Builder {
 
-        private final String name;
+        private String name;
 
         protected Builder(String name) {
-            this.name = internFieldName(name);
+            setName(name);
         }
 
         // TODO rename this to leafName?
@@ -37,6 +37,10 @@ public abstract class Mapper implements ToXContentFragment, Iterable<Mapper> {
 
         /** Returns a newly built mapper. */
         public abstract Mapper build(MapperBuilderContext context);
+
+        void setName(String name) {
+            this.name = internFieldName(name);
+        }
     }
 
     public interface TypeParser {

+ 49 - 6
server/src/main/java/org/elasticsearch/index/mapper/MapperBuilderContext.java

@@ -9,6 +9,9 @@
 package org.elasticsearch.index.mapper;
 
 import org.elasticsearch.common.Strings;
+import org.elasticsearch.core.Nullable;
+
+import java.util.Objects;
 
 /**
  * Holds context for building Mapper objects from their Builders
@@ -19,32 +22,69 @@ public class MapperBuilderContext {
      * The root context, to be used when building a tree of mappers
      */
     public static MapperBuilderContext root(boolean isSourceSynthetic, boolean isDataStream) {
-        return new MapperBuilderContext(null, isSourceSynthetic, isDataStream, false);
+        return new MapperBuilderContext(null, isSourceSynthetic, isDataStream, false, ObjectMapper.Defaults.DYNAMIC);
     }
 
     private final String path;
     private final boolean isSourceSynthetic;
     private final boolean isDataStream;
     private final boolean parentObjectContainsDimensions;
+    private final ObjectMapper.Dynamic dynamic;
 
     MapperBuilderContext(String path) {
-        this(path, false, false, false);
+        this(path, false, false, false, ObjectMapper.Defaults.DYNAMIC);
     }
 
-    MapperBuilderContext(String path, boolean isSourceSynthetic, boolean isDataStream, boolean parentObjectContainsDimensions) {
+    MapperBuilderContext(
+        String path,
+        boolean isSourceSynthetic,
+        boolean isDataStream,
+        boolean parentObjectContainsDimensions,
+        ObjectMapper.Dynamic dynamic
+    ) {
+        Objects.requireNonNull(dynamic, "dynamic must not be null");
         this.path = path;
         this.isSourceSynthetic = isSourceSynthetic;
         this.isDataStream = isDataStream;
         this.parentObjectContainsDimensions = parentObjectContainsDimensions;
+        this.dynamic = dynamic;
+    }
+
+    /**
+     * Creates a new MapperBuilderContext that is a child of this context
+     *
+     * @param name    the name of the child context
+     * @param dynamic strategy for handling dynamic mappings in this context
+     * @return a new MapperBuilderContext with this context as its parent
+     */
+    public MapperBuilderContext createChildContext(String name, @Nullable ObjectMapper.Dynamic dynamic) {
+        return createChildContext(name, this.parentObjectContainsDimensions, dynamic);
     }
 
     /**
      * Creates a new MapperBuilderContext that is a child of this context
-     * @param name the name of the child context
+     *
+     * @param name    the name of the child context
+     * @param dynamic strategy for handling dynamic mappings in this context
+     * @param parentObjectContainsDimensions whether the parent object contains dimensions
      * @return a new MapperBuilderContext with this context as its parent
      */
-    public MapperBuilderContext createChildContext(String name) {
-        return new MapperBuilderContext(buildFullName(name), isSourceSynthetic, isDataStream, parentObjectContainsDimensions);
+    public MapperBuilderContext createChildContext(
+        String name,
+        boolean parentObjectContainsDimensions,
+        @Nullable ObjectMapper.Dynamic dynamic
+    ) {
+        return new MapperBuilderContext(
+            buildFullName(name),
+            this.isSourceSynthetic,
+            this.isDataStream,
+            parentObjectContainsDimensions,
+            getDynamic(dynamic)
+        );
+    }
+
+    protected ObjectMapper.Dynamic getDynamic(@Nullable ObjectMapper.Dynamic dynamic) {
+        return dynamic == null ? this.dynamic : dynamic;
     }
 
     /**
@@ -78,4 +118,7 @@ public class MapperBuilderContext {
         return parentObjectContainsDimensions;
     }
 
+    public ObjectMapper.Dynamic getDynamic() {
+        return dynamic;
+    }
 }

+ 2 - 2
server/src/main/java/org/elasticsearch/index/mapper/MapperMergeContext.java

@@ -46,8 +46,8 @@ public final class MapperMergeContext {
      * @param name the name of the child context
      * @return a new {@link MapperMergeContext} with this context as its parent
      */
-    MapperMergeContext createChildContext(String name) {
-        return createChildContext(mapperBuilderContext.createChildContext(name));
+    MapperMergeContext createChildContext(String name, ObjectMapper.Dynamic dynamic) {
+        return createChildContext(mapperBuilderContext.createChildContext(name, dynamic));
     }
 
     /**

+ 14 - 6
server/src/main/java/org/elasticsearch/index/mapper/NestedObjectMapper.java

@@ -62,7 +62,11 @@ public class NestedObjectMapper extends ObjectMapper {
                     this.includeInRoot = Explicit.IMPLICIT_FALSE;
                 }
             }
-            NestedMapperBuilderContext nestedContext = new NestedMapperBuilderContext(context.buildFullName(name()), parentIncludedInRoot);
+            NestedMapperBuilderContext nestedContext = new NestedMapperBuilderContext(
+                context.buildFullName(name()),
+                parentIncludedInRoot,
+                context.getDynamic(dynamic)
+            );
             final String fullPath = context.buildFullName(name());
             final String nestedTypePath;
             if (indexCreatedVersion.before(IndexVersions.V_8_0_0)) {
@@ -117,14 +121,14 @@ public class NestedObjectMapper extends ObjectMapper {
 
         final boolean parentIncludedInRoot;
 
-        NestedMapperBuilderContext(String path, boolean parentIncludedInRoot) {
-            super(path);
+        NestedMapperBuilderContext(String path, boolean parentIncludedInRoot, Dynamic dynamic) {
+            super(path, false, false, false, dynamic);
             this.parentIncludedInRoot = parentIncludedInRoot;
         }
 
         @Override
-        public MapperBuilderContext createChildContext(String name) {
-            return new NestedMapperBuilderContext(buildFullName(name), parentIncludedInRoot);
+        public MapperBuilderContext createChildContext(String name, Dynamic dynamic) {
+            return new NestedMapperBuilderContext(buildFullName(name), parentIncludedInRoot, getDynamic(dynamic));
         }
     }
 
@@ -280,7 +284,11 @@ public class NestedObjectMapper extends ObjectMapper {
             parentIncludedInRoot |= this.includeInParent.value();
         }
         return mapperMergeContext.createChildContext(
-            new NestedMapperBuilderContext(mapperBuilderContext.buildFullName(name), parentIncludedInRoot)
+            new NestedMapperBuilderContext(
+                mapperBuilderContext.buildFullName(name),
+                parentIncludedInRoot,
+                mapperBuilderContext.getDynamic(dynamic)
+            )
         );
     }
 

+ 112 - 28
server/src/main/java/org/elasticsearch/index/mapper/ObjectMapper.java

@@ -14,6 +14,7 @@ import org.elasticsearch.common.Explicit;
 import org.elasticsearch.common.logging.DeprecationCategory;
 import org.elasticsearch.common.logging.DeprecationLogger;
 import org.elasticsearch.common.xcontent.support.XContentMapValues;
+import org.elasticsearch.core.Nullable;
 import org.elasticsearch.index.IndexVersion;
 import org.elasticsearch.index.IndexVersions;
 import org.elasticsearch.index.mapper.MapperService.MergeReason;
@@ -40,6 +41,7 @@ public class ObjectMapper extends Mapper {
     public static class Defaults {
         public static final boolean ENABLED = true;
         public static final Explicit<Boolean> SUBOBJECTS = Explicit.IMPLICIT_TRUE;
+        public static final Dynamic DYNAMIC = Dynamic.TRUE;
     }
 
     public enum Dynamic {
@@ -69,7 +71,7 @@ public class ObjectMapper extends Mapper {
          */
         static Dynamic getRootDynamic(MappingLookup mappingLookup) {
             ObjectMapper.Dynamic rootDynamic = mappingLookup.getMapping().getRoot().dynamic;
-            return rootDynamic == null ? ObjectMapper.Dynamic.TRUE : rootDynamic;
+            return rootDynamic == null ? Defaults.DYNAMIC : rootDynamic;
         }
     }
 
@@ -154,7 +156,6 @@ public class ObjectMapper extends Mapper {
             Map<String, Mapper> mappers = new HashMap<>();
             for (Mapper.Builder builder : mappersBuilders) {
                 Mapper mapper = builder.build(mapperBuilderContext);
-                assert mapper instanceof ObjectMapper == false || subobjects.value() : "unexpected object while subobjects are disabled";
                 Mapper existing = mappers.get(mapper.simpleName());
                 if (existing != null) {
                     // The same mappings or document may hold the same field twice, either because duplicated JSON keys are allowed or
@@ -164,7 +165,12 @@ public class ObjectMapper extends Mapper {
                     // mix of object notation and dot notation.
                     mapper = existing.merge(mapper, MapperMergeContext.from(mapperBuilderContext, Long.MAX_VALUE));
                 }
-                mappers.put(mapper.simpleName(), mapper);
+                if (subobjects.value() == false && mapper instanceof ObjectMapper objectMapper) {
+                    // We're parsing a mapping that has set `subobjects: false` but has defined sub-objects
+                    objectMapper.asFlattenedFieldMappers(mapperBuilderContext).forEach(m -> mappers.put(m.simpleName(), m));
+                } else {
+                    mappers.put(mapper.simpleName(), mapper);
+                }
             }
             return mappers;
         }
@@ -177,7 +183,7 @@ public class ObjectMapper extends Mapper {
                 enabled,
                 subobjects,
                 dynamic,
-                buildMappers(context.createChildContext(name()))
+                buildMappers(context.createChildContext(name(), dynamic))
             );
         }
     }
@@ -300,12 +306,9 @@ public class ObjectMapper extends Mapper {
                         }
                     }
 
-                    if (objBuilder.subobjects.value() == false
-                        && (type.equals(ObjectMapper.CONTENT_TYPE)
-                            || type.equals(NestedObjectMapper.CONTENT_TYPE)
-                            || type.equals(PassThroughObjectMapper.CONTENT_TYPE))) {
+                    if (objBuilder.subobjects.value() == false && type.equals(NestedObjectMapper.CONTENT_TYPE)) {
                         throw new MapperParsingException(
-                            "Tried to add subobject ["
+                            "Tried to add nested object ["
                                 + fieldName
                                 + "] to object ["
                                 + objBuilder.name()
@@ -390,6 +393,8 @@ public class ObjectMapper extends Mapper {
         } else {
             this.mappers = Map.copyOf(mappers);
         }
+        assert subobjects.value() || this.mappers.values().stream().noneMatch(m -> m instanceof ObjectMapper)
+            : "When subobjects is false, mappers must not contain an ObjectMapper";
     }
 
     /**
@@ -462,7 +467,7 @@ public class ObjectMapper extends Mapper {
     }
 
     protected MapperMergeContext createChildContext(MapperMergeContext mapperMergeContext, String name) {
-        return mapperMergeContext.createChildContext(name);
+        return mapperMergeContext.createChildContext(name, dynamic);
     }
 
     public ObjectMapper merge(Mapper mergeWith, MergeReason reason, MapperMergeContext parentMergeContext) {
@@ -527,7 +532,13 @@ public class ObjectMapper extends Mapper {
                 subObjects = existing.subobjects;
             }
             MapperMergeContext objectMergeContext = existing.createChildContext(parentMergeContext, existing.simpleName());
-            Map<String, Mapper> mergedMappers = buildMergedMappers(existing, mergeWithObject, reason, objectMergeContext);
+            Map<String, Mapper> mergedMappers = buildMergedMappers(
+                existing,
+                mergeWithObject,
+                reason,
+                objectMergeContext,
+                subObjects.value()
+            );
             return new MergeResult(
                 enabled,
                 subObjects,
@@ -540,25 +551,36 @@ public class ObjectMapper extends Mapper {
             ObjectMapper existing,
             ObjectMapper mergeWithObject,
             MergeReason reason,
-            MapperMergeContext objectMergeContext
+            MapperMergeContext objectMergeContext,
+            boolean subobjects
         ) {
-            Iterator<Mapper> iterator = mergeWithObject.iterator();
-            if (iterator.hasNext() == false) {
-                return Map.copyOf(existing.mappers);
+            Map<String, Mapper> mergedMappers = new HashMap<>();
+            for (Mapper childOfExistingMapper : existing.mappers.values()) {
+                if (subobjects == false && childOfExistingMapper instanceof ObjectMapper objectMapper) {
+                    // An existing mapping with sub-objects is merged with a mapping that has set `subobjects: false`
+                    objectMapper.asFlattenedFieldMappers(objectMergeContext.getMapperBuilderContext())
+                        .forEach(m -> mergedMappers.put(m.simpleName(), m));
+                } else {
+                    putMergedMapper(mergedMappers, childOfExistingMapper);
+                }
             }
-            Map<String, Mapper> mergedMappers = new HashMap<>(existing.mappers);
-            while (iterator.hasNext()) {
-                Mapper mergeWithMapper = iterator.next();
+            for (Mapper mergeWithMapper : mergeWithObject) {
                 Mapper mergeIntoMapper = mergedMappers.get(mergeWithMapper.simpleName());
-                Mapper merged = null;
                 if (mergeIntoMapper == null) {
-                    if (objectMergeContext.decrementFieldBudgetIfPossible(mergeWithMapper.getTotalFieldsCount())) {
-                        merged = mergeWithMapper;
+                    if (subobjects == false && mergeWithMapper instanceof ObjectMapper objectMapper) {
+                        // An existing mapping that has set `subobjects: false` is merged with a mapping with sub-objects
+                        objectMapper.asFlattenedFieldMappers(objectMergeContext.getMapperBuilderContext())
+                            .stream()
+                            .filter(m -> objectMergeContext.decrementFieldBudgetIfPossible(m.getTotalFieldsCount()))
+                            .forEach(m -> putMergedMapper(mergedMappers, m));
+                    } else if (objectMergeContext.decrementFieldBudgetIfPossible(mergeWithMapper.getTotalFieldsCount())) {
+                        putMergedMapper(mergedMappers, mergeWithMapper);
                     } else if (mergeWithMapper instanceof ObjectMapper om) {
-                        merged = truncateObjectMapper(reason, objectMergeContext, om);
+                        putMergedMapper(mergedMappers, truncateObjectMapper(reason, objectMergeContext, om));
                     }
                 } else if (mergeIntoMapper instanceof ObjectMapper objectMapper) {
-                    merged = objectMapper.merge(mergeWithMapper, reason, objectMergeContext);
+                    assert subobjects : "existing object mappers are supposed to be flattened if subobjects is false";
+                    putMergedMapper(mergedMappers, objectMapper.merge(mergeWithMapper, reason, objectMergeContext));
                 } else {
                     assert mergeIntoMapper instanceof FieldMapper || mergeIntoMapper instanceof FieldAliasMapper;
                     if (mergeWithMapper instanceof NestedObjectMapper) {
@@ -570,18 +592,21 @@ public class ObjectMapper extends Mapper {
                     // If we're merging template mappings when creating an index, then a field definition always
                     // replaces an existing one.
                     if (reason == MergeReason.INDEX_TEMPLATE) {
-                        merged = mergeWithMapper;
+                        putMergedMapper(mergedMappers, mergeWithMapper);
                     } else {
-                        merged = mergeIntoMapper.merge(mergeWithMapper, objectMergeContext);
+                        putMergedMapper(mergedMappers, mergeIntoMapper.merge(mergeWithMapper, objectMergeContext));
                     }
                 }
-                if (merged != null) {
-                    mergedMappers.put(merged.simpleName(), merged);
-                }
             }
             return Map.copyOf(mergedMappers);
         }
 
+        private static void putMergedMapper(Map<String, Mapper> mergedMappers, @Nullable Mapper merged) {
+            if (merged != null) {
+                mergedMappers.put(merged.simpleName(), merged);
+            }
+        }
+
         private static ObjectMapper truncateObjectMapper(MergeReason reason, MapperMergeContext context, ObjectMapper objectMapper) {
             // there's not enough capacity for the whole object mapper,
             // so we're just trying to add the shallow object, without it's sub-fields
@@ -594,6 +619,65 @@ public class ObjectMapper extends Mapper {
         }
     }
 
+    /**
+     * Returns all FieldMappers this ObjectMapper or its children hold.
+     * The name of the FieldMappers will be updated to reflect the hierarchy.
+     *
+     * @throws IllegalArgumentException if the mapper cannot be flattened
+     */
+    List<FieldMapper> asFlattenedFieldMappers(MapperBuilderContext context) {
+        List<FieldMapper> flattenedMappers = new ArrayList<>();
+        ContentPath path = new ContentPath();
+        asFlattenedFieldMappers(context, flattenedMappers, path);
+        return flattenedMappers;
+    }
+
+    private void asFlattenedFieldMappers(MapperBuilderContext context, List<FieldMapper> flattenedMappers, ContentPath path) {
+        ensureFlattenable(context, path);
+        path.add(simpleName());
+        for (Mapper mapper : mappers.values()) {
+            if (mapper instanceof FieldMapper fieldMapper) {
+                FieldMapper.Builder fieldBuilder = fieldMapper.getMergeBuilder();
+                fieldBuilder.setName(path.pathAsText(mapper.simpleName()));
+                flattenedMappers.add(fieldBuilder.build(context));
+            } else if (mapper instanceof ObjectMapper objectMapper) {
+                objectMapper.asFlattenedFieldMappers(context, flattenedMappers, path);
+            }
+        }
+        path.remove();
+    }
+
+    private void ensureFlattenable(MapperBuilderContext context, ContentPath path) {
+        if (dynamic != null && context.getDynamic() != dynamic) {
+            throwAutoFlatteningException(
+                path,
+                "the value of [dynamic] ("
+                    + dynamic
+                    + ") is not compatible with the value from its parent context ("
+                    + context.getDynamic()
+                    + ")"
+            );
+        }
+        if (isEnabled() == false) {
+            throwAutoFlatteningException(path, "the value of [enabled] is [false]");
+        }
+        if (subobjects.explicit() && subobjects()) {
+            throwAutoFlatteningException(path, "the value of [subobjects] is [true]");
+        }
+    }
+
+    private void throwAutoFlatteningException(ContentPath path, String reason) {
+        throw new IllegalArgumentException(
+            "Object mapper ["
+                + path.pathAsText(simpleName())
+                + "] was found in a context where subobjects is set to false. "
+                + "Auto-flattening ["
+                + path.pathAsText(simpleName())
+                + "] failed because "
+                + reason
+        );
+    }
+
     @Override
     public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
         toXContent(builder, params, null);

+ 1 - 1
server/src/main/java/org/elasticsearch/index/mapper/PassThroughObjectMapper.java

@@ -60,7 +60,7 @@ public class PassThroughObjectMapper extends ObjectMapper {
                 context.buildFullName(name()),
                 enabled,
                 dynamic,
-                buildMappers(context.createChildContext(name())),
+                buildMappers(context.createChildContext(name(), timeSeriesDimensionSubFields.value(), dynamic)),
                 timeSeriesDimensionSubFields
             );
         }

+ 2 - 2
server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapper.java

@@ -111,7 +111,7 @@ public class RootObjectMapper extends ObjectMapper {
 
         @Override
         public RootObjectMapper build(MapperBuilderContext context) {
-            Map<String, Mapper> mappers = buildMappers(context);
+            Map<String, Mapper> mappers = buildMappers(context.createChildContext(null, dynamic));
             mappers.putAll(getAliasMappers(mappers, context));
             return new RootObjectMapper(
                 name(),
@@ -294,7 +294,7 @@ public class RootObjectMapper extends ObjectMapper {
     @Override
     protected MapperMergeContext createChildContext(MapperMergeContext mapperMergeContext, String name) {
         assert Objects.equals(mapperMergeContext.getMapperBuilderContext().buildFullName("foo"), "foo");
-        return mapperMergeContext;
+        return mapperMergeContext.createChildContext(null, dynamic);
     }
 
     @Override

+ 15 - 15
server/src/test/java/org/elasticsearch/cluster/metadata/MetadataIndexTemplateServiceTests.java

@@ -1778,7 +1778,7 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                   "properties": {
                     "field2": {
                       "type": "object",
-                                  "subobjects": false,
+                      "subobjects": false,
                       "properties": {
                         "foo": {
                           "type": "integer"
@@ -1803,12 +1803,12 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                 {
                       "properties": {
                         "field2": {
-                                  "type": "object",
-                                  "properties": {
-                                    "bar": {
-                                      "type": "object"
-                                    }
-                                  }
+                          "type": "object",
+                          "properties": {
+                            "bar": {
+                              "type": "nested"
+                            }
+                          }
                         }
                       }
                     }"""), null))
@@ -1834,7 +1834,7 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         assertNotNull(e.getCause().getCause());
         assertThat(
             e.getCause().getCause().getMessage(),
-            containsString("Tried to add subobject [bar] to object [field2] which does not support subobjects")
+            containsString("Tried to add nested object [bar] to object [field2] which does not support subobjects")
         );
     }
 
@@ -1920,12 +1920,12 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
             {
               "properties": {
                 "field2": {
-                                  "type": "object",
-                                  "properties": {
-                                    "bar": {
-                                      "type": "object"
-                                    }
-                                  }
+                  "type": "object",
+                  "properties": {
+                    "bar": {
+                      "type": "nested"
+                    }
+                  }
                 }
               }
             }
@@ -1951,7 +1951,7 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         assertNotNull(e.getCause().getCause().getCause());
         assertThat(
             e.getCause().getCause().getCause().getMessage(),
-            containsString("Tried to add subobject [bar] to object [field2] which does not support subobjects")
+            containsString("Tried to add nested object [bar] to object [field2] which does not support subobjects")
         );
     }
 

+ 1 - 1
server/src/test/java/org/elasticsearch/index/mapper/DynamicTemplatesTests.java

@@ -1430,7 +1430,7 @@ public class DynamicTemplatesTests extends MapperServiceTestCase {
         );
         assertThat(exception.getRootCause(), instanceOf(MapperParsingException.class));
         assertEquals(
-            "Tried to add subobject [time] to object [__dynamic__test] which does not support subobjects",
+            "Tried to add nested object [time] to object [__dynamic__test] which does not support subobjects",
             exception.getRootCause().getMessage()
         );
     }

+ 294 - 43
server/src/test/java/org/elasticsearch/index/mapper/MapperServiceTests.java

@@ -567,10 +567,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
               }
             }""");
 
-        final MapperService mapperService = createMapperService(mapping(b -> {}));
-        mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE);
-
-        assertEquals("""
+        assertMergeEquals(List.of(mapping1, mapping2), """
             {
               "_doc" : {
                 "_meta" : {
@@ -587,7 +584,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
                   }
                 }
               }
-            }""", Strings.toString(mapperService.documentMapper().mapping(), true, true));
+            }""");
     }
 
     public void testMergeMultipleRootsWithRootType() throws IOException {
@@ -641,10 +638,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
               }
             }""");
 
-        final MapperService mapperService = createMapperService(mapping(b -> {}));
-        mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE);
-
-        assertEquals("""
+        assertMergeEquals(List.of(mapping1, mapping2), """
             {
               "_doc" : {
                 "_meta" : {
@@ -656,7 +650,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
                   }
                 }
               }
-            }""", Strings.toString(mapperService.documentMapper().mapping(), true, true));
+            }""");
     }
 
     public void testValidMappingSubstitution() throws IOException {
@@ -680,10 +674,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
               }
             }""");
 
-        final MapperService mapperService = createMapperService(mapping(b -> {}));
-        mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE);
-
-        assertEquals("""
+        assertMergeEquals(List.of(mapping1, mapping2), """
             {
               "_doc" : {
                 "properties" : {
@@ -693,7 +684,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
                   }
                 }
               }
-            }""", Strings.toString(mapperService.documentMapper().mapping(), true, true));
+            }""");
     }
 
     public void testValidMappingSubtreeSubstitution() throws IOException {
@@ -770,10 +761,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
               }
             }""");
 
-        final MapperService mapperService = createMapperService(mapping(b -> {}));
-        mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE);
-
-        assertEquals("""
+        assertMergeEquals(List.of(mapping1, mapping2), """
             {
               "_doc" : {
                 "properties" : {
@@ -788,7 +776,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
                   }
                 }
               }
-            }""", Strings.toString(mapperService.documentMapper().mapping(), true, true));
+            }""");
     }
 
     public void testObjectAndNestedTypeSubstitution() throws IOException {
@@ -874,10 +862,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
               }
             }""");
 
-        final MapperService mapperService = createMapperService(mapping(b -> {}));
-        mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE);
-
-        assertEquals("""
+        assertMergeEquals(List.of(mapping1, mapping2), """
             {
               "_doc" : {
                 "properties" : {
@@ -895,7 +880,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
                   }
                 }
               }
-            }""", Strings.toString(mapperService.documentMapper().mapping(), true, true));
+            }""");
     }
 
     public void testImplicitObjectHierarchy() throws IOException {
@@ -912,10 +897,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
               }
             }""");
 
-        final MapperService mapperService = createMapperService(mapping(b -> {}));
-        DocumentMapper bulkMerge = mapperService.merge("_doc", List.of(mapping1), MergeReason.INDEX_TEMPLATE);
-
-        assertEquals("""
+        assertMergeEquals(List.of(mapping1), """
             {
               "_doc" : {
                 "properties" : {
@@ -932,10 +914,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
                   }
                 }
               }
-            }""", Strings.toString(mapperService.documentMapper().mapping(), true, true));
-
-        DocumentMapper sequentialMerge = mapperService.merge("_doc", mapping1, MergeReason.INDEX_TEMPLATE);
-        assertEquals(bulkMerge.mappingSource(), sequentialMerge.mappingSource());
+            }""");
     }
 
     public void testSubobjectsMerge() throws IOException {
@@ -965,7 +944,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
         final MapperService mapperService = createMapperService(mapping(b -> {}));
         mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE);
 
-        assertEquals("""
+        assertMergeEquals(List.of(mapping1, mapping2), """
             {
               "_doc" : {
                 "properties" : {
@@ -979,7 +958,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
                   }
                 }
               }
-            }""", Strings.toString(mapperService.documentMapper().mapping(), true, true));
+            }""");
     }
 
     public void testContradictingSubobjects() throws IOException {
@@ -1039,7 +1018,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
         mapperService = createMapperService(mapping(b -> {}));
         mapperService.merge("_doc", List.of(mapping2, mapping1), MergeReason.INDEX_TEMPLATE);
 
-        assertEquals("""
+        assertMergeEquals(List.of(mapping2, mapping1), """
             {
               "_doc" : {
                 "properties" : {
@@ -1053,7 +1032,7 @@ public class MapperServiceTests extends MapperServiceTestCase {
                   }
                 }
               }
-            }""", Strings.toString(mapperService.documentMapper().mapping(), true, true));
+            }""");
     }
 
     public void testSubobjectsImplicitObjectsMerge() throws IOException {
@@ -1076,12 +1055,21 @@ public class MapperServiceTests extends MapperServiceTestCase {
               }
             }""");
 
-        final MapperService mapperService = createMapperService(mapping(b -> {}));
-        MapperParsingException e = expectThrows(
-            MapperParsingException.class,
-            () -> mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE)
-        );
-        assertThat(e.getMessage(), containsString("Tried to add subobject [child] to object [parent] which does not support subobjects"));
+        assertMergeEquals(List.of(mapping1, mapping2), """
+            {
+              "_doc" : {
+                "properties" : {
+                  "parent" : {
+                    "subobjects" : false,
+                    "properties" : {
+                      "child.grandchild" : {
+                        "type" : "keyword"
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
     }
 
     public void testMultipleTypeMerges() throws IOException {
@@ -1467,4 +1455,267 @@ public class MapperServiceTests extends MapperServiceTestCase {
         assertNull(mapper.mappers().getMapper("parent.child"));
     }
 
+    public void testAutoFlattenObjectsSubobjectsTopLevelMerge() throws IOException {
+        CompressedXContent mapping1 = new CompressedXContent("""
+            {
+              "subobjects": false
+            }""");
+
+        CompressedXContent mapping2 = new CompressedXContent("""
+            {
+              "properties": {
+                "parent": {
+                  "properties": {
+                    "child": {
+                    "dynamic": true,
+                      "properties": {
+                        "grandchild": {
+                          "type": "keyword"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+
+        assertMergeEquals(List.of(mapping1, mapping2), """
+            {
+              "_doc" : {
+                "subobjects" : false,
+                "properties" : {
+                  "parent.child.grandchild" : {
+                    "type" : "keyword"
+                  }
+                }
+              }
+            }""");
+    }
+
+    public void testAutoFlattenObjectsSubobjectsMerge() throws IOException {
+        CompressedXContent mapping1 = new CompressedXContent("""
+            {
+              "properties" : {
+                "parent" : {
+                  "properties" : {
+                    "child" : {
+                      "type": "object"
+                    }
+                  }
+                }
+              }
+            }""");
+
+        CompressedXContent mapping2 = new CompressedXContent("""
+            {
+              "properties" : {
+                "parent" : {
+                  "subobjects" : false,
+                  "properties" : {
+                    "child" : {
+                      "properties" : {
+                        "grandchild" : {
+                          "type" : "keyword"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+
+        assertMergeEquals(List.of(mapping1, mapping2), """
+            {
+              "_doc" : {
+                "properties" : {
+                  "parent" : {
+                    "subobjects" : false,
+                    "properties" : {
+                      "child.grandchild" : {
+                        "type" : "keyword"
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+
+        assertMergeEquals(List.of(mapping2, mapping1), """
+            {
+              "_doc" : {
+                "properties" : {
+                  "parent" : {
+                    "subobjects" : false,
+                    "properties" : {
+                      "child.grandchild" : {
+                        "type" : "keyword"
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+    }
+
+    public void testAutoFlattenObjectsSubobjectsMergeConflictingMappingParameter() throws IOException {
+        CompressedXContent mapping1 = new CompressedXContent("""
+            {
+              "subobjects": false
+            }""");
+
+        CompressedXContent mapping2 = new CompressedXContent("""
+            {
+              "properties": {
+                "parent": {
+                  "dynamic": "false",
+                  "properties": {
+                    "child": {
+                      "properties": {
+                        "grandchild": {
+                          "type": "keyword"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+
+        final MapperService mapperService = createMapperService(mapping(b -> {}));
+        MapperParsingException e = expectThrows(
+            MapperParsingException.class,
+            () -> mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE)
+        );
+        assertThat(
+            e.getMessage(),
+            containsString(
+                "Failed to parse mapping: Object mapper [parent] was found in a context where subobjects is set to false. "
+                    + "Auto-flattening [parent] failed because the value of [dynamic] (FALSE) is not compatible "
+                    + "with the value from its parent context (TRUE)"
+            )
+        );
+    }
+
+    public void testAutoFlattenObjectsSubobjectsMergeConflictingMappingParameterRoot() throws IOException {
+        CompressedXContent mapping1 = new CompressedXContent("""
+            {
+              "subobjects": false,
+              "dynamic": false
+            }""");
+
+        CompressedXContent mapping2 = new CompressedXContent("""
+            {
+              "subobjects": false,
+              "properties": {
+                "parent": {
+                  "dynamic": "true",
+                  "properties": {
+                    "child": {
+                      "properties": {
+                        "grandchild": {
+                          "type": "keyword"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+
+        final MapperService mapperService = createMapperService(mapping(b -> {}));
+        MapperParsingException e = expectThrows(
+            MapperParsingException.class,
+            () -> mapperService.merge("_doc", List.of(mapping1, mapping2), MergeReason.INDEX_TEMPLATE)
+        );
+        assertThat(
+            e.getMessage(),
+            containsString(
+                "Failed to parse mapping: Object mapper [parent] was found in a context where subobjects is set to false. "
+                    + "Auto-flattening [parent] failed because the value of [dynamic] (TRUE) is not compatible "
+                    + "with the value from its parent context (FALSE)"
+            )
+        );
+    }
+
+    public void testAutoFlattenObjectsSubobjectsMergeNonConflictingMappingParameter() throws IOException {
+        CompressedXContent mapping = new CompressedXContent("""
+            {
+              "dynamic": false,
+              "properties": {
+                "parent": {
+                  "dynamic": true,
+                  "enabled": false,
+                  "subobjects": false,
+                  "properties": {
+                    "child": {
+                      "properties": {
+                        "grandchild": {
+                          "type": "keyword"
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+
+        assertMergeEquals(List.of(mapping), """
+            {
+              "_doc" : {
+                "dynamic" : "false",
+                "properties" : {
+                  "parent" : {
+                    "dynamic" : "true",
+                    "enabled" : false,
+                    "subobjects" : false,
+                    "properties" : {
+                      "child.grandchild" : {
+                        "type" : "keyword"
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+    }
+
+    public void testExpandDottedNotationToObjectMappers() throws IOException {
+        CompressedXContent mapping1 = new CompressedXContent("""
+            {
+              "properties": {
+                "parent.child": {
+                  "type": "keyword"
+                }
+              }
+            }""");
+
+        CompressedXContent mapping2 = new CompressedXContent("{}");
+
+        assertMergeEquals(List.of(mapping1, mapping2), """
+            {
+              "_doc" : {
+                "properties" : {
+                  "parent" : {
+                    "properties" : {
+                      "child" : {
+                        "type" : "keyword"
+                      }
+                    }
+                  }
+                }
+              }
+            }""");
+    }
+
+    private void assertMergeEquals(List<CompressedXContent> mappingSources, String expected) throws IOException {
+        final MapperService mapperServiceBulk = createMapperService(mapping(b -> {}));
+        // simulates multiple component templates being merged in a composable index template
+        mapperServiceBulk.merge("_doc", mappingSources, MergeReason.INDEX_TEMPLATE);
+        assertEquals(expected, Strings.toString(mapperServiceBulk.documentMapper().mapping(), true, true));
+
+        MapperService mapperServiceSequential = createMapperService(mapping(b -> {}));
+        // simulates a series of mapping updates
+        mappingSources.forEach(m -> mapperServiceSequential.merge("_doc", m, MergeReason.INDEX_TEMPLATE));
+        assertEquals(expected, Strings.toString(mapperServiceSequential.documentMapper().mapping(), true, true));
+    }
 }

+ 18 - 0
server/src/test/java/org/elasticsearch/index/mapper/ObjectMapperMergeTests.java

@@ -311,6 +311,24 @@ public final class ObjectMapperMergeTests extends ESTestCase {
         assertEquals(4, mergedAdd1.getTotalFieldsCount());
     }
 
+    public void testMergeSubobjectsFalseWithObject() {
+        RootObjectMapper mergeInto = new RootObjectMapper.Builder("_doc", Explicit.IMPLICIT_TRUE).add(
+            new ObjectMapper.Builder("parent", Explicit.IMPLICIT_FALSE)
+        ).build(MapperBuilderContext.root(false, false));
+        RootObjectMapper mergeWith = new RootObjectMapper.Builder("_doc", Explicit.IMPLICIT_TRUE).add(
+            new ObjectMapper.Builder("parent", Explicit.IMPLICIT_TRUE).add(
+                new ObjectMapper.Builder("child", Explicit.IMPLICIT_TRUE).add(
+                    new KeywordFieldMapper.Builder("grandchild", IndexVersion.current())
+                )
+            )
+        ).build(MapperBuilderContext.root(false, false));
+
+        ObjectMapper merged = mergeInto.merge(mergeWith, MapperMergeContext.root(false, false, Long.MAX_VALUE));
+        ObjectMapper parentMapper = (ObjectMapper) merged.getMapper("parent");
+        assertNotNull(parentMapper);
+        assertNotNull(parentMapper.getMapper("child.grandchild"));
+    }
+
     private static RootObjectMapper createRootSubobjectFalseLeafWithDots() {
         FieldMapper.Builder fieldBuilder = new KeywordFieldMapper.Builder("host.name", IndexVersion.current());
         FieldMapper fieldMapper = fieldBuilder.build(MapperBuilderContext.root(false, false));

+ 72 - 16
server/src/test/java/org/elasticsearch/index/mapper/ObjectMapperTests.java

@@ -25,6 +25,7 @@ import org.elasticsearch.xcontent.XContentType;
 import java.io.IOException;
 import java.util.List;
 
+import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.instanceOf;
@@ -362,8 +363,8 @@ public class ObjectMapperTests extends MapperServiceTestCase {
         assertNotNull(mapperService.fieldType("metrics.service.time.max"));
     }
 
-    public void testSubobjectsFalseWithInnerObject() {
-        MapperParsingException exception = expectThrows(MapperParsingException.class, () -> createMapperService(mapping(b -> {
+    public void testSubobjectsFalseWithInnerObject() throws IOException {
+        MapperService mapperService = createMapperService(mapping(b -> {
             b.startObject("metrics.service");
             {
                 b.field("subobjects", false);
@@ -384,11 +385,9 @@ public class ObjectMapperTests extends MapperServiceTestCase {
                 b.endObject();
             }
             b.endObject();
-        })));
-        assertEquals(
-            "Failed to parse mapping: Tried to add subobject [time] to object [service] which does not support subobjects",
-            exception.getMessage()
-        );
+        }));
+        assertNull(mapperService.fieldType("metrics.service.time"));
+        assertNotNull(mapperService.fieldType("metrics.service.time.max"));
     }
 
     public void testSubobjectsFalseWithInnerNested() {
@@ -407,7 +406,7 @@ public class ObjectMapperTests extends MapperServiceTestCase {
             b.endObject();
         })));
         assertEquals(
-            "Failed to parse mapping: Tried to add subobject [time] to object [service] which does not support subobjects",
+            "Failed to parse mapping: Tried to add nested object [time] to object [service] which does not support subobjects",
             exception.getMessage()
         );
     }
@@ -430,8 +429,8 @@ public class ObjectMapperTests extends MapperServiceTestCase {
         assertEquals("{\"_doc\":{\"subobjects\":true}}", Strings.toString(mapperService.mappingLookup().getMapping()));
     }
 
-    public void testSubobjectsFalseRootWithInnerObject() {
-        MapperParsingException exception = expectThrows(MapperParsingException.class, () -> createMapperService(mappingNoSubobjects(b -> {
+    public void testSubobjectsFalseRootWithInnerObject() throws IOException {
+        MapperService mapperService = createMapperService(mappingNoSubobjects(b -> {
             b.startObject("metrics.service.time");
             {
                 b.startObject("properties");
@@ -443,11 +442,9 @@ public class ObjectMapperTests extends MapperServiceTestCase {
                 b.endObject();
             }
             b.endObject();
-        })));
-        assertEquals(
-            "Failed to parse mapping: Tried to add subobject [metrics.service.time] to object [_doc] which does not support subobjects",
-            exception.getMessage()
-        );
+        }));
+        assertNull(mapperService.fieldType("metrics.service.time"));
+        assertNotNull(mapperService.fieldType("metrics.service.time.max"));
     }
 
     public void testSubobjectsFalseRootWithInnerNested() {
@@ -457,7 +454,7 @@ public class ObjectMapperTests extends MapperServiceTestCase {
             b.endObject();
         })));
         assertEquals(
-            "Failed to parse mapping: Tried to add subobject [metrics.service] to object [_doc] which does not support subobjects",
+            "Failed to parse mapping: Tried to add nested object [metrics.service] to object [_doc] which does not support subobjects",
             exception.getMessage()
         );
     }
@@ -575,4 +572,63 @@ public class ObjectMapperTests extends MapperServiceTestCase {
         }));
         return (ObjectMapper) mapper.mapping().getRoot().getMapper("object");
     }
+
+    public void testFlatten() {
+        MapperBuilderContext rootContext = MapperBuilderContext.root(false, false);
+        ObjectMapper objectMapper = new ObjectMapper.Builder("parent", Explicit.IMPLICIT_TRUE).add(
+            new ObjectMapper.Builder("child", Explicit.IMPLICIT_TRUE).add(
+                new KeywordFieldMapper.Builder("keyword2", IndexVersion.current())
+            )
+        ).add(new KeywordFieldMapper.Builder("keyword1", IndexVersion.current())).build(rootContext);
+        List<String> fields = objectMapper.asFlattenedFieldMappers(rootContext).stream().map(FieldMapper::name).toList();
+        assertThat(fields, containsInAnyOrder("parent.keyword1", "parent.child.keyword2"));
+    }
+
+    public void testFlattenDynamicIncompatible() {
+        MapperBuilderContext rootContext = MapperBuilderContext.root(false, false);
+        ObjectMapper objectMapper = new ObjectMapper.Builder("parent", Explicit.IMPLICIT_TRUE).add(
+            new ObjectMapper.Builder("child", Explicit.IMPLICIT_TRUE).dynamic(Dynamic.FALSE)
+        ).build(rootContext);
+
+        IllegalArgumentException exception = expectThrows(
+            IllegalArgumentException.class,
+            () -> objectMapper.asFlattenedFieldMappers(rootContext)
+        );
+        assertEquals(
+            "Object mapper [parent.child] was found in a context where subobjects is set to false. "
+                + "Auto-flattening [parent.child] failed because the value of [dynamic] (FALSE) is not compatible with "
+                + "the value from its parent context (TRUE)",
+            exception.getMessage()
+        );
+    }
+
+    public void testFlattenEnabledFalse() {
+        MapperBuilderContext rootContext = MapperBuilderContext.root(false, false);
+        ObjectMapper objectMapper = new ObjectMapper.Builder("parent", Explicit.IMPLICIT_TRUE).enabled(false).build(rootContext);
+
+        IllegalArgumentException exception = expectThrows(
+            IllegalArgumentException.class,
+            () -> objectMapper.asFlattenedFieldMappers(rootContext)
+        );
+        assertEquals(
+            "Object mapper [parent] was found in a context where subobjects is set to false. "
+                + "Auto-flattening [parent] failed because the value of [enabled] is [false]",
+            exception.getMessage()
+        );
+    }
+
+    public void testFlattenExplicitSubobjectsTrue() {
+        MapperBuilderContext rootContext = MapperBuilderContext.root(false, false);
+        ObjectMapper objectMapper = new ObjectMapper.Builder("parent", Explicit.EXPLICIT_TRUE).build(rootContext);
+
+        IllegalArgumentException exception = expectThrows(
+            IllegalArgumentException.class,
+            () -> objectMapper.asFlattenedFieldMappers(rootContext)
+        );
+        assertEquals(
+            "Object mapper [parent] was found in a context where subobjects is set to false. "
+                + "Auto-flattening [parent] failed because the value of [subobjects] is [true]",
+            exception.getMessage()
+        );
+    }
 }

+ 7 - 8
server/src/test/java/org/elasticsearch/index/mapper/PassThroughObjectMapperTests.java

@@ -90,9 +90,9 @@ public class PassThroughObjectMapperTests extends MapperServiceTestCase {
         );
     }
 
-    public void testAddSubobjectThrows() throws IOException {
-        MapperException exception = expectThrows(MapperException.class, () -> createMapperService(mapping(b -> {
-            b.startObject("labels").field("type", "passthrough");
+    public void testAddSubobjectAutoFlatten() throws IOException {
+        MapperService mapperService = createMapperService(mapping(b -> {
+            b.startObject("labels").field("type", "passthrough").field("time_series_dimension", "true");
             {
                 b.startObject("properties");
                 {
@@ -107,12 +107,11 @@ public class PassThroughObjectMapperTests extends MapperServiceTestCase {
                 b.endObject();
             }
             b.endObject();
-        })));
+        }));
 
-        assertEquals(
-            "Failed to parse mapping: Tried to add subobject [subobj] to object [labels] which does not support subobjects",
-            exception.getMessage()
-        );
+        var dim = mapperService.mappingLookup().getMapper("labels.subobj.dim");
+        assertThat(dim, instanceOf(KeywordFieldMapper.class));
+        assertTrue(((KeywordFieldMapper) dim).fieldType().isDimension());
     }
 
     public void testWithoutMappers() throws IOException {