Skip to content

Commit fee6b18

Browse files
Add sub-field support to flattened field type (#144451)
The flattened field type indexes all leaf values as untyped keywords, preventing type-aware operations (range queries, date math, numeric aggregations) on individual keys. Users needing typed behavior must switch the entire object to object type/ A new optional properties parameter lets users declare specific paths with real leaf field types while leaving the rest on the default untyped flattened path. ``` { "labels": { "type": "flattened", "properties": { "host.name": { "type": "keyword" }, "status_code": { "type": "long" } } } } ``` At index time, values matching a mapped property are delegated to that sub-field's mapper and excluded from the root/keyed flattened fields. Unmapped keys continue to behave as normal flattened keywords. Allowed sub-field types: keyword, constant_keyword, wildcard, text, long, integer, short, byte, double, float, half_float, scaled_float, unsigned_long, date, date_nanos, boolean, ip. Supported operations: typed search, sort (including index sort), aggregations, ESQL block loading, and synthetic _source. Restrictions: copy_to and fields (multi-fields) are disallowed on mapped properties. Only leaf types from the allow-list are permitted.
1 parent f5f634d commit fee6b18

File tree

12 files changed

+1736
-59
lines changed

12 files changed

+1736
-59
lines changed

docs/changelog/144451.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
area: Mapping
2+
issues: []
3+
pr: 144451
4+
summary: Add properties support to flattened field type
5+
type: enhancement

docs/reference/elasticsearch/mapping-reference/flattened.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,53 @@ Because `labels` is a `flattened` field type, the entire object is mapped as a s
207207
```
208208

209209

210+
## Mapped sub-fields [flattened-properties]
211+
212+
By default, all keys in a flattened field are indexed as untyped keyword values. The `properties` parameter allows specific keys to be mapped as their own typed fields, such as `keyword`, `ip`, `long`, `date`, or any other leaf field type. Mapped keys are indexed exclusively through their sub-field and are excluded from the flattened field's representation.
213+
214+
This is useful when certain keys within the flattened object need functionality that plain flattened indexing does not support, such as index sorting, field aliases, or typed queries (for example, IP range queries on an `ip` field).
215+
216+
```console
217+
PUT events
218+
{
219+
"mappings": {
220+
"properties": {
221+
"attributes": {
222+
"type": "flattened",
223+
"properties": {
224+
"host.name": { "type": "keyword" },
225+
"host.ip": { "type": "ip" }
226+
}
227+
}
228+
}
229+
}
230+
}
231+
232+
POST events/_doc/1
233+
{
234+
"attributes": {
235+
"host.name": "web-1",
236+
"host.ip": "192.168.1.10",
237+
"region": "us-east-1"
238+
}
239+
}
240+
```
241+
242+
In this example, `attributes.host.name` is a keyword field and `attributes.host.ip` is an IP field, both with their full typed capabilities. The key `region` is not mapped, so it is indexed through the normal flattened mechanism. Searching on `attributes.host.ip` uses IP-aware queries:
243+
244+
```console
245+
POST events/_search
246+
{
247+
"query": {
248+
"term": { "attributes.host.ip": "192.168.1.10" }
249+
}
250+
}
251+
```
252+
253+
Only leaf field types are allowed as sub-field types.
254+
Object, nested, and flattened types cannot be used as properties of a flattened field.
255+
Sub-fields may not use `copy_to` or `fields` (multi-fields) parameters.
256+
210257
## Parameters for flattened object fields [flattened-params]
211258

212259
The following mapping parameters are accepted:
@@ -232,6 +279,9 @@ The following mapping parameters are accepted:
232279
[`null_value`](/reference/elasticsearch/mapping-reference/null-value.md)
233280
: A string value which is substituted for any explicit `null` values within the flattened object field. Defaults to `null`, which means null fields are treated as if they were missing.
234281

282+
`properties`
283+
: (Optional, object) A map of key names to field mappings. Allows specific keys within the flattened object to be mapped as typed sub-fields. Each entry maps a key (using dot notation for nested keys) to a leaf field type definition. See [Mapped sub-fields](#flattened-properties).
284+
235285
[`similarity`](/reference/elasticsearch/mapping-reference/similarity.md)
236286
: Which scoring algorithm or *similarity* should be used. Defaults to `BM25`.
237287

@@ -412,4 +462,4 @@ For example, if the field is defined in an index configured with synthetic sourc
412462
}
413463
}
414464
```
415-
% TEST[skip:backporting-from-new-docs]
465+
% TEST[skip:backporting-from-new-docs]

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/search/340_flattened.yml

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,277 @@ setup:
240240
fields: [ { "field" : "flattened.non_existing_field" } ]
241241

242242
- is_false: hits.hits.0.fields
243+
244+
---
245+
"Test flattened field with mapped properties":
246+
- requires:
247+
cluster_features: ["mapper.flattened.mapped_subfields"]
248+
reason: "mapped properties on flattened fields"
249+
- do:
250+
indices.create:
251+
index: test
252+
body:
253+
mappings:
254+
properties:
255+
labels:
256+
type: flattened
257+
properties:
258+
host.name:
259+
type: keyword
260+
status_code:
261+
type: long
262+
263+
- do:
264+
bulk:
265+
index: test
266+
refresh: true
267+
body:
268+
- '{ "index": { "_id": "1" } }'
269+
- '{ "labels": { "host": { "name": "server-a" }, "status_code": 200, "region": "us-east-1" } }'
270+
- '{ "index": { "_id": "2" } }'
271+
- '{ "labels": { "host": { "name": "server-b" }, "status_code": 503, "region": "eu-west-1" } }'
272+
273+
- match: { errors: false }
274+
275+
# Term query on mapped keyword property
276+
- do:
277+
search:
278+
index: test
279+
body:
280+
query:
281+
term:
282+
labels.host.name: server-a
283+
284+
- match: { hits.total.value: 1 }
285+
- match: { hits.hits.0._id: "1" }
286+
287+
# Range query on mapped long property
288+
- do:
289+
search:
290+
index: test
291+
body:
292+
query:
293+
range:
294+
labels.status_code:
295+
gte: 500
296+
297+
- match: { hits.total.value: 1 }
298+
- match: { hits.hits.0._id: "2" }
299+
300+
# Term query on unmapped key still works as flattened
301+
- do:
302+
search:
303+
index: test
304+
body:
305+
query:
306+
term:
307+
labels.region: us-east-1
308+
309+
- match: { hits.total.value: 1 }
310+
- match: { hits.hits.0._id: "1" }
311+
312+
# Sort on mapped keyword property
313+
- do:
314+
search:
315+
index: test
316+
body:
317+
sort: [ { labels.host.name: asc } ]
318+
319+
- match: { hits.hits.0._id: "1" }
320+
- match: { hits.hits.1._id: "2" }
321+
322+
# Sort on mapped long property
323+
- do:
324+
search:
325+
index: test
326+
body:
327+
sort: [ { labels.status_code: desc } ]
328+
329+
- match: { hits.hits.0._id: "2" }
330+
- match: { hits.hits.1._id: "1" }
331+
332+
---
333+
"Test flattened mapped subfields with aggregations":
334+
- requires:
335+
cluster_features: ["mapper.flattened.mapped_subfields"]
336+
reason: "mapped subfields on flattened fields"
337+
- do:
338+
indices.create:
339+
index: test
340+
body:
341+
mappings:
342+
properties:
343+
labels:
344+
type: flattened
345+
properties:
346+
env:
347+
type: keyword
348+
priority:
349+
type: long
350+
351+
- do:
352+
bulk:
353+
index: test
354+
refresh: true
355+
body:
356+
- '{ "index": { "_id": "1" } }'
357+
- '{ "labels": { "env": "prod", "priority": 1 } }'
358+
- '{ "index": { "_id": "2" } }'
359+
- '{ "labels": { "env": "prod", "priority": 3 } }'
360+
- '{ "index": { "_id": "3" } }'
361+
- '{ "labels": { "env": "staging", "priority": 2 } }'
362+
363+
- match: { errors: false }
364+
365+
# Terms aggregation on mapped keyword
366+
- do:
367+
search:
368+
index: test
369+
body:
370+
size: 0
371+
aggs:
372+
envs:
373+
terms:
374+
field: labels.env
375+
376+
- length: { aggregations.envs.buckets: 2 }
377+
- match: { aggregations.envs.buckets.0.key: prod }
378+
- match: { aggregations.envs.buckets.0.doc_count: 2 }
379+
- match: { aggregations.envs.buckets.1.key: staging }
380+
- match: { aggregations.envs.buckets.1.doc_count: 1 }
381+
382+
# Stats aggregation on mapped long
383+
- do:
384+
search:
385+
index: test
386+
body:
387+
size: 0
388+
aggs:
389+
priority_stats:
390+
stats:
391+
field: labels.priority
392+
393+
- match: { aggregations.priority_stats.count: 3 }
394+
- match: { aggregations.priority_stats.min: 1.0 }
395+
- match: { aggregations.priority_stats.max: 3.0 }
396+
397+
---
398+
"Test flattened mapped subfields with synthetic source":
399+
- requires:
400+
cluster_features: ["mapper.flattened.mapped_subfields"]
401+
reason: "mapped subfields on flattened fields"
402+
- do:
403+
indices.create:
404+
index: test
405+
body:
406+
settings:
407+
index:
408+
mapping.source.mode: synthetic
409+
mappings:
410+
properties:
411+
labels:
412+
type: flattened
413+
properties:
414+
host.name:
415+
type: keyword
416+
status_code:
417+
type: long
418+
419+
- do:
420+
index:
421+
index: test
422+
id: "1"
423+
body:
424+
labels:
425+
host:
426+
name: server-a
427+
status_code: 200
428+
region: us-east-1
429+
refresh: true
430+
431+
- do:
432+
search:
433+
index: test
434+
435+
- match: { hits.hits.0._source.labels.region: us-east-1 }
436+
- match: { hits.hits.0._source.labels.host\.name: server-a }
437+
- match: { hits.hits.0._source.labels.status_code: 200 }
438+
439+
---
440+
"Test flattened mapped subfields serialization in mapping":
441+
- requires:
442+
cluster_features: ["mapper.flattened.mapped_subfields"]
443+
reason: "mapped subfields on flattened fields"
444+
- do:
445+
indices.create:
446+
index: test
447+
body:
448+
mappings:
449+
properties:
450+
labels:
451+
type: flattened
452+
properties:
453+
host.name:
454+
type: keyword
455+
count:
456+
type: long
457+
458+
- do:
459+
indices.get_mapping:
460+
index: test
461+
462+
- match: { test.mappings.properties.labels.type: flattened }
463+
- match: { test.mappings.properties.labels.properties.host\.name.type: keyword }
464+
- match: { test.mappings.properties.labels.properties.count.type: long }
465+
466+
---
467+
"Test flattened mapped sub-field as index sort field":
468+
- requires:
469+
cluster_features: ["mapper.flattened.mapped_subfields"]
470+
reason: "mapped subfields on flattened fields"
471+
- do:
472+
indices.create:
473+
index: test
474+
body:
475+
settings:
476+
index:
477+
sort.field: labels.priority
478+
sort.order: asc
479+
mappings:
480+
properties:
481+
labels:
482+
type: flattened
483+
properties:
484+
priority:
485+
type: long
486+
487+
- do:
488+
bulk:
489+
index: test
490+
refresh: true
491+
body:
492+
- '{ "index": { "_id": "1" } }'
493+
- '{ "labels": { "priority": 3, "name": "low" } }'
494+
- '{ "index": { "_id": "2" } }'
495+
- '{ "labels": { "priority": 1, "name": "high" } }'
496+
- '{ "index": { "_id": "3" } }'
497+
- '{ "labels": { "priority": 2, "name": "medium" } }'
498+
- match: { errors: false }
499+
500+
# force merge to guarantee sort order
501+
- do:
502+
indices.forcemerge:
503+
index: test
504+
max_num_segments: 1
505+
- do:
506+
indices.refresh:
507+
index: test
508+
- do:
509+
search:
510+
index: test
511+
body:
512+
sort: _doc
513+
514+
- match: { hits.hits.0._id: "2" }
515+
- match: { hits.hits.1._id: "3" }
516+
- match: { hits.hits.2._id: "1" }

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import java.util.Set;
1616

17+
import static org.elasticsearch.index.mapper.flattened.FlattenedFieldMapper.FLATTENED_MAPPED_SUBFIELDS_FEATURE;
1718
import static org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper.RESCORE_VECTOR_QUANTIZED_VECTOR_MAPPING;
1819
import static org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper.RESCORE_ZERO_VECTOR_QUANTIZED_VECTOR_MAPPING;
1920
import static org.elasticsearch.index.mapper.vectors.DenseVectorFieldMapper.USE_DEFAULT_OVERSAMPLE_VALUE_FOR_BBQ;
@@ -139,7 +140,8 @@ public Set<NodeFeature> getTestFeatures() {
139140
TEXT_FIELD_DOC_VALUES,
140141
DENSE_VECTOR_DYNAMIC_TEMPLATE_DOTTED_FIELD_FIX,
141142
DOC_VALUES_MULTI_VALUE,
142-
DENSE_VECTOR_DYNAMIC_TEMPLATE_NESTED_OBJECT_FIX
143+
DENSE_VECTOR_DYNAMIC_TEMPLATE_NESTED_OBJECT_FIX,
144+
FLATTENED_MAPPED_SUBFIELDS_FEATURE
143145
);
144146
}
145147
}

0 commit comments

Comments
 (0)