Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1078,4 +1078,181 @@ public void testAwarenessReplicaBalance() throws IOException {
}
}

public void testIndexTemplateWithPreserveDots() throws Exception {
// Create an index template with preserve_dots enabled
client().admin()
.indices()
.preparePutTemplate("metrics_template")
.setPatterns(Collections.singletonList("metrics-*"))
.setMapping(
XContentFactory.jsonBuilder()
.startObject()
.field("preserve_dots", true)
.startObject("properties")
.startObject("cpu.usage")
.field("type", "float")
.endObject()
.startObject("memory.used")
.field("type", "long")
.endObject()
.endObject()
.endObject()
)
.get();

// Create an index that matches the template pattern
assertAcked(prepareCreate("metrics-001"));

// Verify that preserve_dots setting was applied from the template
ClusterState state = client().admin().cluster().prepareState().get().getState();
IndexMetadata indexMetadata = state.metadata().index("metrics-001");
assertNotNull(indexMetadata);

// Get the mapping and verify preserve_dots is set
String mapping = indexMetadata.mapping().source().toString();
assertTrue("Mapping should contain preserve_dots", mapping.contains("preserve_dots"));
assertTrue("preserve_dots should be set to true", mapping.contains("\"preserve_dots\":true"));

// Index a document with dotted field names
client().prepareIndex("metrics-001")
.setSource(
XContentFactory.jsonBuilder()
.startObject()
.field("cpu.usage", 75.5)
.field("memory.used", 8589934592L)
.endObject()
)
.setRefreshPolicy(IMMEDIATE)
.get();

// Query using the complete dotted field name
SearchResponse searchResponse = client().prepareSearch("metrics-001")
.setQuery(QueryBuilders.rangeQuery("cpu.usage").gte(70.0))
.get();

assertHitCount(searchResponse, 1);
}

public void testIndexTemplateWithPreserveDotsAtNestedLevel() throws Exception {
// Create an index template with preserve_dots enabled on a nested object
client().admin()
.indices()
.preparePutTemplate("mixed_template")
.setPatterns(Collections.singletonList("mixed-*"))
.setMapping(
XContentFactory.jsonBuilder()
.startObject()
.startObject("properties")
.startObject("user")
.field("type", "object")
.startObject("properties")
.startObject("name")
.field("type", "text")
.endObject()
.endObject()
.endObject()
.startObject("metrics")
.field("type", "object")
.field("preserve_dots", true)
.startObject("properties")
.startObject("cpu.usage")
.field("type", "float")
.endObject()
.endObject()
.endObject()
.endObject()
.endObject()
)
.get();

// Create an index that matches the template pattern
assertAcked(prepareCreate("mixed-001"));

// Verify that preserve_dots setting was applied from the template
ClusterState state = client().admin().cluster().prepareState().get().getState();
IndexMetadata indexMetadata = state.metadata().index("mixed-001");
assertNotNull(indexMetadata);

// Get the mapping and verify preserve_dots is set on the metrics object
String mapping = indexMetadata.mapping().source().toString();
assertTrue("Mapping should contain preserve_dots", mapping.contains("preserve_dots"));

// Index a document with both nested and flat dotted fields
client().prepareIndex("mixed-001")
.setSource(
XContentFactory.jsonBuilder()
.startObject()
.startObject("user")
.field("name", "John Doe")
.endObject()
.startObject("metrics")
.field("cpu.usage", 85.0)
.endObject()
.endObject()
)
.setRefreshPolicy(IMMEDIATE)
.get();

// Query using both field types
SearchResponse searchResponse = client().prepareSearch("mixed-001")
.setQuery(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("user.name", "John"))
.must(QueryBuilders.rangeQuery("metrics.cpu.usage").gte(80.0)))
.get();

assertHitCount(searchResponse, 1);
}

