Skip to content

Support fetching flattened subfields #70916

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 15, 2021
Merged
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
70 changes: 70 additions & 0 deletions docs/reference/mapping/types/flattened.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,76 @@ lexicographically.
Flattened object fields currently cannot be stored. It is not possible to
specify the <<mapping-store, `store`>> parameter in the mapping.

[[search-fields-flattened]]
==== Retrieving flattened fields

Field values and concrete subfields can be retrieved using the
<<search-fields-param,fields parameter>>. content. Since the `flattened` field maps an
entire object with potentially many subfields as a single field, the response contains
the unaltered structure from `_source`.

Single subfields, however, can be fetched by specifying them explicitly in the request.
This only works for concrete paths, but not using wildcards:

[source,console]
--------------------------------------------------
PUT my-index-000001
{
"mappings": {
"properties": {
"flattened_field": {
"type": "flattened"
}
}
}
}

PUT my-index-000001/_doc/1?refresh=true
{
"flattened_field" : {
"subfield" : "value"
}
}

POST my-index-000001/_search
{
"fields": ["flattened_field.subfield"],
"_source": false
}
--------------------------------------------------

[source,console-result]
----
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 1.0,
"hits": [{
"_index": "my-index-000001",
"_id": "1",
"_score": 1.0,
"fields": {
"flattened_field.subfield" : [ "value" ]
}
}]
}
}
----
// TESTRESPONSE[s/"took": 2/"took": $body.took/]
// TESTRESPONSE[s/"max_score" : 1.0/"max_score" : $body.hits.max_score/]
// TESTRESPONSE[s/"_score" : 1.0/"_score" : $body.hits.hits.0._score/]

[[flattened-params]]
==== Parameters for flattened object fields

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,60 @@ setup:
- length: { hits.hits: 1 }
- length: { hits.hits.0.fields: 1 }
- match: { hits.hits.0.fields.flattened: [ { "some_field": "some_value" } ] }

---
"Test fetching flattened subfields via fields option":
- do:
indices.create:
index: test
body:
mappings:
properties:
flattened:
type: flattened

- do:
index:
index: test
id: 1
body:
flattened:
some_field: some_value
some_fields:
- value1
- value2
refresh: true

- do:
search:
index: test
body:
fields: [ { "field" : "flattened.some_field" } ]

- length: { hits.hits.0.fields: 1 }
- match: { hits.hits.0.fields.flattened\.some_field: [ "some_value" ] }

- do:
search:
index: test
body:
fields: [ { "field" : "flattened.some_fields" } ]

- length: { hits.hits.0.fields: 1 }
- match: { hits.hits.0.fields.flattened\.some_fields: [ "value1", "value2" ] }

- do:
search:
index: test
body:
fields: [ { "field" : "flattened.some*" } ]

- is_false: hits.hits.0.fields

- do:
search:
index: test
body:
fields: [ { "field" : "flattened.non_existing_field" } ]

