diff --git a/docs/changelog/127549.yaml b/docs/changelog/127549.yaml new file mode 100644 index 0000000000000..5f24111d22689 --- /dev/null +++ b/docs/changelog/127549.yaml @@ -0,0 +1,5 @@ +pr: 127549 +summary: Add local optimizations for `constant_keyword` +area: ES|QL +type: enhancement +issues: [] diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LocalLogicalPlanOptimizer.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LocalLogicalPlanOptimizer.java index 188ea200c89fe..0c2ecdcb71fe7 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LocalLogicalPlanOptimizer.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LocalLogicalPlanOptimizer.java @@ -12,7 +12,7 @@ import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.InferIsNotNull; import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.InferNonNullAggConstraint; import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.LocalPropagateEmptyRelation; -import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.ReplaceMissingFieldWithNull; +import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.ReplaceFieldWithConstantOrNull; import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.ReplaceTopNWithLimitAndSort; import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; import org.elasticsearch.xpack.esql.rule.ParameterizedRuleExecutor; @@ -39,7 +39,7 @@ public class LocalLogicalPlanOptimizer extends ParameterizedRuleExecutor { +public class ReplaceFieldWithConstantOrNull extends ParameterizedRule { @Override public LogicalPlan apply(LogicalPlan plan, LocalLogicalOptimizerContext localLogicalOptimizerContext) { var lookupFieldsBuilder = AttributeSet.builder(); + Map attrToConstant = new HashMap<>(); plan.forEachUp(EsRelation.class, esRelation -> { - // Looking only for indices in LOOKUP mode is correct: during parsing, we assign the expected mode and even if a lookup index + // Looking for indices in LOOKUP mode is correct: during parsing, we assign the expected mode and even if a lookup index // is used in the FROM command, it will not be marked with LOOKUP mode there - but STANDARD. // It seems like we could instead just look for JOINs and walk down their right hand side to find lookup fields - but this does // not work as this rule also gets called just on the right hand side of a JOIN, which means that we don't always know that @@ -52,6 +55,18 @@ public LogicalPlan apply(LogicalPlan plan, LocalLogicalOptimizerContext localLog if (esRelation.indexMode() == IndexMode.LOOKUP) { lookupFieldsBuilder.addAll(esRelation.output()); } + // find constant values only in the main indices + else if (esRelation.indexMode() == IndexMode.STANDARD) { + for (Attribute attribute : esRelation.output()) { + if (attribute instanceof FieldAttribute fa) { + // Do not use the attribute name, this can deviate from the field name for union types; use fieldName() instead. + var val = localLogicalOptimizerContext.searchStats().constantValue(fa.fieldName()); + if (val != null) { + attrToConstant.put(attribute, Literal.of(attribute, val)); + } + } + } + } }); AttributeSet lookupFields = lookupFieldsBuilder.build(); @@ -61,10 +76,14 @@ public LogicalPlan apply(LogicalPlan plan, LocalLogicalOptimizerContext localLog || localLogicalOptimizerContext.searchStats().exists(f.fieldName()) || lookupFields.contains(f); - return plan.transformUp(p -> missingToNull(p, shouldBeRetained)); + return plan.transformUp(p -> replaceWithNullOrConstant(p, shouldBeRetained, attrToConstant)); } - private LogicalPlan missingToNull(LogicalPlan plan, Predicate shouldBeRetained) { + private LogicalPlan replaceWithNullOrConstant( + LogicalPlan plan, + Predicate shouldBeRetained, + Map attrToConstant + ) { if (plan instanceof EsRelation relation) { // For any missing field, place an Eval right after the EsRelation to assign null values to that attribute (using the same name // id!), thus avoiding that InsertFieldExtrations inserts a field extraction later. @@ -118,7 +137,13 @@ private LogicalPlan missingToNull(LogicalPlan plan, Predicate sh || plan instanceof OrderBy || plan instanceof RegexExtract || plan instanceof TopN) { - return plan.transformExpressionsOnlyUp(FieldAttribute.class, f -> shouldBeRetained.test(f) ? f : Literal.of(f, null)); + return plan.transformExpressionsOnlyUp(FieldAttribute.class, f -> { + if (attrToConstant.containsKey(f)) {// handle constant values field and use the value itself instead + return attrToConstant.get(f); + } else {// handle missing fields and replace them with null + return shouldBeRetained.test(f) ? f : Literal.of(f, null); + } + }); } return plan; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchContextStats.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchContextStats.java index 57ad2d0275a88..8cb10d14bb578 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchContextStats.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchContextStats.java @@ -310,6 +310,41 @@ public boolean canUseEqualityOnSyntheticSourceDelegate(String name, String value return true; } + public String constantValue(String name) { + String val = null; + for (SearchExecutionContext ctx : contexts) { + MappedFieldType f = ctx.getFieldType(name); + if (f == null) { + return null; + } + if (f instanceof ConstantFieldType cf) { + var fetcher = cf.valueFetcher(ctx, null); + String thisVal = null; + try { + // since the value is a constant, the doc _should_ be irrelevant + List vals = fetcher.fetchValues(null, -1, null); + Object objVal = vals.size() == 1 ? vals.get(0) : null; + // we are considering only string values for now, since this can return "strange" things, + // see IndexModeFieldType + thisVal = objVal instanceof String ? (String) objVal : null; + } catch (IOException iox) {} + + if (thisVal == null) { + // Value not yet set + return null; + } + if (val == null) { + val = thisVal; + } else if (thisVal.equals(val) == false) { + return null; + } + } else { + return null; + } + } + return val; + } + private interface DocCountTester { Boolean test(LeafReader leafReader) throws IOException; } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchStats.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchStats.java index e00de98178832..748ed826836e7 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchStats.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchStats.java @@ -39,6 +39,14 @@ public interface SearchStats { boolean canUseEqualityOnSyntheticSourceDelegate(String name, String value); + /** + * Returns the value for a field if it's a constant (eg. a constant_keyword with only one value for the involved indices). + * NULL if the field is not a constant. + */ + default String constantValue(String name) { + return null; + } + /** * When there are no search stats available, for example when there are no search contexts, we have static results. */ diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java index 0cc8c670895e9..0c2972d6d4c3e 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java @@ -61,6 +61,7 @@ import org.elasticsearch.xpack.esql.plan.logical.Enrich; import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; import org.elasticsearch.xpack.esql.plan.physical.AggregateExec; +import org.elasticsearch.xpack.esql.plan.physical.DissectExec; import org.elasticsearch.xpack.esql.plan.physical.EsQueryExec; import org.elasticsearch.xpack.esql.plan.physical.EsStatsQueryExec; import org.elasticsearch.xpack.esql.plan.physical.EsStatsQueryExec.Stat; @@ -71,6 +72,7 @@ import org.elasticsearch.xpack.esql.plan.physical.FilterExec; import org.elasticsearch.xpack.esql.plan.physical.LimitExec; import org.elasticsearch.xpack.esql.plan.physical.LocalSourceExec; +import org.elasticsearch.xpack.esql.plan.physical.MvExpandExec; import org.elasticsearch.xpack.esql.plan.physical.PhysicalPlan; import org.elasticsearch.xpack.esql.plan.physical.ProjectExec; import org.elasticsearch.xpack.esql.plan.physical.TimeSeriesAggregateExec; @@ -152,6 +154,18 @@ public boolean isSingleValue(String field) { } }; + private final SearchStats CONSTANT_K_STATS = new TestSearchStats() { + @Override + public boolean isSingleValue(String field) { + return true; + } + + @Override + public String constantValue(String name) { + return name.startsWith("constant_keyword") ? "foo" : null; + } + }; + @ParametersFactory(argumentFormatting = PARAM_FORMATTING) public static List readScriptSpec() { return settings().stream().map(t -> { @@ -1858,6 +1872,113 @@ public void testPushDownFieldExtractToTimeSeriesSource() { assertTrue(timeSeriesSource.attrs().stream().noneMatch(EsQueryExec::isSourceAttribute)); } + /** + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[!alias_integer, boolean{f}#415, byte{f}#416, constant_keyword-foo{f}#417, date{f}#418, date_nanos{f}#419, + * double{f}#420, float{f}#421, half_float{f}#422, integer{f}#424, ip{f}#425, keyword{f}#426, long{f}#427, scaled_float{f}#423, + * !semantic_text, short{f}#429, text{f}#430, unsigned_long{f}#428, version{f}#431, wildcard{f}#432], false] + * \_ProjectExec[[!alias_integer, boolean{f}#415, byte{f}#416, constant_keyword-foo{f}#417, date{f}#418, date_nanos{f}#419, + * double{f}#420, float{f}#421, half_float{f}#422, integer{f}#424, ip{f}#425, keyword{f}#426, long{f}#427, scaled_float{f}#423, + * !semantic_text, short{f}#429, text{f}#430, unsigned_long{f}#428, version{f}#431, wildcard{f}#432]] + * \_FieldExtractExec[!alias_integer, boolean{f}#415, byte{f}#416, consta..] + * \_EsQueryExec[test], indexMode[standard], query[][_doc{f}#434], limit[1000], sort[] estimatedRowSize[412] + */ + public void testConstantKeywordWithMatchingFilter() { + String queryText = """ + from test + | where `constant_keyword-foo` == "foo" + """; + var analyzer = makeAnalyzer("mapping-all-types.json"); + var plan = plannerOptimizer.plan(queryText, CONSTANT_K_STATS, analyzer); + + var limit = as(plan, LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var project = as(exchange.child(), ProjectExec.class); + var field = as(project.child(), FieldExtractExec.class); + var query = as(field.child(), EsQueryExec.class); + assertThat(as(query.limit(), Literal.class).value(), is(1000)); + assertNull(query.query()); + } + + /** + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[!alias_integer, boolean{f}#4, byte{f}#5, constant_keyword-foo{f}#6, date{f}#7, date_nanos{f}#8, double{f}#9, + * float{f}#10, half_float{f}#11, integer{f}#13, ip{f}#14, keyword{f}#15, long{f}#16, scaled_float{f}#12, !semantic_text, + * short{f}#18, text{f}#19, unsigned_long{f}#17, version{f}#20, wildcard{f}#21], false] + * \_LocalSourceExec[[!alias_integer, boolean{f}#4, byte{f}#5, constant_keyword-foo{f}#6, date{f}#7, date_nanos{f}#8, double{f}#9, + * float{f}#10, half_float{f}#11, integer{f}#13, ip{f}#14, keyword{f}#15, long{f}#16, scaled_float{f}#12, !semantic_text, + * short{f}#18, text{f}#19, unsigned_long{f}#17, version{f}#20, wildcard{f}#21], EMPTY] + */ + public void testConstantKeywordWithNonMatchingFilter() { + String queryText = """ + from test + | where `constant_keyword-foo` == "non-matching" + """; + var analyzer = makeAnalyzer("mapping-all-types.json"); + var plan = plannerOptimizer.plan(queryText, CONSTANT_K_STATS, analyzer); + + var limit = as(plan, LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var source = as(exchange.child(), LocalSourceExec.class); + } + + /** + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[!alias_integer, boolean{f}#6, byte{f}#7, constant_keyword-foo{r}#25, date{f}#9, date_nanos{f}#10, double{f}#1... + * \_ProjectExec[[!alias_integer, boolean{f}#6, byte{f}#7, constant_keyword-foo{r}#25, date{f}#9, date_nanos{f}#10, double{f}#1... + * \_FieldExtractExec[!alias_integer, boolean{f}#6, byte{f}#7, date{f}#9, + * \_LimitExec[1000[INTEGER]] + * \_FilterExec[constant_keyword-foo{r}#25 == [66 6f 6f][KEYWORD]] + * \_MvExpandExec[constant_keyword-foo{f}#8,constant_keyword-foo{r}#25] + * \_FieldExtractExec[constant_keyword-foo{f}#8] + * \_EsQueryExec[test], indexMode[standard], query[][_doc{f}#26], limit[], sort[] estimatedRowSize[412] + */ + public void testConstantKeywordExpandFilter() { + String queryText = """ + from test + | mv_expand `constant_keyword-foo` + | where `constant_keyword-foo` == "foo" + """; + var analyzer = makeAnalyzer("mapping-all-types.json"); + var plan = plannerOptimizer.plan(queryText, CONSTANT_K_STATS, analyzer); + + var limit = as(plan, LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var project = as(exchange.child(), ProjectExec.class); + var fieldExtract = as(project.child(), FieldExtractExec.class); + var limit2 = as(fieldExtract.child(), LimitExec.class); + var filter = as(limit2.child(), FilterExec.class); + var expand = as(filter.child(), MvExpandExec.class); + var field = as(expand.child(), FieldExtractExec.class); // MV_EXPAND is not optimized yet (it doesn't accept literals) + as(field.child(), EsQueryExec.class); + } + + /** + * DissectExec[constant_keyword-foo{f}#8,Parser[pattern=%{bar}, appendSeparator=, ... + * \_LimitExec[1000[INTEGER]] + * \_ExchangeExec[[!alias_integer, boolean{f}#6, byte{f}#7, constant_keyword-foo{f}#8, date{f}#9, date_nanos{f}#10, double{f}#11... + * \_ProjectExec[[!alias_integer, boolean{f}#6, byte{f}#7, constant_keyword-foo{f}#8, date{f}#9, date_nanos{f}#10, double{f}#11... + * \_FieldExtractExec[!alias_integer, boolean{f}#6, byte{f}#7, constant_k..] + * \_EsQueryExec[test], indexMode[standard], query[][_doc{f}#25], limit[1000], sort[] estimatedRowSize[462] + */ + public void testConstantKeywordDissectFilter() { + String queryText = """ + from test + | dissect `constant_keyword-foo` "%{bar}" + | where `constant_keyword-foo` == "foo" + """; + var analyzer = makeAnalyzer("mapping-all-types.json"); + var plan = plannerOptimizer.plan(queryText, CONSTANT_K_STATS, analyzer); + + var dissect = as(plan, DissectExec.class); + var limit = as(dissect.child(), LimitExec.class); + var exchange = as(limit.child(), ExchangeExec.class); + var project = as(exchange.child(), ProjectExec.class); + var field = as(project.child(), FieldExtractExec.class); + var query = as(field.child(), EsQueryExec.class); + assertNull(query.query()); + } + public void testMatchFunctionWithStatsWherePushable() { String query = """ from test diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/30_types.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/30_types.yml index 1f9ff72669309..c00a6bc4e320c 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/30_types.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/30_types.yml @@ -29,6 +29,66 @@ constant_keyword: - { "index": { } } - { "color": "red" } + - do: + indices.create: + index: test_2 + body: + mappings: + properties: + kind: + type: constant_keyword + value: a different constant + color: + type: keyword + + - do: + bulk: + index: test_2 + refresh: true + body: + - { "index": { } } + - { "color": "blue" } + + - do: + indices.create: + index: test_3 + body: + mappings: + properties: + kind: + type: keyword + color: + type: keyword + + - do: + bulk: + index: test_3 + refresh: true + body: + - { "index": { } } + - { "kind":"not a constant", "color": "pink" } + - { "index": { } } + - { "kind": "still no constant", "color": "pink" } + + - do: + indices.create: + index: text_test + body: + mappings: + properties: + kind: + type: text + color: + type: keyword + + - do: + bulk: + index: text_test + refresh: true + body: + - { "index": { } } + - { "kind":"a text field", "color": "green" } + - do: allowed_warnings_regex: - "No limit defined, adding default limit of \\[.*\\]" @@ -54,6 +114,211 @@ constant_keyword: - length: {values: 1} - match: {values.0.0: 17} + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test | where kind == "wow such constant" | keep color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 1} + - match: {values.0.0: red} + - match: {values.0.1: "wow such constant"} + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test | dissect kind "%{one} %{two} %{three}" | keep one, two, three, kind' + - match: {columns.0.name: one} + - match: {columns.0.type: keyword} + - match: {columns.1.name: two } + - match: {columns.1.type: keyword } + - match: {columns.2.name: three } + - match: {columns.2.type: keyword } + - match: {columns.3.name: kind } + - match: {columns.3.type: keyword } + + - length: {values: 1} + - match: {values.0.0: wow} + - match: {values.0.1: such} + - match: {values.0.2: constant} + - match: {values.0.3: wow such constant} + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test | stats x = max(kind)' + - match: {columns.0.name: x} + - match: {columns.0.type: keyword} + + - length: {values: 1} + - match: {values.0.0: wow such constant} + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test,test_2 | where kind == "wow such constant" | keep color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 1} + - match: {values.0.0: red} + - match: {values.0.1: "wow such constant"} + + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test* | where kind == "wow such constant" | keep color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 1} + - match: {values.0.0: red} + - match: {values.0.1: "wow such constant"} + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test,test_2 | where kind == "wow such constant" | keep color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 1} + - match: {values.0.0: red} + - match: {values.0.1: "wow such constant"} + + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test* | where kind == "wow such constant" | keep color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 1} + - match: {values.0.0: red} + - match: {values.0.1: "wow such constant"} + + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test* | where kind == "not a constant" | keep color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 1} + - match: {values.0.0: pink} + - match: {values.0.1: "not a constant"} + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test* | where kind >= "a" | keep color, kind | sort color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 4} + - match: {values.0.0: blue} + - match: {values.0.1: "a different constant"} + - match: {values.1.0: pink} + - match: {values.1.1: "not a constant"} + - match: {values.2.0: pink} + - match: {values.2.1: "still no constant"} + - match: {values.3.0: red} + - match: {values.3.1: "wow such constant"} + + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test* | where kind >= "o" | keep color, kind | sort color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 2} + - match: {values.0.0: pink} + - match: {values.0.1: "still no constant"} + - match: {values.1.0: red} + - match: {values.1.1: "wow such constant"} + + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test,text_test | where kind::string == "a text field" | eval kind = kind::string | keep color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 1} + - match: {values.0.0: green} + - match: {values.0.1: "a text field"} + + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test,text_test | where kind::string == "wow such constant" | eval kind = kind::string | keep color, kind' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 1} + - match: {values.0.0: red} + - match: {values.0.1: "wow such constant"} + + + - do: + allowed_warnings_regex: + - "No limit defined, adding default limit of \\[.*\\]" + esql.query: + body: + query: 'from test,text_test | where kind::string >= "a" | eval kind = kind::string | keep color, kind | sort color' + - match: {columns.0.name: color} + - match: {columns.0.type: keyword} + - match: { columns.1.name: kind } + - match: { columns.1.type: keyword } + - length: {values: 2} + - match: {values.0.0: green} + - match: {values.0.1: "a text field"} + - match: {values.1.0: red} + - match: {values.1.1: "wow such constant"} + + --- constant_keyword with null value: - do: