diff --git a/server/src/main/java/org/opensearch/index/mapper/DocumentParser.java b/server/src/main/java/org/opensearch/index/mapper/DocumentParser.java
index ba775f76445af..3da5ab52cfffc 100644
--- a/server/src/main/java/org/opensearch/index/mapper/DocumentParser.java
+++ b/server/src/main/java/org/opensearch/index/mapper/DocumentParser.java
@@ -32,6 +32,8 @@
package org.opensearch.index.mapper;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
import org.apache.lucene.document.Field;
import org.apache.lucene.index.FieldInfos;
import org.apache.lucene.index.IndexableField;
@@ -76,6 +78,8 @@
*/
final class DocumentParser {
+ private static final Logger logger = LogManager.getLogger(DocumentParser.class);
+
private final IndexSettings indexSettings;
private final DocumentMapperParser docMapperParser;
private final DocumentMapper docMapper;
@@ -647,6 +651,17 @@ private static void innerParseObject(
parser.skipChildren();
}
} else {
+ // Before branching by token type, offer the field to plugin inferencers.
+ // This is the single convergence point where we know the field name, the
+ // incoming token type, and paths — before the code fans out to parseObject /
+ // parseArray / parseValue. Placing the hook here means one method covers arrays,
+ // objects, and scalars rather than requiring three separate hooks. The hook only
+ // fires for unmapped fields; if the field has a mapper we skip directly to the
+ // switch below via the early-return inside tryPluginInference.
+ if (tryPluginInference(context, mapper, currentFieldName, paths)) {
+ token = parser.nextToken();
+ continue;
+ }
// Process different token types during object parsing
switch (token) {
case START_OBJECT:
@@ -1122,6 +1137,7 @@ private static void parseArray(ParseContext context, ObjectMapper parentMapper,
case TRUE:
case STRICT_ALLOW_TEMPLATES:
case FALSE_ALLOW_TEMPLATES:
+ // Try template matching with OBJECT type (existing behavior).
Mapper.Builder builder = findTemplateBuilder(
context,
arrayFieldName,
@@ -1170,6 +1186,279 @@ private static boolean parsesArrayValue(Mapper mapper) {
return mapper instanceof FieldMapper fieldMapper && fieldMapper.parsesArrayValue();
}
+ /**
+ * Offers an unmapped field to all registered plugin inferencers and plugin-registered dynamic template types
+ * before the normal token-type branching takes place in {@link #innerParseObject}.
+ *
+ *
This is called at the single convergence point in {@code innerParseObject} where the field name,
+ * the incoming token type, and the parser position are all known simultaneously — before the code fans
+ * out into {@code parseObject} / {@code parseArray} / {@code parseValue}. Placing the hook here means
+ * one method handles arrays, objects, and scalars without requiring separate hooks per token type,
+ * making the SPI genuinely generic rather than array-specific.
+ *
+ *
Fast-path exits (no buffering, no deserialization):
+ *
+ * - No inferencers registered ({@code inferencers.isEmpty()}) — zero overhead.
+ * - Field is already mapped ({@code getMapper()} returns non-null) — delegate to normal path.
+ * - Dynamic mapping is {@code STRICT} or {@code FALSE} on the parent object — existing behavior applies.
+ *
+ *
+ * When buffering does occur, the content is kept for replay: if no plugin claims the field, the
+ * same bytes are replayed through the normal unmapped-field logic so existing behavior is preserved.
+ *
+ * @return {@code true} if this method consumed the parser (either a plugin claimed the field or the
+ * content was replayed through the existing path); {@code false} to fall through to the normal
+ * token-type switch in {@code innerParseObject}.
+ */
+ private static boolean tryPluginInference(ParseContext context, ObjectMapper parentMapper, String fieldName, String[] paths)
+ throws IOException {
+ // Fast path: no plugins registered — zero overhead
+ List inferencers = context.mapperService().getDynamicFieldTypeInferencers();
+ Map templateTypes = context.mapperService().getDynamicTemplateTypes();
+ if (inferencers.isEmpty() && templateTypes.isEmpty()) {
+ return false;
+ }
+
+ // Fast path: field is already mapped — let the normal path handle it
+ Mapper existingMapper = getMapper(context, parentMapper, fieldName, paths);
+ if (existingMapper != null) {
+ return false;
+ }
+
+ // Only fire for dynamic=TRUE / STRICT_ALLOW_TEMPLATES / FALSE_ALLOW_TEMPLATES
+ final String[] resolvedPaths = paths != null ? paths : splitAndValidatePath(fieldName);
+ Tuple parentMapperTuple = getDynamicParentMapper(context, resolvedPaths, parentMapper);
+ ObjectMapper resolvedParent = parentMapperTuple.v2();
+ ObjectMapper.Dynamic dynamic = dynamicOrDefault(resolvedParent, context);
+ if (dynamic == ObjectMapper.Dynamic.STRICT || dynamic == ObjectMapper.Dynamic.FALSE) {
+ // Release path-slots added by getDynamicParentMapper before returning
+ for (int i = 0; i < parentMapperTuple.v1(); i++) {
+ context.path().remove();
+ }
+ return false;
+ }
+
+ XContentParser parser = context.parser();
+ MediaType contentType = parser.contentType();
+
+ // Buffer the complete field value — needed for replay regardless of whether
+ // a plugin claims the field or not (streaming parser can only be read once)
+ byte[] rawContent;
+ try (XContentBuilder bufferBuilder = XContentBuilder.builder(contentType.xContent())) {
+ bufferBuilder.copyCurrentStructure(parser);
+ rawContent = BytesReference.toBytes(BytesReference.bytes(bufferBuilder));
+ }
+
+ // Hand plugins a factory that produces a fresh parser over the buffered bytes rather than a
+ // pre-deserialized object. Core stays free of any representation contract: each plugin streams
+ // the tokens it needs. Plugins whose config is already complete never call get(), so no parsing
+ // happens for them.
+ final FieldValueParserSupplier fieldValueParser = new FieldValueParserSupplier(
+ contentType,
+ parser.getXContentRegistry(),
+ parser.getDeprecationHandler(),
+ rawContent
+ );
+
+ final String resolvedFieldName = resolvedPaths[resolvedPaths.length - 1];
+
+ // Step 1: Check plugin-registered dynamic templates first — explicit user intent beats
+ // auto-inference. A user who writes match_mapping_type: "knn_vector" with dimension: 64
+ // has declared their intent; we must not reject it because it falls below the inferencer
+ // threshold. Template matching is already scoped by the user's match/path_match patterns
+ // on the DynamicTemplate, so it only fires for fields the user intended.
+ //
+ // We evaluate ALL registered template types rather than stopping at the first match: if two
+ // plugin types both match the same field, that is an ambiguous configuration and we fail
+ // loudly (per OpenSearch triage) rather than silently letting registration order decide.
+ Mapper.Builder templateBuilder = null;
+ String templateMatchedType = null;
+ for (Map.Entry entry : templateTypes.entrySet()) {
+ Mapper.Builder candidate = findPluginTemplateBuilder(
+ context,
+ resolvedFieldName,
+ entry,
+ dynamic,
+ resolvedParent.fullPath(),
+ fieldValueParser
+ );
+ if (candidate != null) {
+ if (templateBuilder != null) {
+ throw new MapperParsingException(
+ "field ["
+ + resolvedFieldName
+ + "] matched more than one dynamic template plugin type: ["
+ + templateMatchedType
+ + "] and ["
+ + entry.getKey()
+ + "]; the mapping is ambiguous"
+ );
+ }
+ templateBuilder = candidate;
+ templateMatchedType = entry.getKey();
+ }
+ }
+ if (templateBuilder != null) {
+ Mapper.BuilderContext templateBuilderContext = new Mapper.BuilderContext(context.indexSettings().getSettings(), context.path());
+ Mapper templateMapper = templateBuilder.build(templateBuilderContext);
+ context.addDynamicMapper(templateMapper);
+ try (
+ XContentParser replayParser = contentType.xContent()
+ .createParser(context.parser().getXContentRegistry(), context.parser().getDeprecationHandler(), rawContent)
+ ) {
+ replayParser.nextToken();
+ ParseContext replayContext = context.switchParser(replayParser);
+ context.path().add(resolvedFieldName);
+ try {
+ parseObjectOrField(replayContext, templateMapper);
+ } finally {
+ // Release the field-name slot even if replay throws, so ContentPath is not left
+ // corrupt for subsequent fields in the same document.
+ context.path().remove();
+ }
+ } finally {
+ for (int i = 0; i < parentMapperTuple.v1(); i++) {
+ context.path().remove();
+ }
+ }
+ return true;
+ }
+
+ // Step 2: No template matched — run the inferencers as the auto-detection fallback.
+ // This is the path for fields with no user-defined template: each inferencer checks whether
+ // the field looks like a plugin-managed type (e.g. numeric array >= 128 elements).
+ //
+ // We consult ALL registered inferencers rather than stopping at the first claim: if two
+ // inferencers both claim the same field, that is ambiguous and we fail loudly (per OpenSearch
+ // triage) rather than letting plugin load order silently pick a winner.
+ Map inferredFieldMapping = null;
+ DynamicFieldTypeInferencer claimingInferencer = null;
+ for (DynamicFieldTypeInferencer inferencer : inferencers) {
+ Map claim;
+ try {
+ claim = inferencer.inferFieldType(fieldValueParser);
+ } catch (Exception e) {
+ // A buggy inferencer must not break document parsing, but the failure must be visible:
+ // log it rather than swallowing silently, then move on to the next inferencer.
+ logger.warn(
+ () -> new org.apache.logging.log4j.message.ParameterizedMessage(
+ "dynamic field type inferencer [{}] threw while inspecting field [{}]; skipping it",
+ inferencer.getClass().getName(),
+ resolvedFieldName
+ ),
+ e
+ );
+ continue;
+ }
+ if (claim != null) {
+ if (inferredFieldMapping != null) {
+ throw new MapperParsingException(
+ "field ["
+ + resolvedFieldName
+ + "] was claimed by more than one dynamic field type inferencer: ["
+ + claimingInferencer.getClass().getName()
+ + "] and ["
+ + inferencer.getClass().getName()
+ + "]; the inferred type is ambiguous"
+ );
+ }
+ inferredFieldMapping = claim;
+ claimingInferencer = inferencer;
+ }
+ }
+
+ if (inferredFieldMapping == null) { // put these into one if
+ // No template and no inferencer claimed this field — fall through to existing path
+ replayThroughExistingPath(context, resolvedParent, resolvedFieldName, rawContent, parentMapperTuple.v1());
+ return true;
+ }
+
+ String inferredType = (String) inferredFieldMapping.get("type");
+ if (inferredType == null) {
+ replayThroughExistingPath(context, resolvedParent, resolvedFieldName, rawContent, parentMapperTuple.v1());
+ return true;
+ }
+
+ // Step 3: No template — use inferencer result directly.
+ Mapper.TypeParser.ParserContext parserContext = context.docMapperParser().parserContext();
+ Mapper.TypeParser typeParser = parserContext.typeParser(inferredType);
+ if (typeParser == null) {
+ // Unknown type — fall through to existing path rather than failing
+ replayThroughExistingPath(context, resolvedParent, resolvedFieldName, rawContent, parentMapperTuple.v1());
+ return true;
+ }
+
+ Mapper.Builder> builder = typeParser.parse(resolvedFieldName, inferredFieldMapping, parserContext);
+ Mapper.BuilderContext builderContext = new Mapper.BuilderContext(context.indexSettings().getSettings(), context.path());
+ Mapper inferredMapper = builder.build(builderContext);
+ context.addDynamicMapper(inferredMapper);
+
+ // Replay buffered content through the new mapper
+ try (
+ XContentParser replayParser = contentType.xContent()
+ .createParser(parser.getXContentRegistry(), parser.getDeprecationHandler(), rawContent)
+ ) {
+ replayParser.nextToken(); // position at the start of the value
+ ParseContext replayContext = context.switchParser(replayParser);
+ context.path().add(resolvedFieldName);
+ try {
+ parseObjectOrField(replayContext, inferredMapper);
+ } finally {
+ // Release the field-name slot even if replay throws, so ContentPath is not left
+ // corrupt for subsequent fields in the same document.
+ context.path().remove();
+ }
+ } finally {
+ for (int i = 0; i < parentMapperTuple.v1(); i++) {
+ context.path().remove();
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Replays raw-buffered field content through the normal unmapped-field logic — dynamic
+ * templates, parseDynamicValue, parseNonDynamicArray — as if the buffer had never been created.
+ */
+ private static void replayThroughExistingPath(
+ ParseContext context,
+ ObjectMapper parentMapper,
+ String fieldName,
+ byte[] rawContent,
+ int pathsAddedByGetDynamicParentMapper
+ ) throws IOException {
+ XContentParser originalParser = context.parser();
+ try (
+ XContentParser replayParser = originalParser.contentType()
+ .xContent()
+ .createParser(originalParser.getXContentRegistry(), originalParser.getDeprecationHandler(), rawContent)
+ ) {
+ replayParser.nextToken(); // position at value start
+ ParseContext replayContext = context.switchParser(replayParser);
+ XContentParser.Token replayToken = replayParser.currentToken();
+ String[] replayPaths = splitAndValidatePath(fieldName);
+ switch (replayToken) {
+ case START_OBJECT:
+ parseObject(replayContext, parentMapper, fieldName, replayPaths);
+ break;
+ case START_ARRAY:
+ parseArray(replayContext, parentMapper, fieldName, replayPaths);
+ break;
+ case VALUE_NULL:
+ parseNullValue(replayContext, parentMapper, fieldName, replayPaths);
+ break;
+ default:
+ if (replayToken != null && replayToken.isValue()) {
+ parseValue(replayContext, parentMapper, fieldName, replayToken, replayPaths);
+ }
+ }
+ } finally {
+ for (int i = 0; i < pathsAddedByGetDynamicParentMapper; i++) {
+ context.path().remove();
+ }
+ }
+ }
+
private static void parseNonDynamicArray(ParseContext context, ObjectMapper mapper, final String lastFieldName, String arrayFieldName)
throws IOException {
@@ -1824,4 +2113,59 @@ private static Mapper.Builder findTemplateBuilder(
}
return builder;
}
+
+ /**
+ * Attempts to find a plugin-registered dynamic template whose {@code match_mapping_type} equals
+ * {@code pluginType} and whose path/name patterns match the field being parsed.
+ *
+ * If a matching template is found, calls {@link DynamicTemplateTypeHandler#adjustMappingConfig}
+ * with a factory that produces a fresh parser over the buffered field bytes, so the handler can
+ * inject any required parameters (e.g. dimension) before the {@link Mapper.TypeParser} builds the
+ * mapper. This runs before TypeParser so that parameters that can only be inferred from the data
+ * are present when the mapper is constructed. Handlers whose config is already complete never call
+ * {@code get()}, so no parsing happens for fully-specified templates.
+ *
+ * @param name the simple field name being parsed (last path component)
+ * @param entry the plugin type string (e.g. {@code "knn_vector"}) and its handler
+ * @param fieldValueParser produces a fresh parser over the buffered field bytes
+ * @return a {@link Mapper.Builder} ready to build the mapper, or {@code null} if no template matched
+ */
+ @SuppressWarnings("rawtypes")
+ private static Mapper.Builder findPluginTemplateBuilder(
+ ParseContext context,
+ String name,
+ Map.Entry entry,
+ ObjectMapper.Dynamic dynamic,
+ String fieldFullPath,
+ FieldValueParserSupplier fieldValueParser
+ ) throws IOException {
+ String pluginType = entry.getKey();
+ DynamicTemplateTypeHandler handler = entry.getValue();
+ DynamicTemplate dynamicTemplate = findPluginTemplate(context.root(), context.path(), name, pluginType);
+ if (dynamicTemplate == null) {
+ return null;
+ }
+ String mappingType = dynamicTemplate.mappingType(pluginType);
+ Mapper.TypeParser.ParserContext parserContext = context.docMapperParser().parserContext();
+ Mapper.TypeParser typeParser = parserContext.typeParser(mappingType);
+ if (typeParser == null) {
+ throw new MapperParsingException("failed to find type parsed [" + mappingType + "] for [" + name + "]");
+ }
+ Map mappingConfig = dynamicTemplate.mappingForName(name, pluginType);
+ // The handler completes the config (injects its own type when omitted, and any data-derived
+ // params such as dimension) before the TypeParser builds the mapper.
+ handler.adjustMappingConfig(mappingConfig, fieldValueParser);
+ return typeParser.parse(name, mappingConfig, parserContext);
+ }
+
+ /** Scans dynamic templates on the root mapper for one whose {@code match_mapping_type} equals {@code pluginType} and whose path/name patterns match. */
+ private static DynamicTemplate findPluginTemplate(RootObjectMapper root, ContentPath path, String name, String pluginType) {
+ final String pathAsString = path.pathAsText(name);
+ for (DynamicTemplate dynamicTemplate : root.dynamicTemplates()) {
+ if (dynamicTemplate.matchesPluginType(pathAsString, name, pluginType)) {
+ return dynamicTemplate;
+ }
+ }
+ return null;
+ }
}
diff --git a/server/src/main/java/org/opensearch/index/mapper/DynamicFieldTypeInferencer.java b/server/src/main/java/org/opensearch/index/mapper/DynamicFieldTypeInferencer.java
new file mode 100644
index 0000000000000..d070a3b916e11
--- /dev/null
+++ b/server/src/main/java/org/opensearch/index/mapper/DynamicFieldTypeInferencer.java
@@ -0,0 +1,51 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.index.mapper;
+
+import org.opensearch.common.annotation.ExperimentalApi;
+import org.opensearch.core.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * SPI for plugins to register dynamic field type inference logic.
+ *
+ * When DocumentParser encounters an unmapped field and no dynamic template matches, it buffers
+ * the field content and calls {@link #inferFieldType} on each registered inferencer in order. The
+ * first non-null config map wins; the type is read from the {@code "type"} key, the mapper is built,
+ * and the field content is replayed through it. If no inferencer claims the field, existing fallback
+ * behavior applies.
+ *
+ *
Rather than deserializing the field value into a fixed Java representation, core hands the
+ * inferencer a {@link FieldValueParserSupplier} that produces a fresh {@link XContentParser} over the
+ * buffered bytes. Each call to {@code fieldValueParser.get()} returns an independent parser positioned before
+ * the field value, so the plugin can inspect the content however it needs — for example streaming
+ * through tokens to count array elements. This keeps core free of any representation contract: each
+ * plugin decides how to interpret the value.
+ *
+ * @opensearch.experimental
+ */
+@ExperimentalApi
+public interface DynamicFieldTypeInferencer {
+
+ /**
+ * Inspect the buffered field value and decide whether to claim it.
+ *
+ * @param fieldValueParser produces a fresh {@link XContentParser} over the buffered field bytes;
+ * call {@code get()} and advance the parser to read the value. The returned
+ * parser should be closed by the caller (e.g. via try-with-resources).
+ * @return a mutable mapping config map with at minimum a {@code "type"} key (e.g.
+ * {@code {"type": "knn_vector", "dimension": 384}}), or {@code null} to pass to the
+ * next inferencer. The map MUST be mutable — TypeParser implementations call
+ * {@code node.remove()} on it during parsing.
+ * @throws IOException if reading from the parser fails
+ */
+ Map inferFieldType(FieldValueParserSupplier fieldValueParser) throws IOException;
+}
diff --git a/server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java b/server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java
index da62b20c98563..56d10812b3da4 100644
--- a/server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java
+++ b/server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java
@@ -195,7 +195,18 @@ public static XContentFieldType fromString(String value) {
public abstract String defaultMappingType();
}
+ /** Parses a dynamic template without plugin-type validation: any unknown match_mapping_type is kept as a plugin match type. */
public static DynamicTemplate parse(String name, Map conf) throws MapperParsingException {
+ return parse(name, conf, null);
+ }
+
+ /**
+ * Parses a dynamic template. If {@code knownPluginTypes} is non-null, a {@code match_mapping_type}
+ * that is neither an {@link XContentFieldType} nor a registered plugin type fails here, before any
+ * {@link DynamicTemplate} is constructed.
+ */
+ static DynamicTemplate parse(String name, Map conf, Map knownPluginTypes)
+ throws MapperParsingException {
String match = null;
String pathMatch = null;
String unmatch = null;
@@ -235,8 +246,28 @@ public static DynamicTemplate parse(String name, Map conf) throw
}
XContentFieldType xcontentFieldType = null;
- if (matchMappingType != null && matchMappingType.equals("*") == false) {
- xcontentFieldType = XContentFieldType.fromString(matchMappingType);
+ String pluginMatchType = null;
+ if (matchMappingType != null && !matchMappingType.equals("*")) {
+ for (XContentFieldType t : XContentFieldType.values()) {
+ if (t.toString().equals(matchMappingType)) {
+ xcontentFieldType = t;
+ break;
+ }
+ }
+ if (xcontentFieldType == null) {
+ pluginMatchType = matchMappingType;
+ // Validate plugin type before constructing the template
+ if (knownPluginTypes != null && !knownPluginTypes.containsKey(pluginMatchType)) {
+ List allTypes = new ArrayList<>();
+ for (XContentFieldType t : XContentFieldType.values()) {
+ allTypes.add(t.toString());
+ }
+ allTypes.addAll(knownPluginTypes.keySet());
+ throw new IllegalArgumentException(
+ "No field type matched on [" + pluginMatchType + "], possible values are " + allTypes
+ );
+ }
+ }
}
final MatchType matchType = MatchType.fromString(matchPattern);
@@ -256,7 +287,7 @@ public static DynamicTemplate parse(String name, Map conf) throw
}
}
- return new DynamicTemplate(name, pathMatch, pathUnmatch, match, unmatch, xcontentFieldType, matchType, mapping);
+ return new DynamicTemplate(name, pathMatch, pathUnmatch, match, unmatch, xcontentFieldType, pluginMatchType, matchType, mapping);
}
private final String name;
@@ -273,6 +304,8 @@ public static DynamicTemplate parse(String name, Map conf) throw
private final XContentFieldType xcontentFieldType;
+ private final String pluginMatchType;
+
private final Map mapping;
private DynamicTemplate(
@@ -282,6 +315,7 @@ private DynamicTemplate(
String match,
String unmatch,
XContentFieldType xcontentFieldType,
+ String pluginMatchType,
MatchType matchType,
Map mapping
) {
@@ -292,6 +326,7 @@ private DynamicTemplate(
this.unmatch = unmatch;
this.matchType = matchType;
this.xcontentFieldType = xcontentFieldType;
+ this.pluginMatchType = pluginMatchType;
this.mapping = mapping;
}
@@ -303,25 +338,26 @@ public String pathMatch() {
return pathMatch;
}
+ private boolean matchesPathAndName(String path, String name) {
+ if (pathMatch != null && !matchType.matches(pathMatch, path)) return false;
+ if (match != null && !matchType.matches(match, name)) return false;
+ if (pathUnmatch != null && matchType.matches(pathUnmatch, path)) return false;
+ if (unmatch != null && matchType.matches(unmatch, name)) return false;
+ return true;
+ }
+
public boolean match(String path, String name, XContentFieldType xcontentFieldType) {
- if (pathMatch != null && !matchType.matches(pathMatch, path)) {
- return false;
- }
- if (match != null && !matchType.matches(match, name)) {
- return false;
- }
- if (pathUnmatch != null && matchType.matches(pathUnmatch, path)) {
- return false;
- }
- if (unmatch != null && matchType.matches(unmatch, name)) {
- return false;
- }
- if (this.xcontentFieldType != null && this.xcontentFieldType != xcontentFieldType) {
- return false;
- }
+ if (!matchesPathAndName(path, name)) return false;
+ if (this.xcontentFieldType != null && this.xcontentFieldType != xcontentFieldType) return false;
return true;
}
+ /** Returns true if this template's {@code match_mapping_type} equals {@code pluginType} and the path/name patterns match. */
+ public boolean matchesPluginType(String path, String name, String pluginType) {
+ if (this.pluginMatchType == null || !this.pluginMatchType.equals(pluginType)) return false;
+ return matchesPathAndName(path, name);
+ }
+
public String mappingType(String dynamicType) {
String type;
if (mapping.containsKey("type")) {
@@ -401,6 +437,11 @@ XContentFieldType getXContentFieldType() {
return xcontentFieldType;
}
+ /** Returns the plugin-registered match_mapping_type string, or null if this is a standard template. */
+ public String getPluginMatchType() {
+ return pluginMatchType;
+ }
+
Map getMapping() {
return mapping;
}
@@ -422,6 +463,8 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
}
if (xcontentFieldType != null) {
builder.field("match_mapping_type", xcontentFieldType);
+ } else if (pluginMatchType != null) {
+ builder.field("match_mapping_type", pluginMatchType);
} else if (match == null && pathMatch == null) {
builder.field("match_mapping_type", "*");
}
diff --git a/server/src/main/java/org/opensearch/index/mapper/DynamicTemplateTypeHandler.java b/server/src/main/java/org/opensearch/index/mapper/DynamicTemplateTypeHandler.java
new file mode 100644
index 0000000000000..bd1ee79ad5704
--- /dev/null
+++ b/server/src/main/java/org/opensearch/index/mapper/DynamicTemplateTypeHandler.java
@@ -0,0 +1,62 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.index.mapper;
+
+import org.opensearch.common.annotation.ExperimentalApi;
+import org.opensearch.core.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Handler for plugin-registered dynamic template types.
+ * Called when a dynamic template matches on a plugin-registered type string
+ * (e.g. "knn_vector") to allow the plugin to adjust the mapping configuration
+ * before the mapper is built.
+ *
+ * Rather than deserializing the field value for the handler, core hands it a
+ * {@link FieldValueParserSupplier} that produces a fresh {@link XContentParser} over the buffered
+ * bytes. A handler whose template config is already complete never calls {@code get()},
+ * so no parsing happens for fully-specified templates. A handler that needs a
+ * data-derived parameter (e.g. a vector's dimension) creates a parser and reads what it
+ * needs.
+ *
+ * @opensearch.experimental
+ */
+@ExperimentalApi
+public interface DynamicTemplateTypeHandler {
+
+ /**
+ * Adjust the mapping configuration before the TypeParser builds the mapper.
+ * Called when a dynamic template matches but before the mapper is constructed.
+ *
+ * @param mappingConfig the mutable mapping config from the template (modified in place)
+ * @param fieldValueParser produces a fresh {@link XContentParser} over the buffered field bytes;
+ * only call {@code get()} if the config is missing a parameter that must
+ * be derived from the data. Close the returned parser (e.g. via
+ * try-with-resources).
+ * @throws IOException if reading from the parser fails
+ */
+ void adjustMappingConfig(Map mappingConfig, FieldValueParserSupplier fieldValueParser) throws IOException;
+
+ /**
+ * Returns {@code true} if the given template mapping config is fully specified — i.e. building a
+ * mapper from it requires no parameter derived from a document ({@link #adjustMappingConfig} would
+ * not need to read the field value). When this returns {@code true}, core validates the template
+ * eagerly at index-creation time by handing the config to the {@link Mapper.TypeParser}, which
+ * reports any invalid content. When it returns {@code false}, validation is deferred to
+ * document-parse time, where the data-derived parameters become available.
+ *
+ * @param mappingConfig the mapping config from the matched template
+ * @return whether the config can be validated without inspecting a document
+ */
+ default boolean isConfigComplete(Map mappingConfig) {
+ return false;
+ }
+}
diff --git a/server/src/main/java/org/opensearch/index/mapper/FieldValueParserSupplier.java b/server/src/main/java/org/opensearch/index/mapper/FieldValueParserSupplier.java
new file mode 100644
index 0000000000000..5dec7b88bf0ca
--- /dev/null
+++ b/server/src/main/java/org/opensearch/index/mapper/FieldValueParserSupplier.java
@@ -0,0 +1,81 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.index.mapper;
+
+import org.opensearch.common.annotation.ExperimentalApi;
+import org.opensearch.core.xcontent.DeprecationHandler;
+import org.opensearch.core.xcontent.MediaType;
+import org.opensearch.core.xcontent.NamedXContentRegistry;
+import org.opensearch.core.xcontent.XContentParser;
+
+import java.io.IOException;
+
+/**
+ * Supplies a fresh {@link XContentParser} positioned at the start of a buffered field value.
+ *
+ * Handed to dynamic-mapping plugin extension points ({@link DynamicFieldTypeInferencer} and
+ * {@link DynamicTemplateTypeHandler}) so they can inspect an unmapped field's value without core
+ * committing to any deserialized representation. Each call to {@link #get()} creates an independent
+ * parser over the same buffered bytes, so a plugin may read the value more than once. The caller
+ * closes the returned parser (e.g. via try-with-resources).
+ *
+ *
Plugins whose configuration is already complete need not call {@link #get()} at all — index-creation
+ * validation constructs the supplier with {@link #withoutValue()}, whose {@link #get()} throws, so a
+ * complete config that never reads the value is validated without any field data being available.
+ *
+ * @opensearch.experimental
+ */
+@ExperimentalApi
+public class FieldValueParserSupplier {
+
+ private final MediaType contentType;
+ private final NamedXContentRegistry registry;
+ private final DeprecationHandler deprecationHandler;
+ private final byte[] rawContent;
+
+ /**
+ * Creates a supplier over the buffered field value. {@link #get()} produces a fresh parser
+ * positioned at the value's first token on each call.
+ */
+ public FieldValueParserSupplier(
+ MediaType contentType,
+ NamedXContentRegistry registry,
+ DeprecationHandler deprecationHandler,
+ byte[] rawContent
+ ) {
+ this.contentType = contentType;
+ this.registry = registry;
+ this.deprecationHandler = deprecationHandler;
+ this.rawContent = rawContent;
+ }
+
+ /**
+ * A supplier with no field value behind it — {@link #get()} throws. Used at index-creation time,
+ * where a fully specified template config must be validated without reading any document.
+ */
+ public static FieldValueParserSupplier withoutValue() {
+ return new FieldValueParserSupplier(null, null, null, null);
+ }
+
+ /**
+ * Returns a fresh {@link XContentParser} positioned at the first token of the field value. The
+ * caller is responsible for closing it (e.g. via try-with-resources).
+ *
+ * @throws IOException if the parser cannot be created
+ * @throws IllegalStateException if this supplier has no field value ({@link #withoutValue()})
+ */
+ public XContentParser get() throws IOException {
+ if (rawContent == null) {
+ throw new IllegalStateException("No field value is available to parse");
+ }
+ XContentParser parser = contentType.xContent().createParser(registry, deprecationHandler, rawContent);
+ parser.nextToken(); // position at the start of the value
+ return parser;
+ }
+}
diff --git a/server/src/main/java/org/opensearch/index/mapper/MapperService.java b/server/src/main/java/org/opensearch/index/mapper/MapperService.java
index 788d725b7afd1..eb179c72a1c07 100644
--- a/server/src/main/java/org/opensearch/index/mapper/MapperService.java
+++ b/server/src/main/java/org/opensearch/index/mapper/MapperService.java
@@ -845,6 +845,20 @@ public Set getMetadataFields() {
return Collections.unmodifiableSet(mapperRegistry.getMetadataMapperParsers().keySet());
}
+ /**
+ * Returns the list of registered dynamic field type inferencers.
+ */
+ public List getDynamicFieldTypeInferencers() {
+ return mapperRegistry.getDynamicFieldTypeInferencers();
+ }
+
+ /**
+ * Returns the map of registered dynamic template type handlers.
+ */
+ public Map getDynamicTemplateTypes() {
+ return mapperRegistry.getDynamicTemplateTypes();
+ }
+
/**
* An analyzer wrapper that can lookup fields within the index mappings
*/
diff --git a/server/src/main/java/org/opensearch/index/mapper/RootObjectMapper.java b/server/src/main/java/org/opensearch/index/mapper/RootObjectMapper.java
index 6a6531c7b4213..6b5d044f3b5f3 100644
--- a/server/src/main/java/org/opensearch/index/mapper/RootObjectMapper.java
+++ b/server/src/main/java/org/opensearch/index/mapper/RootObjectMapper.java
@@ -74,6 +74,7 @@
@PublicApi(since = "1.0.0")
public class RootObjectMapper extends ObjectMapper {
private static final DeprecationLogger DEPRECATION_LOGGER = DeprecationLogger.getLogger(RootObjectMapper.class);
+ private static final org.apache.logging.log4j.Logger logger = org.apache.logging.log4j.LogManager.getLogger(RootObjectMapper.class);
/**
* Default parameters for root object
@@ -287,11 +288,17 @@ protected boolean processField(RootObjectMapper.Builder builder, String fieldNam
Map.Entry entry = tmpl.entrySet().iterator().next();
String templateName = entry.getKey();
Map templateParams = (Map) entry.getValue();
- DynamicTemplate template = DynamicTemplate.parse(templateName, templateParams);
- if (template != null) {
+ DynamicTemplate template = DynamicTemplate.parse(
+ templateName,
+ templateParams,
+ parserContext.mapperService().getDynamicTemplateTypes()
+ );
+ if (template.getPluginMatchType() == null) {
validateDynamicTemplate(parserContext, template);
- templates.add(template);
+ } else {
+ validatePluginDynamicTemplate(parserContext, template);
}
+ templates.add(template);
}
builder.dynamicTemplates(templates);
return true;
@@ -456,7 +463,7 @@ private Mapper.Builder findTemplateBuilder(ParseContext context, String name, XC
public DynamicTemplate findTemplate(ContentPath path, String name, XContentFieldType matchType) {
final String pathAsString = path.pathAsText(name);
for (DynamicTemplate dynamicTemplate : dynamicTemplates.value()) {
- if (dynamicTemplate.match(pathAsString, name, matchType)) {
+ if (dynamicTemplate.getPluginMatchType() == null && dynamicTemplate.match(pathAsString, name, matchType)) {
return dynamicTemplate;
}
}
@@ -718,6 +725,72 @@ private static void validateDynamicTemplate(Mapper.TypeParser.ParserContext pars
}
}
+ /**
+ * Validates a dynamic template whose {@code match_mapping_type} is a plugin-registered type
+ * (e.g. {@code knn_vector}) at index-creation time.
+ *
+ * Plugin templates are allowed to be intentionally incomplete: a parameter such as a vector's
+ * {@code dimension} may be derived from the first indexed document rather than declared in the
+ * template. Such templates cannot be validated up front, so we only validate eagerly when the
+ * plugin reports — via {@link DynamicTemplateTypeHandler#isConfigComplete} — that the config is
+ * fully specified. In that case we hand the config to the plugin's {@link Mapper.TypeParser}, and
+ * the type parser (not core) reports any invalid content, exactly as it would for an explicit
+ * field mapping of the same type. Otherwise validation is deferred to document-parse time.
+ */
+ private static void validatePluginDynamicTemplate(Mapper.TypeParser.ParserContext parserContext, DynamicTemplate dynamicTemplate) {
+ // {name} placeholders can't be resolved until a concrete field name is known — skip, as the
+ // builtin path does.
+ if (containsSnippet(dynamicTemplate.getMapping(), "{name}")) {
+ return;
+ }
+
+ String pluginType = dynamicTemplate.getPluginMatchType();
+ DynamicTemplateTypeHandler handler = parserContext.mapperService().getDynamicTemplateTypes().get(pluginType);
+ if (handler == null) {
+ // Unknown plugin types are already rejected in DynamicTemplate.parse; nothing to do here.
+ return;
+ }
+
+ String mappingType = dynamicTemplate.mappingType(pluginType);
+ Mapper.TypeParser typeParser = parserContext.typeParser(mappingType);
+ if (typeParser == null) {
+ // No parser to validate against — defer to document-parse time, which reports the error.
+ return;
+ }
+
+ String templateName = "__dynamic__" + dynamicTemplate.getName();
+ Map fieldTypeConfig = dynamicTemplate.mappingForName(templateName, pluginType);
+ if (handler.isConfigComplete(fieldTypeConfig) == false) {
+ // Config relies on data-derived parameters; it can only be validated once a document is seen.
+ return;
+ }
+
+ // Config is fully specified. Let the handler normalize it (e.g. inject its own type when the
+ // template omitted it) — a complete config never reads the field value, so the supplier is
+ // never invoked at index-creation time. Then hand it to the type parser, which validates the
+ // content and reports any invalid config.
+ try {
+ // No document is available at index creation, so the supplier's get() throws. A complete
+ // config never reads the field value, so this is a no-op normalization (e.g. type injection).
+ handler.adjustMappingConfig(fieldTypeConfig, FieldValueParserSupplier.withoutValue());
+ } catch (IllegalStateException | IOException e) {
+ // A complete config performs no I/O and must not read the field value. If a handler
+ // violates that contract, treat it as non-fatal and defer validation to document-parse time
+ // rather than failing index creation — but log it so the contract violation is visible.
+ logger.warn(
+ () -> new org.apache.logging.log4j.message.ParameterizedMessage(
+ "dynamic template type handler for [{}] threw while normalizing a complete config for template [{}]; "
+ + "deferring validation to document-parse time",
+ dynamicTemplate.getName(),
+ templateName
+ ),
+ e
+ );
+ return;
+ }
+ typeParser.parse(templateName, fieldTypeConfig, parserContext);
+ }
+
private static boolean containsSnippet(Map, ?> map, String snippet) {
for (Map.Entry, ?> entry : map.entrySet()) {
String key = entry.getKey().toString();
diff --git a/server/src/main/java/org/opensearch/indices/IndicesModule.java b/server/src/main/java/org/opensearch/indices/IndicesModule.java
index b3e7950020dff..a13259e7018f3 100644
--- a/server/src/main/java/org/opensearch/indices/IndicesModule.java
+++ b/server/src/main/java/org/opensearch/indices/IndicesModule.java
@@ -52,6 +52,8 @@
import org.opensearch.index.mapper.DateFieldMapper;
import org.opensearch.index.mapper.DerivedFieldMapper;
import org.opensearch.index.mapper.DocCountFieldMapper;
+import org.opensearch.index.mapper.DynamicFieldTypeInferencer;
+import org.opensearch.index.mapper.DynamicTemplateTypeHandler;
import org.opensearch.index.mapper.FieldAliasMapper;
import org.opensearch.index.mapper.FieldNamesFieldMapper;
import org.opensearch.index.mapper.FlatObjectFieldMapper;
@@ -114,11 +116,37 @@ public IndicesModule(List mapperPlugins) {
this.mapperRegistry = new MapperRegistry(
getMappers(mapperPlugins),
getMetadataMappers(mapperPlugins),
- getFieldFilter(mapperPlugins)
+ getFieldFilter(mapperPlugins),
+ getDynamicFieldTypeInferencers(mapperPlugins),
+ getDynamicTemplateTypes(mapperPlugins)
);
registerBuiltinWritables();
}
+ /** Collects dynamic field type inferencers from all mapper plugins in registration order. */
+ private static List getDynamicFieldTypeInferencers(List mapperPlugins) {
+ List inferencers = new ArrayList<>();
+ for (MapperPlugin mapperPlugin : mapperPlugins) {
+ inferencers.addAll(mapperPlugin.getDynamicFieldTypeInferencers());
+ }
+ return inferencers;
+ }
+
+ /** Merges dynamic template type handlers from all mapper plugins; throws if two plugins register the same type string. */
+ private static Map getDynamicTemplateTypes(List mapperPlugins) {
+ Map templateTypes = new LinkedHashMap<>();
+ for (MapperPlugin mapperPlugin : mapperPlugins) {
+ Map pluginTypes = mapperPlugin.getDynamicTemplateTypes();
+ for (Map.Entry entry : pluginTypes.entrySet()) {
+ if (templateTypes.containsKey(entry.getKey())) {
+ throw new IllegalArgumentException("dynamic template type [" + entry.getKey() + "] is already registered");
+ }
+ templateTypes.put(entry.getKey(), entry.getValue());
+ }
+ }
+ return templateTypes;
+ }
+
private void registerBuiltinWritables() {
namedWritables.add(new NamedWriteableRegistry.Entry(Condition.class, MaxAgeCondition.NAME, MaxAgeCondition::new));
namedWritables.add(new NamedWriteableRegistry.Entry(Condition.class, MaxDocsCondition.NAME, MaxDocsCondition::new));
diff --git a/server/src/main/java/org/opensearch/indices/mapper/MapperRegistry.java b/server/src/main/java/org/opensearch/indices/mapper/MapperRegistry.java
index 7974de3514ce3..d3cbd57c25916 100644
--- a/server/src/main/java/org/opensearch/indices/mapper/MapperRegistry.java
+++ b/server/src/main/java/org/opensearch/indices/mapper/MapperRegistry.java
@@ -33,12 +33,16 @@
package org.opensearch.indices.mapper;
import org.opensearch.common.annotation.PublicApi;
+import org.opensearch.index.mapper.DynamicFieldTypeInferencer;
+import org.opensearch.index.mapper.DynamicTemplateTypeHandler;
import org.opensearch.index.mapper.Mapper;
import org.opensearch.index.mapper.MetadataFieldMapper;
import org.opensearch.plugins.MapperPlugin;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;
@@ -54,15 +58,29 @@ public final class MapperRegistry {
private final Map mapperParsers;
private final Map metadataMapperParsers;
private final Function> fieldFilter;
+ private final List dynamicFieldTypeInferencers;
+ private final Map dynamicTemplateTypes;
public MapperRegistry(
Map mapperParsers,
Map metadataMapperParsers,
Function> fieldFilter
+ ) {
+ this(mapperParsers, metadataMapperParsers, fieldFilter, Collections.emptyList(), Collections.emptyMap());
+ }
+
+ public MapperRegistry(
+ Map mapperParsers,
+ Map metadataMapperParsers,
+ Function> fieldFilter,
+ List dynamicFieldTypeInferencers,
+ Map dynamicTemplateTypes
) {
this.mapperParsers = Collections.unmodifiableMap(new LinkedHashMap<>(mapperParsers));
this.metadataMapperParsers = Collections.unmodifiableMap(new LinkedHashMap<>(metadataMapperParsers));
this.fieldFilter = fieldFilter;
+ this.dynamicFieldTypeInferencers = Collections.unmodifiableList(new ArrayList<>(dynamicFieldTypeInferencers));
+ this.dynamicTemplateTypes = Collections.unmodifiableMap(new LinkedHashMap<>(dynamicTemplateTypes));
}
/**
@@ -98,4 +116,16 @@ public boolean isMetadataField(String field) {
public Function> getFieldFilter() {
return fieldFilter;
}
+
+ /**
+ * Returns the registered dynamic field type inferencers, in priority order.
+ */
+ public List getDynamicFieldTypeInferencers() {
+ return dynamicFieldTypeInferencers;
+ }
+
+ /** Returns the registered dynamic template type handlers keyed by their match_mapping_type string. */
+ public Map getDynamicTemplateTypes() {
+ return dynamicTemplateTypes;
+ }
}
diff --git a/server/src/main/java/org/opensearch/plugins/MapperPlugin.java b/server/src/main/java/org/opensearch/plugins/MapperPlugin.java
index 1480f46582ff5..a65cbe869b579 100644
--- a/server/src/main/java/org/opensearch/plugins/MapperPlugin.java
+++ b/server/src/main/java/org/opensearch/plugins/MapperPlugin.java
@@ -32,6 +32,8 @@
package org.opensearch.plugins;
+import org.opensearch.index.mapper.DynamicFieldTypeInferencer;
+import org.opensearch.index.mapper.DynamicTemplateTypeHandler;
import org.opensearch.index.mapper.Mapper;
import org.opensearch.index.mapper.MappingTransformer;
import org.opensearch.index.mapper.MetadataFieldMapper;
@@ -100,4 +102,24 @@ default Function> getFieldFilter() {
default List getMappingTransformers() {
return Collections.emptyList();
}
+
+ /**
+ * Returns dynamic field type inferencers provided by this plugin.
+ * These are consulted when an unmapped array field is encountered during document indexing
+ * and no template matches. The first inferencer to claim a field wins.
+ */
+ default List getDynamicFieldTypeInferencers() {
+ return Collections.emptyList();
+ }
+
+ /**
+ * Returns dynamic template types registered by this plugin.
+ * These allow plugins to register custom match_mapping_type strings
+ * (e.g. "knn_vector") that users can reference in dynamic templates.
+ * The key is the type string, and the value is the handler that adjusts
+ * the mapper builder when a template matches.
+ */
+ default Map getDynamicTemplateTypes() {
+ return Collections.emptyMap();
+ }
}
diff --git a/server/src/test/java/org/opensearch/index/mapper/DynamicTemplateTests.java b/server/src/test/java/org/opensearch/index/mapper/DynamicTemplateTests.java
index 3d53f3fbebd87..7aecf220fd21e 100644
--- a/server/src/test/java/org/opensearch/index/mapper/DynamicTemplateTests.java
+++ b/server/src/test/java/org/opensearch/index/mapper/DynamicTemplateTests.java
@@ -58,12 +58,9 @@ public void testParseUnknownMatchType() {
Map templateDef2 = new HashMap<>();
templateDef2.put("match_mapping_type", "text");
templateDef2.put("mapping", Collections.singletonMap("store", true));
- // if a wrong match type is specified, we ignore the template
- IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> DynamicTemplate.parse("my_template", templateDef2));
- assertEquals(
- "No field type matched on [text], possible values are [object, string, long, double, boolean, date, binary]",
- e.getMessage()
- );
+ // Unknown match_mapping_type is stored as pluginMatchType — validation against the registry happens in RootObjectMapper
+ DynamicTemplate template = DynamicTemplate.parse("my_template", templateDef2);
+ assertEquals("text", template.getPluginMatchType());
}
public void testParseInvalidRegex() {
diff --git a/server/src/test/java/org/opensearch/index/mapper/PluginDynamicTemplateTests.java b/server/src/test/java/org/opensearch/index/mapper/PluginDynamicTemplateTests.java
new file mode 100644
index 0000000000000..f7e9a9a3320d8
--- /dev/null
+++ b/server/src/test/java/org/opensearch/index/mapper/PluginDynamicTemplateTests.java
@@ -0,0 +1,586 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.index.mapper;
+
+import org.opensearch.common.xcontent.json.JsonXContent;
+import org.opensearch.core.xcontent.ToXContent;
+import org.opensearch.core.xcontent.XContentBuilder;
+import org.opensearch.core.xcontent.XContentParser;
+import org.opensearch.index.mapper.DynamicTemplate.XContentFieldType;
+import org.opensearch.plugins.MapperPlugin;
+import org.opensearch.plugins.Plugin;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * Tests for plugin-extensible dynamic template types (match_mapping_type SPI)
+ * and field type inferencers (DynamicFieldTypeInferencer SPI).
+ *
+ * Uses a minimal stub plugin:
+ * - Registers "mock_type" as a plugin match_mapping_type
+ * - Registers an inferencer that claims numeric scalars >= 100 as "long"
+ * (scalars are safe — Long mapper handles them without parsesArrayValue issues)
+ */
+public class PluginDynamicTemplateTests extends MapperServiceTestCase {
+
+ private static final String MOCK_TYPE = "mock_type";
+ // A plugin type whose handler always reads the field value, i.e. it depends on data-derived
+ // parameters and therefore cannot be validated at index-creation time.
+ private static final String MOCK_INFERRED_TYPE = "mock_inferred_type";
+ // A plugin type whose handler reports its config complete but throws when normalizing it — used to
+ // verify eager index-creation validation treats a handler contract violation as non-fatal (defer).
+ private static final String MOCK_THROWING_TYPE = "mock_throwing_type";
+
+ /** No-op handler — template config used as-is. Reports its config as always complete. */
+ static class MockTemplateTypeHandler implements DynamicTemplateTypeHandler {
+ @Override
+ public void adjustMappingConfig(Map mappingConfig, FieldValueParserSupplier fieldValueParser) {}
+
+ @Override
+ public boolean isConfigComplete(Map mappingConfig) {
+ return true;
+ }
+ }
+
+ /** Handler for a data-derived type: config is never complete, so validation is deferred. */
+ static class MockInferredTemplateTypeHandler implements DynamicTemplateTypeHandler {
+ @Override
+ public void adjustMappingConfig(Map mappingConfig, FieldValueParserSupplier fieldValueParser) throws IOException {
+ try (XContentParser parser = fieldValueParser.get()) {
+ parser.currentToken();
+ }
+ }
+
+ @Override
+ public boolean isConfigComplete(Map mappingConfig) {
+ return false;
+ }
+ }
+
+ /**
+ * Reports its config as complete (so index-creation validation runs eagerly) but throws from
+ * adjustMappingConfig — a handler-contract violation. Core must treat this as non-fatal and defer.
+ */
+ static class ThrowingTemplateTypeHandler implements DynamicTemplateTypeHandler {
+ @Override
+ public void adjustMappingConfig(Map mappingConfig, FieldValueParserSupplier fieldValueParser) {
+ throw new IllegalStateException("handler contract violation");
+ }
+
+ @Override
+ public boolean isConfigComplete(Map mappingConfig) {
+ return true;
+ }
+ }
+
+ /**
+ * Claims numeric scalars >= 100 as "long".
+ * Using scalars (not arrays) avoids needing a parsesArrayValue mapper in core tests.
+ */
+ static class MockInferencer implements DynamicFieldTypeInferencer {
+ @Override
+ public Map inferFieldType(FieldValueParserSupplier fieldValueParser) throws IOException {
+ try (XContentParser parser = fieldValueParser.get()) {
+ if (parser.currentToken() != XContentParser.Token.VALUE_NUMBER) return null;
+ if (parser.doubleValue() < 100) return null;
+ }
+ Map config = new HashMap<>();
+ config.put("type", "long");
+ return config;
+ }
+ }
+
+ /**
+ * TypeParser for a plugin field type that requires a {@code required_param} and throws a
+ * {@link MapperParsingException} when it is missing — mirroring how the knn_vector parser rejects
+ * a mapping with no {@code dimension}. Used to prove eager index-creation-time validation throws.
+ */
+ static class RequiresParamTypeParser implements Mapper.TypeParser {
+ @Override
+ public Mapper.Builder> parse(String name, Map node, ParserContext parserContext) throws MapperParsingException {
+ if (node.containsKey("required_param") == false) {
+ throw new MapperParsingException("required_param missing for field [" + name + "]");
+ }
+ node.remove("type");
+ node.remove("required_param");
+ return new MockFieldMapper.Builder(name);
+ }
+ }
+
+ static class MockMapperPlugin extends Plugin implements MapperPlugin {
+ @Override
+ public Map getDynamicTemplateTypes() {
+ Map handlers = new HashMap<>();
+ handlers.put(MOCK_TYPE, new MockTemplateTypeHandler());
+ handlers.put(MOCK_INFERRED_TYPE, new MockInferredTemplateTypeHandler());
+ handlers.put(MOCK_THROWING_TYPE, new ThrowingTemplateTypeHandler());
+ return handlers;
+ }
+
+ @Override
+ public Map getMappers() {
+ Map mappers = new HashMap<>();
+ mappers.put(MOCK_TYPE, new RequiresParamTypeParser());
+ mappers.put(MOCK_INFERRED_TYPE, new RequiresParamTypeParser());
+ mappers.put(MOCK_THROWING_TYPE, new RequiresParamTypeParser());
+ return mappers;
+ }
+
+ @Override
+ public List getDynamicFieldTypeInferencers() {
+ return Collections.singletonList(new MockInferencer());
+ }
+ }
+
+ @Override
+ protected Collection extends Plugin> getPlugins() {
+ return Collections.singletonList(new MockMapperPlugin());
+ }
+
+ // =====================================================================
+ // DynamicTemplate unit tests
+ // =====================================================================
+
+ public void testPluginMatchTypeStoredOnParse() {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", MOCK_TYPE);
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ assertEquals(MOCK_TYPE, template.getPluginMatchType());
+ assertNull(template.getXContentFieldType());
+ }
+
+ public void testBuiltinMatchTypeNotStoredAsPlugin() {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", "string");
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ assertNull(template.getPluginMatchType());
+ assertEquals(XContentFieldType.STRING, template.getXContentFieldType());
+ }
+
+ public void testAllBuiltinTypesNotStoredAsPlugin() {
+ for (XContentFieldType t : XContentFieldType.values()) {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", t.toString());
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t_" + t, conf);
+ assertNull("builtin type " + t + " must not be stored as pluginMatchType", template.getPluginMatchType());
+ assertEquals(t, template.getXContentFieldType());
+ }
+ }
+
+ public void testMatchesPluginTypeTrue() {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", MOCK_TYPE);
+ conf.put("match", "big_*");
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ assertTrue(template.matchesPluginType("big_field", "big_field", MOCK_TYPE));
+ }
+
+ public void testMatchesPluginTypeFalseWrongType() {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", MOCK_TYPE);
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ assertFalse(template.matchesPluginType("field", "field", "other_type"));
+ }
+
+ public void testMatchesPluginTypeFalseNamePatternMismatch() {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", MOCK_TYPE);
+ conf.put("match", "big_*");
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ assertFalse(template.matchesPluginType("small_field", "small_field", MOCK_TYPE));
+ }
+
+ public void testMatchesPluginTypeWithPathMatch() {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", MOCK_TYPE);
+ conf.put("path_match", "obj.*");
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ assertTrue(template.matchesPluginType("obj.field", "field", MOCK_TYPE));
+ assertFalse(template.matchesPluginType("other.field", "field", MOCK_TYPE));
+ }
+
+ public void testMatchesPluginTypeWithUnmatch() {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", MOCK_TYPE);
+ conf.put("unmatch", "excluded_*");
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ assertTrue(template.matchesPluginType("big_field", "big_field", MOCK_TYPE));
+ assertFalse(template.matchesPluginType("excluded_field", "excluded_field", MOCK_TYPE));
+ }
+
+ public void testBuiltinMatchNeverReturnsPluginTemplate() throws IOException {
+ // findTemplate() on RootObjectMapper must skip plugin templates
+ MapperService mapperService = createMapperService(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("mock_template");
+ b.field("match_mapping_type", MOCK_TYPE);
+ b.startObject("mapping");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ RootObjectMapper root = mapperService.documentMapper().mapping().root();
+ ContentPath path = new ContentPath();
+ for (XContentFieldType t : XContentFieldType.values()) {
+ assertNull("findTemplate must not return plugin template for builtin type " + t, root.findTemplate(path, "any_field", t));
+ }
+ }
+
+ public void testPluginTemplateSerializesMatchMappingType() throws Exception {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", MOCK_TYPE);
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ XContentBuilder builder = JsonXContent.contentBuilder();
+ template.toXContent(builder, ToXContent.EMPTY_PARAMS);
+ assertThat(builder.toString(), containsString("\"match_mapping_type\":\"" + MOCK_TYPE + "\""));
+ }
+
+ public void testWildcardNotStoredAsPlugin() {
+ Map conf = new HashMap<>();
+ conf.put("match_mapping_type", "*");
+ conf.put("mapping", Collections.singletonMap("type", "keyword"));
+ DynamicTemplate template = DynamicTemplate.parse("t", conf);
+ assertNull(template.getPluginMatchType());
+ assertNull(template.getXContentFieldType());
+ }
+
+ // =====================================================================
+ // Index creation validation tests
+ // =====================================================================
+
+ public void testUnregisteredPluginTypeThrowsAtIndexCreation() throws IOException {
+ XContentBuilder mapping = topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("bad_template");
+ b.field("match_mapping_type", "unregistered_type");
+ b.startObject("mapping");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ });
+ MapperParsingException e = expectThrows(MapperParsingException.class, () -> createMapperService(mapping));
+ assertThat(e.getMessage(), containsString("No field type matched on [unregistered_type]"));
+ assertThat(e.getMessage(), containsString(MOCK_TYPE));
+ assertThat(e.getMessage(), containsString("string"));
+ assertThat(e.getMessage(), containsString("long"));
+ }
+
+ public void testTypoInPluginTypeThrowsWithFullList() throws IOException {
+ XContentBuilder mapping = topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("typo_template");
+ b.field("match_mapping_type", "mock_typo");
+ b.startObject("mapping");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ });
+ MapperParsingException e = expectThrows(MapperParsingException.class, () -> createMapperService(mapping));
+ assertThat(e.getMessage(), containsString("mock_typo"));
+ assertThat(e.getMessage(), containsString(MOCK_TYPE));
+ }
+
+ public void testRegisteredPluginTypeAcceptedAtIndexCreation() throws IOException {
+ createMapperService(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("mock_template");
+ b.field("match_mapping_type", MOCK_TYPE);
+ b.startObject("mapping");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ }
+
+ public void testBuiltinTypeStillWorksAlongsidePluginType() throws IOException {
+ createMapperService(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("string_template");
+ b.field("match_mapping_type", "string");
+ b.startObject("mapping");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.startObject();
+ b.startObject("mock_template");
+ b.field("match_mapping_type", MOCK_TYPE);
+ b.startObject("mapping");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ }
+
+ // =====================================================================
+ // Eager plugin-template validation at index creation
+ // =====================================================================
+
+ public void testCompletePluginTemplateWithValidConfigAccepted() throws IOException {
+ // Handler opens no parser (complete config) and the type parser is satisfied → index creation succeeds.
+ createMapperService(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("mock_template");
+ b.field("match_mapping_type", MOCK_TYPE);
+ b.startObject("mapping");
+ b.field("type", MOCK_TYPE);
+ b.field("required_param", "present");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ }
+
+ public void testCompletePluginTemplateWithInvalidConfigThrowsAtIndexCreation() throws IOException {
+ // Complete config (handler opens no parser) but required_param is missing → the type parser
+ // throws at index creation instead of silently accepting the broken template.
+ XContentBuilder mapping = topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("mock_template");
+ b.field("match_mapping_type", MOCK_TYPE);
+ b.startObject("mapping");
+ b.field("type", MOCK_TYPE);
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ });
+ MapperParsingException e = expectThrows(MapperParsingException.class, () -> createMapperService(mapping));
+ assertThat(e.getMessage(), containsString("required_param missing"));
+ }
+
+ public void testInferredPluginTemplateWithIncompleteConfigNotValidatedAtIndexCreation() throws IOException {
+ // The handler for this type always opens the parser (data-derived config), so even though
+ // required_param is absent the template must NOT be validated/rejected at index creation.
+ createMapperService(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("inferred_template");
+ b.field("match_mapping_type", MOCK_INFERRED_TYPE);
+ b.startObject("mapping");
+ b.field("type", MOCK_INFERRED_TYPE);
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ }
+
+ public void testHandlerThrowingDuringEagerValidationIsNonFatal() throws IOException {
+ // The handler reports its config complete but throws from adjustMappingConfig. Core must treat
+ // this contract violation as non-fatal (log + defer) rather than failing index creation.
+ createMapperService(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("throwing_template");
+ b.field("match_mapping_type", MOCK_THROWING_TYPE);
+ b.startObject("mapping");
+ b.field("type", MOCK_THROWING_TYPE);
+ b.field("required_param", "present");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ }
+
+ public void testPluginTemplateWithNamePlaceholderSkipsEagerValidation() throws IOException {
+ // {name} can't be resolved up front, so validation is skipped even though required_param is missing.
+ createMapperService(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("named_template");
+ b.field("match_mapping_type", MOCK_TYPE);
+ b.startObject("mapping");
+ b.field("type", MOCK_TYPE);
+ b.field("field_name", "{name}");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ }
+
+ // =====================================================================
+ // Document indexing — inferencer tests
+ // =====================================================================
+
+ public void testInferencerClaimsLargeNumericScalar() throws IOException {
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ // 200 >= 100 threshold — MockInferencer claims it as "long"
+ ParsedDocument doc = mapper.parse(source(b -> b.field("big_number", 200)));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("big_number");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("long"));
+ }
+
+ public void testInferencerDoesNotClaimSmallNumericScalar() throws IOException {
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ // 50 < 100 threshold — MockInferencer returns null, normal path maps as long
+ ParsedDocument doc = mapper.parse(source(b -> b.field("small_number", 50)));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("small_number");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("long"));
+ }
+
+ public void testInferencerDoesNotClaimString() throws IOException {
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ // String — MockInferencer returns null (not a Number), normal path maps as text
+ ParsedDocument doc = mapper.parse(source(b -> b.field("str_field", "hello")));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("str_field");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("text"));
+ }
+
+ public void testInferencerDoesNotClaimBoolean() throws IOException {
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ ParsedDocument doc = mapper.parse(source(b -> b.field("bool_field", true)));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("bool_field");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("boolean"));
+ }
+
+ public void testExplicitMappingBeatsInference() throws IOException {
+ // Explicit keyword mapping — inference must not fire even for value >= 100
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+ b.startObject("properties");
+ b.startObject("my_field");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ }));
+ ParsedDocument doc = mapper.parse(source(b -> b.field("my_field", 200)));
+ assertNull("explicit mapping must beat inferencer", doc.dynamicMappingsUpdate());
+ }
+
+ public void testAlreadyInferredFieldSkipsInferenceOnSecondDoc() throws IOException {
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ // First doc triggers inference
+ ParsedDocument first = mapper.parse(source(b -> b.field("big_number", 200)));
+ assertNotNull(first.dynamicMappingsUpdate());
+
+ // Simulate mapping update applied — second doc should not re-infer
+ MapperService mapperService = createMapperService(topMapping(b -> {
+ b.startObject("properties");
+ b.startObject("big_number");
+ b.field("type", "long");
+ b.endObject();
+ b.endObject();
+ }));
+ ParsedDocument second = mapperService.documentMapper().parse(source(b -> b.field("big_number", 300)));
+ assertNull("already mapped field must not re-trigger inference", second.dynamicMappingsUpdate());
+ }
+
+ public void testNoPluginsNormalPathRuns() throws IOException {
+ // Without plugins, tryPluginInference returns false immediately
+ MapperService noPluginService = new MapperServiceTestCase() {
+ @Override
+ protected Collection extends Plugin> getPlugins() {
+ return Collections.emptyList();
+ }
+ }.createMapperService(topMapping(b -> {}));
+
+ ParsedDocument doc = noPluginService.documentMapper().parse(source(b -> b.field("my_field", "hello")));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("my_field");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("text"));
+ }
+
+ public void testBuiltinTemplateFallbackWhenInferencerDoesNotClaim() throws IOException {
+ // Builtin string → keyword template still fires when inferencer doesn't claim
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("strings_as_keyword");
+ b.field("match_mapping_type", "string");
+ b.startObject("mapping");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ ParsedDocument doc = mapper.parse(source(b -> b.field("my_field", "hello")));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("my_field");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("keyword"));
+ }
+
+ /** Plugin that registers only a template type handler — no inferencer */
+ static class TemplateOnlyPlugin extends Plugin implements MapperPlugin {
+ @Override
+ public Map getDynamicTemplateTypes() {
+ return Collections.singletonMap(MOCK_TYPE, new MockTemplateTypeHandler());
+ }
+ }
+
+ public void testFastPathNotExitedWhenOnlyTemplateTypesRegistered() throws IOException {
+ // Plugin registers template type but no inferencer.
+ // tryPluginInference must NOT fast-exit — template types alone are enough to proceed.
+ MapperService mapperService = new MapperServiceTestCase() {
+ @Override
+ protected Collection extends Plugin> getPlugins() {
+ return Collections.singletonList(new TemplateOnlyPlugin());
+ }
+ }.createMapperService(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("mock_template");
+ b.field("match_mapping_type", MOCK_TYPE);
+ b.startObject("mapping");
+ b.field("type", "keyword");
+ b.endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ assertNotNull(mapperService.documentMapper());
+ }
+}
diff --git a/server/src/test/java/org/opensearch/index/mapper/PluginInferenceConflictTests.java b/server/src/test/java/org/opensearch/index/mapper/PluginInferenceConflictTests.java
new file mode 100644
index 0000000000000..e2987b1071fb9
--- /dev/null
+++ b/server/src/test/java/org/opensearch/index/mapper/PluginInferenceConflictTests.java
@@ -0,0 +1,158 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.index.mapper;
+
+import org.opensearch.core.xcontent.XContentParser;
+import org.opensearch.plugins.MapperPlugin;
+import org.opensearch.plugins.Plugin;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.containsString;
+
+/**
+ * Tests that core fails loudly when more than one plugin mechanism claims the same unmapped field —
+ * two inferencers both claiming, or two plugin template types both matching. Per OpenSearch triage
+ * (Froh): loop through all claimants and throw on ambiguity rather than letting registration/load
+ * order silently pick a winner.
+ */
+public class PluginInferenceConflictTests extends MapperServiceTestCase {
+
+ private static final String TYPE_A = "type_a";
+ private static final String TYPE_B = "type_b";
+
+ /** An inferencer that claims every numeric scalar as {@code claimedType}. */
+ static class AlwaysClaimsNumberInferencer implements DynamicFieldTypeInferencer {
+ private final String claimedType;
+
+ AlwaysClaimsNumberInferencer(String claimedType) {
+ this.claimedType = claimedType;
+ }
+
+ @Override
+ public Map inferFieldType(FieldValueParserSupplier fieldValueParser) throws IOException {
+ try (XContentParser parser = fieldValueParser.get()) {
+ if (parser.currentToken() != XContentParser.Token.VALUE_NUMBER) return null;
+ }
+ Map config = new HashMap<>();
+ config.put("type", claimedType);
+ return config;
+ }
+ }
+
+ /** A no-op template handler whose config is always complete (validated eagerly, no field read). */
+ static class NoopHandler implements DynamicTemplateTypeHandler {
+ @Override
+ public void adjustMappingConfig(Map mappingConfig, FieldValueParserSupplier fieldValueParser) {}
+
+ @Override
+ public boolean isConfigComplete(Map mappingConfig) {
+ return true;
+ }
+ }
+
+ /** Builds a MockFieldMapper for a mock plugin type (no required params). */
+ static class MockTypeParser implements Mapper.TypeParser {
+ @Override
+ public Mapper.Builder> parse(String name, Map node, ParserContext parserContext) {
+ node.remove("type");
+ return new MockFieldMapper.Builder(name);
+ }
+ }
+
+ /**
+ * Registers two inferencers that both claim numeric scalars, and two plugin template types that
+ * both match any field — so a single unmapped field is claimed by two mechanisms of each kind.
+ */
+ static class ConflictingPlugin extends Plugin implements MapperPlugin {
+ @Override
+ public List getDynamicFieldTypeInferencers() {
+ return Arrays.asList(new AlwaysClaimsNumberInferencer(TYPE_A), new AlwaysClaimsNumberInferencer(TYPE_B));
+ }
+
+ @Override
+ public Map getDynamicTemplateTypes() {
+ Map handlers = new HashMap<>();
+ handlers.put(TYPE_A, new NoopHandler());
+ handlers.put(TYPE_B, new NoopHandler());
+ return handlers;
+ }
+
+ @Override
+ public Map getMappers() {
+ Map mappers = new HashMap<>();
+ mappers.put(TYPE_A, new MockTypeParser());
+ mappers.put(TYPE_B, new MockTypeParser());
+ return mappers;
+ }
+ }
+
+ @Override
+ protected Collection extends Plugin> getPlugins() {
+ return Collections.singletonList(new ConflictingPlugin());
+ }
+
+ public void testTwoInferencersClaimSameFieldThrows() throws IOException {
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ MapperParsingException e = expectThrows(MapperParsingException.class, () -> mapper.parse(source(b -> b.field("n", 5))));
+ assertThat(e.getMessage(), containsString("claimed by more than one dynamic field type inferencer"));
+ assertThat(e.getMessage(), containsString("n"));
+ }
+
+ public void testTwoPluginTemplatesMatchSameFieldThrows() throws IOException {
+ // Two templates, each targeting a different plugin type but the same field name pattern.
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("t_a");
+ b.field("match_mapping_type", TYPE_A);
+ b.field("match", "v_*");
+ b.startObject("mapping").field("type", TYPE_A).endObject();
+ b.endObject();
+ b.endObject();
+ b.startObject();
+ b.startObject("t_b");
+ b.field("match_mapping_type", TYPE_B);
+ b.field("match", "v_*");
+ b.startObject("mapping").field("type", TYPE_B).endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ MapperParsingException e = expectThrows(MapperParsingException.class, () -> mapper.parse(source(b -> b.field("v_x", 5))));
+ assertThat(e.getMessage(), containsString("matched more than one dynamic template plugin type"));
+ assertThat(e.getMessage(), containsString("v_x"));
+ }
+
+ public void testSingleTemplateMatchDoesNotThrow() throws IOException {
+ // Sanity: with only ONE plugin template matching (a name pattern no other template shares),
+ // there is no ambiguity — the field is claimed and mapped without a conflict exception.
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+ b.startArray("dynamic_templates");
+ b.startObject();
+ b.startObject("only_a");
+ b.field("match_mapping_type", TYPE_A);
+ b.field("match", "only_*");
+ b.startObject("mapping").field("type", TYPE_A).endObject();
+ b.endObject();
+ b.endObject();
+ b.endArray();
+ }));
+ // "only_field" is a string, so the numeric inferencers don't fire; only template t_a matches.
+ ParsedDocument doc = mapper.parse(source(b -> b.field("only_field", "text-value")));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ assertNotNull("single-matching template must map the field, not throw", doc.dynamicMappingsUpdate().root().getMapper("only_field"));
+ }
+}
diff --git a/server/src/test/java/org/opensearch/index/mapper/PluginInferencerEdgeCaseTests.java b/server/src/test/java/org/opensearch/index/mapper/PluginInferencerEdgeCaseTests.java
new file mode 100644
index 0000000000000..9a69ffad24b40
--- /dev/null
+++ b/server/src/test/java/org/opensearch/index/mapper/PluginInferencerEdgeCaseTests.java
@@ -0,0 +1,124 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.index.mapper;
+
+import org.opensearch.core.xcontent.XContentParser;
+import org.opensearch.plugins.MapperPlugin;
+import org.opensearch.plugins.Plugin;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * Exercises the error/edge branches of the inferencer path in {@code DocumentParser.tryPluginInference}:
+ * a buggy inferencer that throws is caught and skipped; an inferencer that returns a config with no
+ * {@code type}, or an unknown {@code type}, falls through to the normal path instead of failing.
+ *
+ * Each test installs a plugin with a single inferencer of a fixed behavior — no shared mutable
+ * state — so the tests are independent and safe under any execution order.
+ */
+public class PluginInferencerEdgeCaseTests extends MapperServiceTestCase {
+
+ enum Behavior {
+ THROW, // inferencer throws — must be caught, field falls through to normal path
+ NO_TYPE, // returns a config map without a "type" key — falls through
+ UNKNOWN_TYPE // returns a config with a type no TypeParser is registered for — falls through
+ }
+
+ /** Claims numeric scalars, then behaves per its fixed {@link Behavior}. Immutable, no shared state. */
+ private static class ConfigurableInferencer implements DynamicFieldTypeInferencer {
+ private final Behavior behavior;
+
+ ConfigurableInferencer(Behavior behavior) {
+ this.behavior = behavior;
+ }
+
+ @Override
+ public Map inferFieldType(FieldValueParserSupplier fieldValueParser) throws IOException {
+ try (XContentParser parser = fieldValueParser.get()) {
+ if (parser.currentToken() != XContentParser.Token.VALUE_NUMBER) return null;
+ }
+ switch (behavior) {
+ case THROW:
+ throw new RuntimeException("boom from inferencer");
+ case NO_TYPE: {
+ Map config = new HashMap<>();
+ config.put("some_param", "x"); // deliberately no "type"
+ return config;
+ }
+ case UNKNOWN_TYPE:
+ default: {
+ Map config = new HashMap<>();
+ config.put("type", "no_such_registered_type");
+ return config;
+ }
+ }
+ }
+ }
+
+ private static class EdgeCasePlugin extends Plugin implements MapperPlugin {
+ private final Behavior behavior;
+
+ EdgeCasePlugin(Behavior behavior) {
+ this.behavior = behavior;
+ }
+
+ @Override
+ public List getDynamicFieldTypeInferencers() {
+ return Collections.singletonList(new ConfigurableInferencer(behavior));
+ }
+ }
+
+ // Set by each test before createDocumentMapper() is called; consumed by getPlugins().
+ private Behavior behavior;
+
+ @Override
+ protected Collection extends Plugin> getPlugins() {
+ return Collections.singletonList(new EdgeCasePlugin(behavior));
+ }
+
+ /** A throwing inferencer must not break parsing; the field falls through to normal inference (long). */
+ public void testThrowingInferencerIsCaughtAndFieldFallsThrough() throws IOException {
+ behavior = Behavior.THROW;
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ ParsedDocument doc = mapper.parse(source(b -> b.field("n", 5)));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("n");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("long"));
+ }
+
+ /** A config with no "type" key is ignored; the field falls through to normal inference. */
+ public void testInferredConfigWithoutTypeFallsThrough() throws IOException {
+ behavior = Behavior.NO_TYPE;
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ ParsedDocument doc = mapper.parse(source(b -> b.field("n", 5)));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("n");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("long"));
+ }
+
+ /** A config whose "type" has no registered TypeParser is ignored; the field falls through. */
+ public void testInferredUnknownTypeFallsThrough() throws IOException {
+ behavior = Behavior.UNKNOWN_TYPE;
+ DocumentMapper mapper = createDocumentMapper(topMapping(b -> {}));
+ ParsedDocument doc = mapper.parse(source(b -> b.field("n", 5)));
+ assertNotNull(doc.dynamicMappingsUpdate());
+ Mapper fieldMapper = doc.dynamicMappingsUpdate().root().getMapper("n");
+ assertNotNull(fieldMapper);
+ assertThat(fieldMapper.typeName(), equalTo("long"));
+ }
+}
diff --git a/server/src/test/java/org/opensearch/indices/IndicesModuleTests.java b/server/src/test/java/org/opensearch/indices/IndicesModuleTests.java
index c54dfa0fab277..077f2485f2874 100644
--- a/server/src/test/java/org/opensearch/indices/IndicesModuleTests.java
+++ b/server/src/test/java/org/opensearch/indices/IndicesModuleTests.java
@@ -290,4 +290,59 @@ public Function> getFieldFilter() {
assertNotSame(MapperPlugin.NOOP_FIELD_PREDICATE, fieldFilter.apply("hidden_index"));
assertNotSame(MapperPlugin.NOOP_FIELD_PREDICATE, fieldFilter.apply("filtered"));
}
+
+ /** A no-op dynamic template type handler for registration tests. */
+ private static org.opensearch.index.mapper.DynamicTemplateTypeHandler noopHandler() {
+ return new org.opensearch.index.mapper.DynamicTemplateTypeHandler() {
+ @Override
+ public void adjustMappingConfig(
+ Map mappingConfig,
+ org.opensearch.index.mapper.FieldValueParserSupplier fieldValueParser
+ ) {}
+ };
+ }
+
+ /** A no-op inferencer for registration tests. */
+ private static org.opensearch.index.mapper.DynamicFieldTypeInferencer noopInferencer() {
+ return fieldValueParser -> null;
+ }
+
+ public void testInferencersAndTemplateTypesRegisteredFromPlugins() {
+ List plugins = Collections.singletonList(new MapperPlugin() {
+ @Override
+ public List getDynamicFieldTypeInferencers() {
+ return Collections.singletonList(noopInferencer());
+ }
+
+ @Override
+ public Map getDynamicTemplateTypes() {
+ return Collections.singletonMap("my_type", noopHandler());
+ }
+ });
+ MapperRegistry registry = new IndicesModule(plugins).getMapperRegistry();
+ assertEquals(1, registry.getDynamicFieldTypeInferencers().size());
+ assertTrue(registry.getDynamicTemplateTypes().containsKey("my_type"));
+ }
+
+ public void testDuplicateTemplateTypeAcrossPluginsRejectedAtStartup() {
+ List plugins = Arrays.asList(new MapperPlugin() {
+ @Override
+ public Map getDynamicTemplateTypes() {
+ return Collections.singletonMap("dup_type", noopHandler());
+ }
+ }, new MapperPlugin() {
+ @Override
+ public Map getDynamicTemplateTypes() {
+ return Collections.singletonMap("dup_type", noopHandler());
+ }
+ });
+ IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> new IndicesModule(plugins));
+ assertThat(e.getMessage(), containsString("dynamic template type [dup_type] is already registered"));
+ }
+
+ public void testNoDynamicInferencersOrTemplateTypesByDefault() {
+ MapperRegistry registry = new IndicesModule(Collections.emptyList()).getMapperRegistry();
+ assertTrue(registry.getDynamicFieldTypeInferencers().isEmpty());
+ assertTrue(registry.getDynamicTemplateTypes().isEmpty());
+ }
}