- is_false: hits.hits.0.fields
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ Set<String> sourcePaths(String field) {
if (fullNameToFieldType.isEmpty()) {
return Set.of();
}
if (dynamicKeyLookup.get(field) != null) {
return Set.of(field);
}

String resolvedField = field;
int lastDotIndex = field.lastIndexOf('.');
if (lastDotIndex > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,17 +165,19 @@ public FlattenedFieldMapper build(ContentPath contentPath) {
*/
public static final class KeyedFlattenedFieldType extends StringFieldType {
private final String key;
private final String rootName;

public KeyedFlattenedFieldType(String name, boolean indexed, boolean hasDocValues, String key,
KeyedFlattenedFieldType(String rootName, boolean indexed, boolean hasDocValues, String key,
boolean splitQueriesOnWhitespace, Map<String, String> meta) {
super(name, indexed, false, hasDocValues,
super(rootName + KEYED_FIELD_SUFFIX, indexed, false, hasDocValues,
splitQueriesOnWhitespace ? TextSearchInfo.WHITESPACE_MATCH_ONLY : TextSearchInfo.SIMPLE_MATCH_ONLY,
meta);
this.key = key;
this.rootName = rootName;
}

private KeyedFlattenedFieldType(String name, String key, RootFlattenedFieldType ref) {
this(name, ref.isSearchable(), ref.hasDocValues(), key, ref.splitQueriesOnWhitespace, ref.meta());
private KeyedFlattenedFieldType(String rootName, String key, RootFlattenedFieldType ref) {
this(rootName, ref.isSearchable(), ref.hasDocValues(), key, ref.splitQueriesOnWhitespace, ref.meta());
}

@Override
Expand Down Expand Up @@ -261,8 +263,11 @@ public IndexFieldData.Builder fielddataBuilder(String fullyQualifiedIndexName, S

@Override
public ValueFetcher valueFetcher(SearchExecutionContext context, String format) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized we should throw an error if the format is non-null, as we do for most other field types. (This is unfortunately very manual right now and easy to forget...)

// This is an internal field but it can match a field pattern so we return an empty list.
return lookup -> List.of();
if (format != null) {
throw new IllegalArgumentException("Field [" + rootName + "." + key + "] of type [" + typeName() +
"] doesn't support formats.");
}
return SourceValueFetcher.identity(rootName + "." + key, context, format);
}
}

Expand Down Expand Up @@ -426,7 +431,7 @@ private FlattenedFieldMapper(String simpleName,
Builder builder) {
super(simpleName, mappedFieldType, Lucene.KEYWORD_ANALYZER, CopyTo.empty());
this.builder = builder;
this.fieldParser = new FlattenedFieldParser(mappedFieldType.name(), keyedFieldName(),
this.fieldParser = new FlattenedFieldParser(mappedFieldType.name(), mappedFieldType.name() + KEYED_FIELD_SUFFIX,
mappedFieldType, builder.depthLimit.get(), builder.ignoreAbove.get(), builder.nullValue.get());
}

Expand All @@ -450,11 +455,7 @@ public RootFlattenedFieldType fieldType() {

@Override
public KeyedFlattenedFieldType keyedFieldType(String key) {
return new KeyedFlattenedFieldType(keyedFieldName(), key, fieldType());
}

public String keyedFieldName() {
return mappedFieldType.name() + KEYED_FIELD_SUFFIX;
return new KeyedFlattenedFieldType(name(), key, fieldType());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ public void testFlattenedLookup() {
String searchFieldName = fieldName + "." + objectKey;

MappedFieldType searchFieldType = lookup.get(searchFieldName);
assertEquals(mapper.keyedFieldName(), searchFieldType.name());
assertEquals(mapper.keyedFieldType(objectKey).name(), searchFieldType.name());
assertThat(searchFieldType, Matchers.instanceOf(FlattenedFieldMapper.KeyedFlattenedFieldType.class));

FlattenedFieldMapper.KeyedFlattenedFieldType keyedFieldType = (FlattenedFieldMapper.KeyedFlattenedFieldType) searchFieldType;
Expand All @@ -210,7 +210,7 @@ public void testFlattenedLookupWithAlias() {
String searchFieldName = aliasName + "." + objectKey;

MappedFieldType searchFieldType = lookup.get(searchFieldName);
assertEquals(mapper.keyedFieldName(), searchFieldType.name());
assertEquals(mapper.keyedFieldType(objectKey).name(), searchFieldType.name());
assertThat(searchFieldType, Matchers.instanceOf(FlattenedFieldMapper.KeyedFlattenedFieldType.class));

FlattenedFieldMapper.KeyedFlattenedFieldType keyedFieldType = (FlattenedFieldMapper.KeyedFlattenedFieldType) searchFieldType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ public void testGlobalFieldDataCaching() throws IOException {
indexService.mapperService());

FlattenedFieldMapper fieldMapper = new FlattenedFieldMapper.Builder("json").build(new ContentPath(1));
KeyedFlattenedFieldType fieldType1 = fieldMapper.keyedFieldType("key");

AtomicInteger onCacheCalled = new AtomicInteger();
ifdService.setListener(new IndexFieldDataCache.Listener() {
@Override
public void onCache(ShardId shardId, String fieldName, Accountable ramUsage) {
assertEquals(fieldMapper.keyedFieldName(), fieldName);
assertEquals(fieldType1.name(), fieldName);
onCacheCalled.incrementAndGet();
}
});
Expand All @@ -71,7 +72,6 @@ public void onCache(ShardId shardId, String fieldName, Accountable ramUsage) {
new ShardId("test", "_na_", 1));

// Load global field data for subfield 'key'.
KeyedFlattenedFieldType fieldType1 = fieldMapper.keyedFieldType("key");
IndexFieldData<?> ifd1 = ifdService.getForField(fieldType1, "test", () -> {
throw new UnsupportedOperationException("search lookup not available");
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@
import org.elasticsearch.common.lucene.search.AutomatonQueries;
import org.elasticsearch.common.unit.Fuzziness;
import org.elasticsearch.index.mapper.FieldTypeTestCase;
import org.elasticsearch.index.mapper.ValueFetcher;
import org.elasticsearch.index.mapper.flattened.FlattenedFieldMapper.KeyedFlattenedFieldType;
import org.elasticsearch.index.query.SearchExecutionContext;
import org.elasticsearch.search.lookup.SourceLookup;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class KeyedFlattenedFieldTypeTests extends FieldTypeTestCase {

Expand All @@ -50,23 +57,22 @@ public void testIndexedValueForSearch() {
public void testTermQuery() {
KeyedFlattenedFieldType ft = createFieldType();

Query expected = new TermQuery(new Term("field", "key\0value"));
Query expected = new TermQuery(new Term(ft.name(), "key\0value"));
assertEquals(expected, ft.termQuery("value", null));

expected = AutomatonQueries.caseInsensitiveTermQuery(new Term("field", "key\0value"));
expected = AutomatonQueries.caseInsensitiveTermQuery(new Term(ft.name(), "key\0value"));
assertEquals(expected, ft.termQueryCaseInsensitive("value", null));

KeyedFlattenedFieldType unsearchable = new KeyedFlattenedFieldType("field", false, true, "key",
false, Collections.emptyMap());
KeyedFlattenedFieldType unsearchable = new KeyedFlattenedFieldType("field", false, true, "key", false, Collections.emptyMap());
IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
() -> unsearchable.termQuery("field", null));
assertEquals("Cannot search on field [field] since it is not indexed.", e.getMessage());
assertEquals("Cannot search on field [" + ft.name() + "] since it is not indexed.", e.getMessage());
}

public void testTermsQuery() {
KeyedFlattenedFieldType ft = createFieldType();

Query expected = new TermInSetQuery("field",
Query expected = new TermInSetQuery(ft.name(),
new BytesRef("key\0value1"),
new BytesRef("key\0value2"));

Expand All @@ -81,17 +87,17 @@ public void testTermsQuery() {
public void testExistsQuery() {
KeyedFlattenedFieldType ft = createFieldType();

Query expected = new PrefixQuery(new Term("field", "key\0"));
Query expected = new PrefixQuery(new Term(ft.name(), "key\0"));
assertEquals(expected, ft.existsQuery(null));
}

public void testPrefixQuery() {
KeyedFlattenedFieldType ft = createFieldType();

Query expected = new PrefixQuery(new Term("field", "key\0val"));
Query expected = new PrefixQuery(new Term(ft.name(), "key\0val"));
assertEquals(expected, ft.prefixQuery("val", MultiTermQuery.CONSTANT_SCORE_REWRITE, false, MOCK_CONTEXT));

expected = AutomatonQueries.caseInsensitivePrefixQuery(new Term("field", "key\0vAl"));
expected = AutomatonQueries.caseInsensitivePrefixQuery(new Term(ft.name(), "key\0vAl"));
assertEquals(expected, ft.prefixQuery("vAl", MultiTermQuery.CONSTANT_SCORE_REWRITE, true, MOCK_CONTEXT));

ElasticsearchException ee = expectThrows(ElasticsearchException.class,
Expand All @@ -111,12 +117,12 @@ public void testFuzzyQuery() {
public void testRangeQuery() {
KeyedFlattenedFieldType ft = createFieldType();

TermRangeQuery expected = new TermRangeQuery("field",
TermRangeQuery expected = new TermRangeQuery(ft.name(),
new BytesRef("key\0lower"),
new BytesRef("key\0upper"), false, false);
assertEquals(expected, ft.rangeQuery("lower", "upper", false, false, MOCK_CONTEXT));

expected = new TermRangeQuery("field",
expected = new TermRangeQuery(ft.name(),
new BytesRef("key\0lower"),
new BytesRef("key\0upper"), true, true);
assertEquals(expected, ft.rangeQuery("lower", "upper", true, true, MOCK_CONTEXT));
Expand Down Expand Up @@ -160,4 +166,23 @@ public void testFetchIsEmpty() throws IOException {
assertEquals(List.of(), fetchSourceValue(ft, sourceValue));
assertEquals(List.of(), fetchSourceValue(ft, null));
}

public void testFetchSourceValue() throws IOException {
KeyedFlattenedFieldType ft = createFieldType();
Map<String, Object> sourceValue = Map.of("key", "value");

SearchExecutionContext searchExecutionContext = mock(SearchExecutionContext.class);
when(searchExecutionContext.sourcePath("field.key")).thenReturn(Set.of("field.key"));

ValueFetcher fetcher = ft.valueFetcher(searchExecutionContext, null);
SourceLookup lookup = new SourceLookup();
lookup.setSource(Collections.singletonMap("field", sourceValue));

assertEquals(List.of("value"), fetcher.fetchValues(lookup));
lookup.setSource(Collections.singletonMap("field", null));
assertEquals(List.of(), fetcher.fetchValues(lookup));

IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> ft.valueFetcher(searchExecutionContext, "format"));
assertEquals("Field [field.key] of type [flattened] doesn't support formats.", e.getMessage());
}
}
Loading