Skip to content

Commit 1c6c1c5

Browse files
authored
Merge branch 'main' into entitlement/test-resolver
2 parents 115069e + 445c3ea commit 1c6c1c5

File tree

8 files changed

+473
-12
lines changed

8 files changed

+473
-12
lines changed

docs/changelog/127549.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 127549
2+
summary: Add local optimizations for `constant_keyword`
3+
area: ES|QL
4+
type: enhancement
5+
issues: []

docs/reference/search-connectors/es-connectors-outlook.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,12 @@ To integrate with the Outlook connector using Azure, follow these steps to creat
116116
4. Click on the **Certificates & secrets** tab and create a new client secret. Keep this secret handy.
117117
5. Go to the **API permissions** tab.
118118

119-
* Click on "Add permissions."
120-
* Choose "APIs my organization uses."
121-
* Search for and select "Office 365 Exchange Online."
122-
* Add the `full_access_as_app` application permission.
119+
1. Click on "Add permissions".
120+
2. Choose "APIs my organization uses".
121+
3. Search for and select "Office 365 Exchange Online".
122+
* Add the `full_access_as_app` application permission.
123+
4. Search for and select "Microsoft Graph"
124+
* Add the `User.Read.All` application permission.
123125

124126

125127
You can now use the Client ID, Tenant ID, and Client Secret you’ve noted to configure the Outlook connector.

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LocalLogicalPlanOptimizer.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.InferIsNotNull;
1313
import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.InferNonNullAggConstraint;
1414
import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.LocalPropagateEmptyRelation;
15-
import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.ReplaceMissingFieldWithNull;
15+
import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.ReplaceFieldWithConstantOrNull;
1616
import org.elasticsearch.xpack.esql.optimizer.rules.logical.local.ReplaceTopNWithLimitAndSort;
1717
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
1818
import org.elasticsearch.xpack.esql.rule.ParameterizedRuleExecutor;
@@ -39,7 +39,7 @@ public class LocalLogicalPlanOptimizer extends ParameterizedRuleExecutor<Logical
3939
"Local rewrite",
4040
Limiter.ONCE,
4141
new ReplaceTopNWithLimitAndSort(),
42-
new ReplaceMissingFieldWithNull(),
42+
new ReplaceFieldWithConstantOrNull(),
4343
new InferIsNotNull(),
4444
new InferNonNullAggConstraint()
4545
),
Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.elasticsearch.xpack.esql.core.expression.Alias;
1313
import org.elasticsearch.xpack.esql.core.expression.Attribute;
1414
import org.elasticsearch.xpack.esql.core.expression.AttributeSet;
15+
import org.elasticsearch.xpack.esql.core.expression.Expression;
1516
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
1617
import org.elasticsearch.xpack.esql.core.expression.Literal;
1718
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
@@ -29,21 +30,23 @@
2930
import org.elasticsearch.xpack.esql.rule.ParameterizedRule;
3031

3132
import java.util.ArrayList;
33+
import java.util.HashMap;
3234
import java.util.List;
3335
import java.util.Map;
3436
import java.util.function.Predicate;
3537