public void testMultipleTemplatesWithPreserveDots() throws Exception {
// Create first template with lower order
client().admin()
.indices()
.preparePutTemplate("template_1")
.setPatterns(Collections.singletonList("test-*"))
.setOrder(0)
.setMapping(
XContentFactory.jsonBuilder()
.startObject()
.field("preserve_dots", false)
.startObject("properties")
.startObject("field1")
.field("type", "text")
.endObject()
.endObject()
.endObject()
)
.get();

// Create second template with higher order and preserve_dots enabled
client().admin()
.indices()
.preparePutTemplate("template_2")
.setPatterns(Collections.singletonList("test-*"))
.setOrder(1)
.setMapping(
XContentFactory.jsonBuilder()
.startObject()
.field("preserve_dots", true)
.startObject("properties")
.startObject("field.dotted")
.field("type", "keyword")
.endObject()
.endObject()
.endObject()
)
.get();

// Create an index that matches both templates
assertAcked(prepareCreate("test-001"));

// Verify that preserve_dots from the higher order template was applied
ClusterState state = client().admin().cluster().prepareState().get().getState();
IndexMetadata indexMetadata = state.metadata().index("test-001");
assertNotNull(indexMetadata);

String mapping = indexMetadata.mapping().source().toString();
assertTrue("Mapping should contain preserve_dots", mapping.contains("preserve_dots"));
assertTrue("preserve_dots should be set to true from higher order template", mapping.contains("\"preserve_dots\":true"));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,14 @@ private static ParseContext nestedContext(ParseContext context, ObjectMapper map

private static void parseObjectOrField(ParseContext context, Mapper mapper) throws IOException {
if (mapper instanceof ObjectMapper objectMapper) {
parseObjectOrNested(context, objectMapper);
// Check if preserve_dots is enabled for this object mapper
if (objectMapper.preserveDots()) {
// Parse as flat field - do not expand dots into nested objects
parseFieldWithDots(context, objectMapper);
} else {
// Existing behavior: parse as nested object
parseObjectOrNested(context, objectMapper);
}
} else if (mapper instanceof FieldMapper fieldMapper) {
fieldMapper.parse(context);
parseCopyFields(context, fieldMapper.copyTo().copyToFields());
Expand All @@ -561,6 +568,188 @@ private static void parseObjectOrField(ParseContext context, Mapper mapper) thro
}
}

/**
* Parses fields with dots as literal field names when preserve_dots is enabled.
* This method treats dotted field names (e.g., "user.name", "metrics.cpu.usage") as flat fields
* rather than expanding them into nested object hierarchies.
*
* @param context the parse context
* @param mapper the object mapper with preserve_dots enabled
* @throws IOException if parsing fails
*/
private static void parseFieldWithDots(ParseContext context, ObjectMapper mapper) throws IOException {
if (mapper.isEnabled() == false) {
context.parser().skipChildren();
return;
}

XContentParser parser = context.parser();
XContentParser.Token token = parser.currentToken();

if (token == XContentParser.Token.VALUE_NULL) {
// the object is null, simply bail
return;
}

String currentFieldName = parser.currentName();
if (token.isValue()) {
throw new MapperParsingException(
"Document structure incompatibility: Field ["
+ currentFieldName
+ "] in object ["
+ mapper.name()
+ "] is defined as an object with preserve_dots=true, but received a concrete value. "
+ "Expected format: an object containing flat dotted field names (e.g., {\"field.name\": \"value\"})."
);
}

// if we are at the end of the previous object, advance
if (token == XContentParser.Token.END_OBJECT) {
token = parser.nextToken();
}
if (token == XContentParser.Token.START_OBJECT) {
// if we are just starting an OBJECT, advance, this is the object we are parsing, we need the name first
token = parser.nextToken();
}

try {
assert token == XContentParser.Token.FIELD_NAME || token == XContentParser.Token.END_OBJECT;
context.incrementFieldCurrentDepth();
context.checkFieldDepthLimit();

while (token != XContentParser.Token.END_OBJECT) {
if (token == XContentParser.Token.FIELD_NAME) {
currentFieldName = parser.currentName();
// For preserve_dots, treat the entire field name (including dots) as a literal field name
// Do not split on dots - this is the key difference from normal object parsing

// Look up the mapper using the complete dotted field name
Mapper fieldMapper = mapper.getMapper(currentFieldName);

if (fieldMapper != null) {
// Found an existing mapper for this dotted field name
token = parser.nextToken();
if (fieldMapper instanceof FieldMapper fm) {
fm.parse(context);
parseCopyFields(context, fm.copyTo().copyToFields());
} else if (fieldMapper instanceof ObjectMapper om) {
// Even with preserve_dots, we can have nested ObjectMappers
// for explicitly defined sub-objects
parseObjectOrNested(context, om);
} else {
throw new MapperParsingException(
"Document structure incompatibility: Cannot parse field ["
+ currentFieldName
+ "] with mapper type ["
+ fieldMapper.getClass().getSimpleName()
+ "]. Expected a field mapper or object mapper."
);
}
} else {
// No existing mapper - handle dynamic mapping
token = parser.nextToken();
ObjectMapper.Dynamic dynamic = dynamicOrDefault(mapper, context);

switch (dynamic) {
case STRICT:
throw new StrictDynamicMappingException(
dynamic.name().toLowerCase(Locale.ROOT),
mapper.fullPath(),
currentFieldName
);
case TRUE:
case STRICT_ALLOW_TEMPLATES:
case FALSE_ALLOW_TEMPLATES:
// Determine the field type based on the token
XContentFieldType fieldType = getFieldType(token);

Mapper.Builder builder = findTemplateBuilder(
context,
currentFieldName,
fieldType,
dynamic,
mapper.fullPath()
);

if (builder == null) {
if (dynamic == ObjectMapper.Dynamic.FALSE_ALLOW_TEMPLATES) {
if (token == XContentParser.Token.START_OBJECT || token == XContentParser.Token.START_ARRAY) {
parser.skipChildren();
}
break;
}
// Create a default field mapper based on the value type
builder = createBuilderFromDynamicValue(
context,
token,
currentFieldName,
dynamic,
mapper.fullPath()
);
}

if (builder != null) {
Mapper.BuilderContext builderContext = new Mapper.BuilderContext(
context.indexSettings().getSettings(),
context.path()
);
Mapper dynamicMapper = builder.build(builderContext);
context.addDynamicMapper(dynamicMapper);

if (dynamicMapper instanceof FieldMapper fm) {
fm.parse(context);
parseCopyFields(context, fm.copyTo().copyToFields());
} else if (dynamicMapper instanceof ObjectMapper om) {
parseObjectOrNested(context, om);
}
}
break;
case FALSE:
// Dynamic mapping is disabled, skip the field
if (token == XContentParser.Token.START_OBJECT || token == XContentParser.Token.START_ARRAY) {
parser.skipChildren();
}
break;
}
}
} else if (token == null) {
throw new MapperParsingException(
"Document structure incompatibility: Unexpected end of document while parsing field ["
+ currentFieldName
+ "] in object ["
+ mapper.name()
+ "]. Expected format: an object containing flat dotted field names with their values."
);
}
token = parser.nextToken();
}
} finally {
context.decrementFieldCurrentDepth();
}
}

/**
* Determines the XContentFieldType based on the parser token.
* Used for dynamic field mapping when preserve_dots is enabled.
*/
private static XContentFieldType getFieldType(XContentParser.Token token) {
if (token == XContentParser.Token.START_OBJECT) {
return XContentFieldType.OBJECT;
} else if (token == XContentParser.Token.START_ARRAY) {
return XContentFieldType.OBJECT; // Arrays are treated as objects for template matching
} else if (token == XContentParser.Token.VALUE_STRING) {
return XContentFieldType.STRING;
} else if (token == XContentParser.Token.VALUE_NUMBER) {
return XContentFieldType.LONG; // Default to LONG, will be refined by createBuilderFromDynamicValue
} else if (token == XContentParser.Token.VALUE_BOOLEAN) {
return XContentFieldType.BOOLEAN;
} else if (token == XContentParser.Token.VALUE_EMBEDDED_OBJECT) {
return XContentFieldType.BINARY;
} else {
return XContentFieldType.STRING; // Default fallback
}
}

private static void parseObject(final ParseContext context, ObjectMapper mapper, String currentFieldName, String[] paths)
throws IOException {
assert currentFieldName != null;
Expand Down
Loading
Loading