Skip to content

Commit d45b19d

Browse files
authored
Add support for dots in field names for metrics usecases (#86166)
This PR adds support for a new mapping parameter to the configuration of the object mapper (root as well as individual fields), that makes it possible to store metrics data where it's common to have fields with dots in their names in the following format: ``` { "metrics.time" : 10, "metrics.time.min" : 1, "metrics.time.max" : 500 } ``` Instead of expanding dotted paths the their corresponding object structure, objects can be configured to preserve dots in field names, in which case they can only hold leaf sub-fields and no further objects. The mapping parameter is called subobjects and controls whether an object can hold other objects (defaults to true) or not. The following example shows how it can be configured in the mappings: ``` { "mappings" : { "properties" : { "metrics" : { "type" : "object", "subobjects" : false } } } } ``` Closes #63530
1 parent 6b73207 commit d45b19d

20 files changed

+944
-55
lines changed

docs/changelog/86166.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
pr: 86166
2+
summary: Add support for dots in field names for metrics usecases
3+
area: Mapping
4+
type: feature
5+
issues:
6+
- 63530
7+
highlight:
8+
title: Add support for dots in field names for metrics usecases
9+
body: |-
10+
Metrics data can often be made of several fields with dots in their names,
11+
sharing common prefixes, like in the following example:
12+
13+
```
14+
{
15+
"metrics.time" : 10,
16+
"metrics.time.min" : 1,
17+
"metrics.time.max" : 500
18+
}
19+
```
20+
21+
Such format causes a mapping conflict as the `metrics.time` holds a value,
22+
but it also needs to be mapped as an object in order to hold the `min` and
23+
`max` leaf fields.
24+
25+
A new object mapping parameter called `subobjects`, which defaults to `true`,
26+
has been introduced to preserve dots in field names. An object with `subobjects`
27+
set to `false` can only ever hold leaf sub-fields and no further objects. The
28+
following example shows how it can be configured in the mappings for the
29+
`metrics` object:
30+
31+
```
32+
{
33+
"mappings": {
34+
"properties" : {
35+
"metrics" : {
36+
"type" : "object",
37+
"subobjects" : false
38+
}
39+
}
40+
}
41+
}
42+
```
43+
44+
With this configuration any child of `metrics` will be mapped unchanged,
45+
without expanding dots in field names to the corresponding object structure.
46+
That makes it possible to store the metrics document above.
47+
48+
notable: true

docs/reference/mapping/params.asciidoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The following mapping parameters are common to some or all field data types:
3131
* <<properties,`properties`>>
3232
* <<search-analyzer,`search_analyzer`>>
3333
* <<similarity,`similarity`>>
34+
* <<subobjects,`subobjects`>>
3435
* <<mapping-store,`store`>>
3536
* <<term-vector,`term_vector`>>
3637

@@ -83,4 +84,6 @@ include::params/similarity.asciidoc[]
8384

8485
include::params/store.asciidoc[]
8586

87+
include::params/subobjects.asciidoc[]
88+
8689
include::params/term-vector.asciidoc[]
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
[[subobjects]]
2+
=== `subobjects`
3+
4+
When indexing a document or updating mappings, Elasticsearch accepts fields that contain dots in their names,
5+
which get expanded to their corresponding object structure. For instance, the field `metrics.time.max`
6+
is mapped as a `max` leaf field with a parent `time` object, belonging to its parent `metrics` object.
7+
8+
The described default behaviour is reasonable for most scenarios, but causes problems in certain situations
9+
where for instance a field `metrics.time` holds a value too, which is common when indexing metrics data.
10+
A document holding a value for both `metrics.time.max` and `metrics.time` gets rejected given that `time`
11+
would need to be a leaf field to hold a value as well as an object to hold the `max` sub-field.
12+
13+
The `subobjects` setting, which can be applied only to the top-level mapping definition and
14+
to <<object,`object`>> fields, disables the ability for an object to hold further subobjects and makes it possible
15+
to store documents where field names contain dots and share common prefixes. From the example above, if the object
16+
container `metrics` has `subobjects` set to `false`, it can hold values for both `time` and `time.max` directly
17+
without the need for any intermediate object, as dots in field names are preserved.
18+
19+
[source,console]
20+
--------------------------------------------------
21+
PUT my-index-000001
22+
{
23+
"mappings": {
24+
"properties": {
25+
"metrics": {
26+
"type": "object",
27+
"subobjects": false <1>
28+
}
29+
}
30+
}
31+
}
32+
33+
PUT my-index-000001/_doc/metric_1
34+
{
35+
"metrics.time" : 100, <2>
36+
"metrics.time.min" : 10,
37+
"metrics.time.max" : 900
38+
}
39+
40+
PUT my-index-000001/_doc/metric_2
41+
{
42+
"metrics" : {
43+
"time" : 100, <3>
44+
"time.min" : 10,
45+
"time.max" : 900
46+
}
47+
}
48+
49+
GET my-index-000001/_mapping
50+
--------------------------------------------------
51+
52+
[source,console-result]
53+
--------------------------------------------------
54+
{
55+
"my-index-000001" : {
56+
"mappings" : {
57+
"properties" : {
58+
"metrics" : {
59+
"subobjects" : false,
60+
"properties" : {
61+
"time" : {
62+
"type" : "long"
63+
},
64+
"time.min" : { <4>
65+
"type" : "long"
66+
},
67+
"time.max" : {
68+
"type" : "long"
69+
}
70+
}
71+
}
72+
}
73+
}
74+
}
75+
}
76+
--------------------------------------------------
77+
78+
<1> The `metrics` field cannot hold other objects.
79+
<2> Sample document holding flat paths
80+
<3> Sample document holding an object (configured to not hold subobjects) and its leaf sub-fields
81+
<4> The resulting mapping where dots in field names were preserved
82+
83+
The entire mapping may be configured to not support subobjects as well, in which case the document can
84+
only ever hold leaf sub-fields:
85+
86+
[source,console]
87+
--------------------------------------------------
88+
PUT my-index-000001
89+
{
90+
"mappings": {
91+
"subobjects": false <1>
92+
}
93+
}
94+
95+
PUT my-index-000001/_doc/metric_1
96+
{
97+
"time" : "100ms", <2>
98+
"time.min" : "10ms",
99+
"time.max" : "900ms"
100+
}
101+
102+
--------------------------------------------------
103+
104+
<1> The entire mapping is configured to not support objects.
105+
<2> The document does not support objects
106+
107+
The `subobjects` setting for existing fields and the top-level mapping definition cannot be updated.

docs/reference/mapping/types/object.asciidoc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ The following parameters are accepted by `object` fields:
9090
Whether the JSON value given for the object field should be
9191
parsed and indexed (`true`, default) or completely ignored (`false`).
9292

93+
<<subobjects,`subobjects`>>::
94+
95+
Whether the object can hold subobjects (`true`, default) or not (`false`). If not, sub-fields
96+
with dots in their names will be treated as leaves instead, otherwise their field names
97+
would be expanded to their corresponding object structure.
98+
9399
<<properties,`properties`>>::
94100

95101
The fields within the object, which can be of any
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"Metrics indexing":
3+
- skip:
4+
version: " - 8.2.99"
5+
reason: added in 8.3.0
6+
7+
- do:
8+
indices.put_template:
9+
name: test
10+
body:
11+
index_patterns: test-*
12+
mappings:
13+
dynamic_templates:
14+
- no_subobjects:
15+
match: metrics
16+
mapping:
17+
type: object
18+
subobjects: false
19+
20+
- do:
21+
index:
22+
index: test-1
23+
id: 1
24+
refresh: true
25+
body:
26+
{ metrics.time: 10, metrics.time.max: 100, metrics.time.min: 1 }
27+
28+
- do:
29+
field_caps:
30+
index: test-1
31+
fields: metrics.time*
32+
- match: {fields.metrics\.time.long.searchable: true}
33+
- match: {fields.metrics\.time.long.aggregatable: true}
34+
- match: {fields.metrics\.time\.max.long.searchable: true}
35+
- match: {fields.metrics\.time\.max.long.aggregatable: true}
36+
- match: {fields.metrics\.time\.min.long.searchable: true}
37+
- match: {fields.metrics\.time\.min.long.aggregatable: true}

server/src/main/java/org/elasticsearch/index/mapper/ContentPath.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public final class ContentPath {
2020

2121
private String[] path = new String[10];
2222

23+
private boolean withinLeafObject = false;
24+
2325
public ContentPath() {
2426
this(0);
2527
}
@@ -54,6 +56,14 @@ public void remove() {
5456
path[index--] = null;
5557
}
5658

59+
public void setWithinLeafObject(boolean withinLeafObject) {
60+
this.withinLeafObject = withinLeafObject;
61+
}
62+
63+
public boolean isWithinLeafObject() {
64+
return withinLeafObject;
65+
}
66+
5767
public String pathAsText(String name) {
5868
sb.setLength(0);
5969
for (int i = offset; i < index; i++) {

server/src/main/java/org/elasticsearch/index/mapper/DocumentParser.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,13 @@ private static void parseObject(final DocumentParserContext context, ObjectMappe
445445
Mapper objectMapper = getMapper(context, mapper, currentFieldName);
446446
if (objectMapper != null) {
447447
context.path().add(currentFieldName);
448+
if (objectMapper instanceof ObjectMapper objMapper) {
449+
if (objMapper.subobjects() == false) {
450+
context.path().setWithinLeafObject(true);
451+
}
452+
}
448453
parseObjectOrField(context, objectMapper);
454+
context.path().setWithinLeafObject(false);
449455
context.path().remove();
450456
} else {
451457
parseObjectDynamic(context, mapper, currentFieldName);
@@ -474,7 +480,13 @@ private static void parseObjectDynamic(DocumentParserContext context, ObjectMapp
474480
throwOnCreateDynamicNestedViaCopyTo(dynamicObjectMapper);
475481
}
476482
context.path().add(currentFieldName);
483+
if (dynamicObjectMapper instanceof ObjectMapper objectMapper) {
484+
if (objectMapper.subobjects() == false) {
485+
context.path().setWithinLeafObject(true);
486+
}
487+
}
477488
parseObjectOrField(context, dynamicObjectMapper);
489+
context.path().setWithinLeafObject(false);
478490
context.path().remove();
479491
}
480492
}
@@ -789,7 +801,7 @@ protected String contentType() {
789801

790802
private static class NoOpObjectMapper extends ObjectMapper {
791803
NoOpObjectMapper(String name, String fullPath) {
792-
super(name, fullPath, Explicit.IMPLICIT_TRUE, Dynamic.RUNTIME, Collections.emptyMap());
804+
super(name, fullPath, Explicit.IMPLICIT_TRUE, Explicit.IMPLICIT_TRUE, Dynamic.RUNTIME, Collections.emptyMap());
793805
}
794806
}
795807

@@ -815,7 +827,11 @@ private static class InternalDocumentParserContext extends DocumentParserContext
815827
XContentParser parser
816828
) throws IOException {
817829
super(mappingLookup, indexSettings, indexAnalyzers, parserContext, source);
818-
this.parser = DotExpandingXContentParser.expandDots(parser);
830+
if (mappingLookup.getMapping().getRoot().subobjects()) {
831+
this.parser = DotExpandingXContentParser.expandDots(parser, this.path::isWithinLeafObject);
832+
} else {
833+
this.parser = parser;
834+
}
819835
this.document = new LuceneDocument();
820836
this.documents.add(document);
821837
this.maxAllowedNumNestedDocs = indexSettings().getMappingNestedDocsLimit();

server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ public LuceneDocument doc() {
314314
*/
315315
public final DocumentParserContext createCopyToContext(String copyToField, LuceneDocument doc) throws IOException {
316316
ContentPath path = new ContentPath(0);
317-
XContentParser parser = DotExpandingXContentParser.expandDots(new CopyToParser(copyToField, parser()));
317+
XContentParser parser = DotExpandingXContentParser.expandDots(new CopyToParser(copyToField, parser()), path::isWithinLeafObject);
318318
return new Wrapper(this) {
319319
@Override
320320
public ContentPath path() {

0 commit comments

Comments
 (0)