3638
/**
37-
* Look for any fields used in the plan that are missing locally and replace them with null.
39+
* Look for any fields used in the plan that are missing and replaces them with null or look for fields that are constant.
3840
* This should minimize the plan execution, in the best scenario skipping its execution all together.
3941
*/
40-
public class ReplaceMissingFieldWithNull extends ParameterizedRule<LogicalPlan, LogicalPlan, LocalLogicalOptimizerContext> {
42+
public class ReplaceFieldWithConstantOrNull extends ParameterizedRule<LogicalPlan, LogicalPlan, LocalLogicalOptimizerContext> {
4143

4244
@Override
4345
public LogicalPlan apply(LogicalPlan plan, LocalLogicalOptimizerContext localLogicalOptimizerContext) {
4446
var lookupFieldsBuilder = AttributeSet.builder();
47+
Map<Attribute, Expression> attrToConstant = new HashMap<>();
4548
plan.forEachUp(EsRelation.class, esRelation -> {
46-
// Looking only for indices in LOOKUP mode is correct: during parsing, we assign the expected mode and even if a lookup index
49+
// Looking for indices in LOOKUP mode is correct: during parsing, we assign the expected mode and even if a lookup index
4750
// is used in the FROM command, it will not be marked with LOOKUP mode there - but STANDARD.
4851
// It seems like we could instead just look for JOINs and walk down their right hand side to find lookup fields - but this does
4952
// 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
5255
if (esRelation.indexMode() == IndexMode.LOOKUP) {
5356
lookupFieldsBuilder.addAll(esRelation.output());
5457
}
58+
// find constant values only in the main indices
59+
else if (esRelation.indexMode() == IndexMode.STANDARD) {
60+
for (Attribute attribute : esRelation.output()) {
61+
if (attribute instanceof FieldAttribute fa) {
62+
// Do not use the attribute name, this can deviate from the field name for union types; use fieldName() instead.
63+
var val = localLogicalOptimizerContext.searchStats().constantValue(fa.fieldName());
64+
if (val != null) {
65+
attrToConstant.put(attribute, Literal.of(attribute, val));
66+
}
67+
}
68+
}
69+
}
5570
});
5671
AttributeSet lookupFields = lookupFieldsBuilder.build();
5772

@@ -61,10 +76,14 @@ public LogicalPlan apply(LogicalPlan plan, LocalLogicalOptimizerContext localLog
6176
|| localLogicalOptimizerContext.searchStats().exists(f.fieldName())
6277
|| lookupFields.contains(f);
6378

64-
return plan.transformUp(p -> missingToNull(p, shouldBeRetained));
79+
return plan.transformUp(p -> replaceWithNullOrConstant(p, shouldBeRetained, attrToConstant));
6580
}
6681

67-
private LogicalPlan missingToNull(LogicalPlan plan, Predicate<FieldAttribute> shouldBeRetained) {
82+
private LogicalPlan replaceWithNullOrConstant(
83+
LogicalPlan plan,
84+
Predicate<FieldAttribute> shouldBeRetained,
85+
Map<Attribute, Expression> attrToConstant
86+
) {
6887
if (plan instanceof EsRelation relation) {
6988
// For any missing field, place an Eval right after the EsRelation to assign null values to that attribute (using the same name
7089
// id!), thus avoiding that InsertFieldExtrations inserts a field extraction later.
@@ -118,7 +137,13 @@ private LogicalPlan missingToNull(LogicalPlan plan, Predicate<FieldAttribute> sh
118137
|| plan instanceof OrderBy
119138
|| plan instanceof RegexExtract
120139
|| plan instanceof TopN) {
121-
return plan.transformExpressionsOnlyUp(FieldAttribute.class, f -> shouldBeRetained.test(f) ? f : Literal.of(f, null));
140+
return plan.transformExpressionsOnlyUp(FieldAttribute.class, f -> {
141+
if (attrToConstant.containsKey(f)) {// handle constant values field and use the value itself instead
142+
return attrToConstant.get(f);
143+
} else {// handle missing fields and replace them with null
144+
return shouldBeRetained.test(f) ? f : Literal.of(f, null);
145+
}
146+
});
122147
}
123148

124149
return plan;

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchContextStats.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,41 @@ public boolean canUseEqualityOnSyntheticSourceDelegate(String name, String value
310310
return true;
311311
}
312312

313+
public String constantValue(String name) {
314+
String val = null;
315+
for (SearchExecutionContext ctx : contexts) {
316+
MappedFieldType f = ctx.getFieldType(name);
317+
if (f == null) {
318+
return null;
319+
}
320+
if (f instanceof ConstantFieldType cf) {
321+
var fetcher = cf.valueFetcher(ctx, null);
322+
String thisVal = null;
323+
try {
324+
// since the value is a constant, the doc _should_ be irrelevant
325+
List<Object> vals = fetcher.fetchValues(null, -1, null);
326+
Object objVal = vals.size() == 1 ? vals.get(0) : null;
327+
// we are considering only string values for now, since this can return "strange" things,
328+
// see IndexModeFieldType
329+
thisVal = objVal instanceof String ? (String) objVal : null;
330+
} catch (IOException iox) {}
331+
332+
if (thisVal == null) {
333+
// Value not yet set
334+
return null;
335+
}
336+
if (val == null) {
337+
val = thisVal;
338+
} else if (thisVal.equals(val) == false) {
339+
return null;
340+
}
341+
} else {
342+
return null;
343+
}
344+
}
345+
return val;
346+
}
347+
313348
private interface DocCountTester {
314349
Boolean test(LeafReader leafReader) throws IOException;
315350
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/stats/SearchStats.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ public interface SearchStats {
3939

4040
boolean canUseEqualityOnSyntheticSourceDelegate(String name, String value);
4141

42+
/**
43+
* Returns the value for a field if it's a constant (eg. a constant_keyword with only one value for the involved indices).
44+
* NULL if the field is not a constant.
45+
*/
46+
default String constantValue(String name) {
47+
return null;
48+
}
49+
4250
/**
4351
* When there are no search stats available, for example when there are no search contexts, we have static results.
4452
*/

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
import org.elasticsearch.xpack.esql.plan.logical.Enrich;
6262
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
6363
import org.elasticsearch.xpack.esql.plan.physical.AggregateExec;
64+
import org.elasticsearch.xpack.esql.plan.physical.DissectExec;
6465
import org.elasticsearch.xpack.esql.plan.physical.EsQueryExec;
6566
import org.elasticsearch.xpack.esql.plan.physical.EsStatsQueryExec;
6667
import org.elasticsearch.xpack.esql.plan.physical.EsStatsQueryExec.Stat;
@@ -71,6 +72,7 @@
7172
import org.elasticsearch.xpack.esql.plan.physical.FilterExec;
7273
import org.elasticsearch.xpack.esql.plan.physical.LimitExec;
7374
import org.elasticsearch.xpack.esql.plan.physical.LocalSourceExec;
75+
import org.elasticsearch.xpack.esql.plan.physical.MvExpandExec;
7476
import org.elasticsearch.xpack.esql.plan.physical.PhysicalPlan;
7577
import org.elasticsearch.xpack.esql.plan.physical.ProjectExec;
7678
import org.elasticsearch.xpack.esql.plan.physical.TimeSeriesAggregateExec;
@@ -152,6 +154,18 @@ public boolean isSingleValue(String field) {
152154
}
153155
};
154156

157+
private final SearchStats CONSTANT_K_STATS = new TestSearchStats() {
158+
@Override
159+
public boolean isSingleValue(String field) {
160+
return true;
161+
}
162+
163+
@Override
164+
public String constantValue(String name) {
165+
return name.startsWith("constant_keyword") ? "foo" : null;
166+
}
167+
};
168+
155169
@ParametersFactory(argumentFormatting = PARAM_FORMATTING)
156170
public static List<Object[]> readScriptSpec() {
157171
return settings().stream().map(t -> {
@@ -1858,6 +1872,113 @@ public void testPushDownFieldExtractToTimeSeriesSource() {
18581872
assertTrue(timeSeriesSource.attrs().stream().noneMatch(EsQueryExec::isSourceAttribute));
18591873
}
18601874

1875+
/**
1876+
* LimitExec[1000[INTEGER]]
1877+
* \_ExchangeExec[[!alias_integer, boolean{f}#415, byte{f}#416, constant_keyword-foo{f}#417, date{f}#418, date_nanos{f}#419,
1878+
* 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,
1879+
* !semantic_text, short{f}#429, text{f}#430, unsigned_long{f}#428, version{f}#431, wildcard{f}#432], false]
1880+
* \_ProjectExec[[!alias_integer, boolean{f}#415, byte{f}#416, constant_keyword-foo{f}#417, date{f}#418, date_nanos{f}#419,
1881+
* 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,
1882+
* !semantic_text, short{f}#429, text{f}#430, unsigned_long{f}#428, version{f}#431, wildcard{f}#432]]
1883+
* \_FieldExtractExec[!alias_integer, boolean{f}#415, byte{f}#416, consta..]
1884+
* \_EsQueryExec[test], indexMode[standard], query[][_doc{f}#434], limit[1000], sort[] estimatedRowSize[412]
1885+
*/
1886+
public void testConstantKeywordWithMatchingFilter() {
1887+
String queryText = """
1888+
from test
1889+
| where `constant_keyword-foo` == "foo"
1890+
""";
1891+
var analyzer = makeAnalyzer("mapping-all-types.json");
1892+
var plan = plannerOptimizer.plan(queryText, CONSTANT_K_STATS, analyzer);
1893+
1894+
var limit = as(plan, LimitExec.class);
1895+
var exchange = as(limit.child(), ExchangeExec.class);
1896+
var project = as(exchange.child(), ProjectExec.class);
1897+
var field = as(project.child(), FieldExtractExec.class);
1898+
var query = as(field.child(), EsQueryExec.class);
1899+
assertThat(as(query.limit(), Literal.class).value(), is(1000));
1900+
assertNull(query.query());
1901+
}
1902+
1903+
/**
1904+
* LimitExec[1000[INTEGER]]
1905+
* \_ExchangeExec[[!alias_integer, boolean{f}#4, byte{f}#5, constant_keyword-foo{f}#6, date{f}#7, date_nanos{f}#8, double{f}#9,
1906+
* 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,
1907+
* short{f}#18, text{f}#19, unsigned_long{f}#17, version{f}#20, wildcard{f}#21], false]
1908+
* \_LocalSourceExec[[!alias_integer, boolean{f}#4, byte{f}#5, constant_keyword-foo{f}#6, date{f}#7, date_nanos{f}#8, double{f}#9,
1909+
* 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,
1910+
* short{f}#18, text{f}#19, unsigned_long{f}#17, version{f}#20, wildcard{f}#21], EMPTY]
1911+
*/
1912+
public void testConstantKeywordWithNonMatchingFilter() {
1913+
String queryText = """
1914+
from test
1915+
| where `constant_keyword-foo` == "non-matching"
1916+
""";
1917+
var analyzer = makeAnalyzer("mapping-all-types.json");
1918+
var plan = plannerOptimizer.plan(queryText, CONSTANT_K_STATS, analyzer);
1919+
1920+
var limit = as(plan, LimitExec.class);
1921+
var exchange = as(limit.child(), ExchangeExec.class);
1922+
var source = as(exchange.child(), LocalSourceExec.class);
1923+
}
1924+
1925+
/**
1926+
* LimitExec[1000[INTEGER]]
1927+
* \_ExchangeExec[[!alias_integer, boolean{f}#6, byte{f}#7, constant_keyword-foo{r}#25, date{f}#9, date_nanos{f}#10, double{f}#1...
1928+
* \_ProjectExec[[!alias_integer, boolean{f}#6, byte{f}#7, constant_keyword-foo{r}#25, date{f}#9, date_nanos{f}#10, double{f}#1...
1929+
* \_FieldExtractExec[!alias_integer, boolean{f}#6, byte{f}#7, date{f}#9,
1930+
* \_LimitExec[1000[INTEGER]]
1931+
* \_FilterExec[constant_keyword-foo{r}#25 == [66 6f 6f][KEYWORD]]
1932+
* \_MvExpandExec[constant_keyword-foo{f}#8,constant_keyword-foo{r}#25]
1933+
* \_FieldExtractExec[constant_keyword-foo{f}#8]
1934+
* \_EsQueryExec[test], indexMode[standard], query[][_doc{f}#26], limit[], sort[] estimatedRowSize[412]
1935+
*/
1936+
public void testConstantKeywordExpandFilter() {
1937+
String queryText = """
1938+
from test
1939+
| mv_expand `constant_keyword-foo`
1940+
| where `constant_keyword-foo` == "foo"
1941+
""";
1942+
var analyzer = makeAnalyzer("mapping-all-types.json");
1943+
var plan = plannerOptimizer.plan(queryText, CONSTANT_K_STATS, analyzer);
1944+
1945+
var limit = as(plan, LimitExec.class);
1946+
var exchange = as(limit.child(), ExchangeExec.class);
1947+
var project = as(exchange.child(), ProjectExec.class);
1948+
var fieldExtract = as(project.child(), FieldExtractExec.class);
1949+
var limit2 = as(fieldExtract.child(), LimitExec.class);
1950+
var filter = as(limit2.child(), FilterExec.class);
1951+
var expand = as(filter.child(), MvExpandExec.class);
1952+
var field = as(expand.child(), FieldExtractExec.class); // MV_EXPAND is not optimized yet (it doesn't accept literals)
1953+
as(field.child(), EsQueryExec.class);
1954+
}
1955+
1956+
/**
1957+
* DissectExec[constant_keyword-foo{f}#8,Parser[pattern=%{bar}, appendSeparator=, ...
1958+
* \_LimitExec[1000[INTEGER]]
1959+
* \_ExchangeExec[[!alias_integer, boolean{f}#6, byte{f}#7, constant_keyword-foo{f}#8, date{f}#9, date_nanos{f}#10, double{f}#11...
1960+
* \_ProjectExec[[!alias_integer, boolean{f}#6, byte{f}#7, constant_keyword-foo{f}#8, date{f}#9, date_nanos{f}#10, double{f}#11...
1961+
* \_FieldExtractExec[!alias_integer, boolean{f}#6, byte{f}#7, constant_k..]
1962+
* \_EsQueryExec[test], indexMode[standard], query[][_doc{f}#25], limit[1000], sort[] estimatedRowSize[462]
1963+
*/
1964+
public void testConstantKeywordDissectFilter() {
1965+
String queryText = """
1966+
from test
1967+
| dissect `constant_keyword-foo` "%{bar}"
1968+
| where `constant_keyword-foo` == "foo"
1969+
""";
1970+
var analyzer = makeAnalyzer("mapping-all-types.json");
1971+
var plan = plannerOptimizer.plan(queryText, CONSTANT_K_STATS, analyzer);
1972+
1973+
var dissect = as(plan, DissectExec.class);
1974+
var limit = as(dissect.child(), LimitExec.class);
1975+
var exchange = as(limit.child(), ExchangeExec.class);
1976+
var project = as(exchange.child(), ProjectExec.class);
1977+
var field = as(project.child(), FieldExtractExec.class);
1978+
var query = as(field.child(), EsQueryExec.class);
1979+
assertNull(query.query());
1980+
}
1981+
18611982
public void testMatchFunctionWithStatsWherePushable() {
18621983
String query = """
18631984
from test

0 commit comments

Comments
 (0)