Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.APPROXIMATION_V2;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.FORK_V9;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.METRICS_GROUP_BY_ALL;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.OPTIONAL_FIELDS_V2;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.UNMAPPED_FIELDS;
import static org.elasticsearch.xpack.esql.action.EsqlCapabilities.Cap.VIEWS_WITH_BRANCHING;
Expand Down Expand Up @@ -62,6 +63,12 @@ protected void shouldSkipTest(String testName) throws IOException {
testCase.requiredCapabilities.contains(UNMAPPED_FIELDS.capabilityName())
);

// FORK is not supported with unmapped_fields="load", see https://github.com/elastic/elasticsearch/issues/142033
assumeFalse(
"FORK is not supported with unmapped_fields=\"load\"",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: We could add a comment pointing to #142033?
Also applies elsewhere where this PR disables testing of some queries with load.

testCase.requiredCapabilities.contains(OPTIONAL_FIELDS_V2.capabilityName())
);

assumeFalse(
"Tests using subqueries are skipped since we don't support nested subqueries",
testCase.requiredCapabilities.contains(SUBQUERY_IN_FROM_COMMAND.capabilityName())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ FROM partial_mapping_sample_data
2024-10-23T13:52:55.015Z | 173.21.3.15 | 8268152 | Connection error? | 1 | English
;

doesNotLoadUnmappedFieldsLookupJoin
doesNotLoadUnmappedFieldsLookupJoin-Ignore
// temporarily forbidding "load" with LOOKUP JOIN
required_capability: optional_fields_v2
required_capability: join_lookup_v12
FROM partial_mapping_sample_data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ public LogicalPlan analyze(LogicalPlan plan) {
}

public LogicalPlan verify(LogicalPlan plan, BitSet partialMetrics) {
Collection<Failure> failures = verifier.verify(plan, partialMetrics);
Collection<Failure> failures = verifier.verify(plan, partialMetrics, context().unmappedResolution());
if (failures.isEmpty() == false) {
throw new VerificationException(failures);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

package org.elasticsearch.xpack.esql.analysis;

import org.elasticsearch.index.IndexMode;
import org.elasticsearch.license.XPackLicenseState;
import org.elasticsearch.xpack.esql.LicenseAware;
import org.elasticsearch.xpack.esql.capabilities.ConfigurationAware;
Expand All @@ -31,13 +32,16 @@
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.NotEquals;
import org.elasticsearch.xpack.esql.plan.logical.Aggregate;
import org.elasticsearch.xpack.esql.plan.logical.EsRelation;
import org.elasticsearch.xpack.esql.plan.logical.Fork;
import org.elasticsearch.xpack.esql.plan.logical.InlineStats;
import org.elasticsearch.xpack.esql.plan.logical.Insist;
import org.elasticsearch.xpack.esql.plan.logical.Limit;
import org.elasticsearch.xpack.esql.plan.logical.LimitBy;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
import org.elasticsearch.xpack.esql.plan.logical.Lookup;
import org.elasticsearch.xpack.esql.plan.logical.Project;
import org.elasticsearch.xpack.esql.plan.logical.Subquery;
import org.elasticsearch.xpack.esql.plan.logical.UnionAll;
import org.elasticsearch.xpack.esql.plan.logical.promql.PromqlCommand;
import org.elasticsearch.xpack.esql.telemetry.FeatureMetric;
import org.elasticsearch.xpack.esql.telemetry.Metrics;
Expand Down Expand Up @@ -86,9 +90,10 @@ public Verifier(Metrics metrics, XPackLicenseState licenseState, List<BiConsumer
*
* @param plan The logical plan to be verified
* @param partialMetrics a bitset indicating a certain command (or "telemetry feature") is present in the query
* @param unmappedResolution the active unmapped-field resolution strategy; used to gate commands unsupported in certain modes
* @return a collection of verification failures; empty if and only if the plan is valid
*/
Collection<Failure> verify(LogicalPlan plan, BitSet partialMetrics) {
Collection<Failure> verify(LogicalPlan plan, BitSet partialMetrics, UnmappedResolution unmappedResolution) {
assert partialMetrics != null;
Failures failures = new Failures();

Expand All @@ -102,6 +107,10 @@ Collection<Failure> verify(LogicalPlan plan, BitSet partialMetrics) {
return failures.failures();
}

if (unmappedResolution == UnmappedResolution.LOAD) {
checkLoadModeDisallowedCommands(plan, failures);
}

// collect plan checkers
var planCheckers = planCheckers(plan);
planCheckers.addAll(extraCheckers);
Expand Down Expand Up @@ -340,6 +349,24 @@ private static void checkLimitBy(LogicalPlan plan, Failures failures) {
}
}

/**
* {@code unmapped_fields="load"} does not yet support branching commands (FORK, LOOKUP JOIN, subqueries/views).
* See https://github.com/elastic/elasticsearch/issues/142033
*/
private static void checkLoadModeDisallowedCommands(LogicalPlan plan, Failures failures) {
plan.forEachDown(p -> {
if (p instanceof Fork && p instanceof UnionAll == false) {
failures.add(fail(p, "FORK is not supported with unmapped_fields=\"load\""));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think for actual subqueries, we'll get the FORK is not supported message, too, because subqueries use UnionAll which is a subclass of Fork.

}
if (p instanceof Subquery) {
failures.add(fail(p, "Subqueries and views are not supported with unmapped_fields=\"load\""));
}
if (p instanceof EsRelation esRelation && esRelation.indexMode() == IndexMode.LOOKUP) {
failures.add(fail(p, "LOOKUP JOIN is not supported with unmapped_fields=\"load\""));
}
});
}

private void licenseCheck(LogicalPlan plan, Failures failures) {
Consumer<Node<?>> licenseCheck = n -> {
if (n instanceof LicenseAware la && la.licenseCheck(licenseState) == false) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

package org.elasticsearch.xpack.esql.analysis;

import org.elasticsearch.index.IndexMode;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.esql.VerificationException;
import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
Expand Down Expand Up @@ -2471,11 +2470,11 @@ public void testSubquerysMixAndLookupJoinNullify() {
assertThat(Expressions.names(plan.output()), is(List.of("COUNT(*)", "empNo", "languageCode", "does_not_exist2")));
}

// same tree as above, except for the source nodes
// unmapped_fields="load" disallows subqueries and LOOKUP JOIN (see #142033)
public void testSubquerysMixAndLookupJoinLoad() {
assumeTrue("Requires subquery in FROM command support", EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND.isEnabled());

var plan = analyzeStatement(setUnmappedLoad("""
var e = expectThrows(VerificationException.class, () -> analyzeStatement(setUnmappedLoad("""
FROM test,
(FROM languages
| WHERE language_code > 10
Expand All @@ -2489,36 +2488,12 @@ public void testSubquerysMixAndLookupJoinLoad() {
| STATS COUNT(*) BY emp_no, language_code, does_not_exist2
| RENAME emp_no AS empNo, language_code AS languageCode
| MV_EXPAND languageCode
"""));

// TODO: golden testing
assertThat(Expressions.names(plan.output()), is(List.of("COUNT(*)", "empNo", "languageCode", "does_not_exist2")));

List<EsRelation> esRelations = plan.collect(EsRelation.class);
assertThat(
esRelations.stream().map(EsRelation::indexPattern).toList(),
is(
List.of(
"test", // FROM
"languages",
"sample_data",
"test", // LOOKUP JOIN
"languages_lookup"
)
)
);
for (var esr : esRelations) {
if (esr.indexMode() != IndexMode.LOOKUP) {
var dne = esr.output().stream().filter(a -> a.name().startsWith("does_not_exist")).toList();
assertThat(dne.size(), is(2));
var dne1 = as(dne.getFirst(), FieldAttribute.class);
var dne2 = as(dne.getLast(), FieldAttribute.class);
var pukesf1 = as(dne1.field(), PotentiallyUnmappedKeywordEsField.class);
var pukesf2 = as(dne2.field(), PotentiallyUnmappedKeywordEsField.class);
assertThat(pukesf1.getName(), is("does_not_exist1"));
assertThat(pukesf2.getName(), is("does_not_exist2"));
}
}
""")));
String msg = e.getMessage();
assertThat(msg, containsString("Found 4 problems"));
assertThat(msg, containsString("Subqueries and views are not supported with unmapped_fields=\"load\""));
assertThat(msg, containsString("LOOKUP JOIN is not supported with unmapped_fields=\"load\""));
assertThat(msg, not(containsString("FORK is not supported")));
}

public void testFailSubquerysWithNoMainAndStatsOnlyNullify() {
Expand Down Expand Up @@ -3795,6 +3770,163 @@ public void testStatsFilteredAggAfterEvalWithDottedUnmappedFieldFromIndex() {
}
}

public void testLoadModeDisallowsFork() {
verificationFailure(
setUnmappedLoad("FROM test | FORK (WHERE emp_no > 1) (WHERE emp_no < 100)"),
"FORK is not supported with unmapped_fields=\"load\""
);
}

public void testLoadModeDisallowsForkWithStats() {
verificationFailure(
setUnmappedLoad("FROM test | FORK (STATS c = COUNT(*)) (STATS d = AVG(salary))"),
"FORK is not supported with unmapped_fields=\"load\""
);
}

public void testLoadModeDisallowsForkWithMultipleBranches() {
verificationFailure(setUnmappedLoad("""
FROM test
| FORK (WHERE emp_no > 1)
(WHERE emp_no < 100)
(WHERE salary > 50000)
"""), "FORK is not supported with unmapped_fields=\"load\"");
}

public void testLoadModeDisallowsLookupJoin() {
verificationFailure(
setUnmappedLoad("FROM test | EVAL language_code = languages | LOOKUP JOIN languages_lookup ON language_code"),
"LOOKUP JOIN is not supported with unmapped_fields=\"load\""
);
}

public void testLoadModeDisallowsLookupJoinAfterFilter() {
verificationFailure(setUnmappedLoad("""
FROM test
| WHERE emp_no > 1
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code
| KEEP emp_no, language_name
"""), "LOOKUP JOIN is not supported with unmapped_fields=\"load\"");
}

public void testLoadModeDisallowsSubquery() {
assumeTrue("Requires subquery in FROM command support", EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND.isEnabled());

verificationFailure(
setUnmappedLoad("FROM test, (FROM languages | WHERE language_code > 1)"),
"Subqueries and views are not supported with unmapped_fields=\"load\""
);
}

public void testLoadModeAllowsSingleSubquery() {
assumeTrue("Requires subquery in FROM command support", EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND.isEnabled());

// A single subquery without a main index is merged into the main query during analysis,
// so there is no Subquery node in the plan and no branching — this is allowed.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

++, that's correct!

var plan = analyzeStatement(setUnmappedLoad("FROM (FROM languages | WHERE language_code > 1)"));

// TODO: golden testing
var limit = as(plan, Limit.class);
var filter = as(limit.child(), Filter.class);
var relation = as(filter.child(), EsRelation.class);
assertThat(relation.indexPattern(), is("languages"));
}

public void testLoadModeDisallowsMultipleSubqueries() {
assumeTrue("Requires subquery in FROM command support", EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND.isEnabled());

verificationFailure(setUnmappedLoad("""
FROM test,
(FROM languages | WHERE language_code > 1),
(FROM sample_data | STATS max(@timestamp))
"""), "Subqueries and views are not supported with unmapped_fields=\"load\"");
}

public void testLoadModeDisallowsNestedSubqueries() {
assumeTrue("Requires subquery in FROM command support", EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND.isEnabled());

verificationFailure(
setUnmappedLoad("FROM test, (FROM languages, (FROM sample_data | STATS count(*)) | WHERE language_code > 10)"),
"Subqueries and views are not supported with unmapped_fields=\"load\""
);
}

public void testLoadModeDisallowsSubqueryWithLookupJoin() {
assumeTrue("Requires subquery in FROM command support", EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND.isEnabled());

verificationFailure(setUnmappedLoad("""
FROM test,
(FROM test
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code)
"""), "Subqueries and views are not supported with unmapped_fields=\"load\"");
}

public void testLoadModeDisallowsForkAndLookupJoin() {
var query = setUnmappedLoad("""
FROM test
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code
| FORK (WHERE emp_no > 1) (WHERE emp_no < 100)
Comment on lines +3868 to +3871
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The query is duplicated. Let's extract that into a String that is re-used.

It's correct that we assert both failures, though.

""");
verificationFailure(query, "FORK is not supported with unmapped_fields=\"load\"");
verificationFailure(query, "LOOKUP JOIN is not supported with unmapped_fields=\"load\"");
}

public void testLoadModeDisallowsSubqueryAndFork() {
assumeTrue("Requires subquery in FROM command support", EsqlCapabilities.Cap.SUBQUERY_IN_FROM_COMMAND.isEnabled());

var query = setUnmappedLoad("""
FROM test, (FROM languages | WHERE language_code > 1)
| FORK (WHERE emp_no > 1) (WHERE emp_no < 100)
""");
verificationFailure(query, "Subqueries and views are not supported with unmapped_fields=\"load\"");
verificationFailure(query, "FORK is not supported with unmapped_fields=\"load\"");
}

public void testLoadModeAllowsInlineStats() {
var plan = analyzeStatement(setUnmappedLoad("""
FROM test
| INLINE STATS c = COUNT(*) BY emp_no
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not-so nit: we already have inline stats + nullify tests above, e.g. testInlineStats. The same is true for fork. Let's double check which of the positive tests with nullify already exist and remove them from this PR.

"""));

// TODO: golden testing
var limit = as(plan, Limit.class);
var inlineStats = as(limit.child(), InlineStats.class);
var agg = as(inlineStats.child(), Aggregate.class);
assertThat(Expressions.names(agg.groupings()), is(List.of("emp_no")));
var relation = as(agg.child(), EsRelation.class);
assertThat(relation.indexPattern(), is("test"));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: asserting the plan structure generally makes sense if we already add a positive test. And/or leaving a comment TODO: golden tests so we pick this up later.


public void testLoadModeAllowsInlineStatsWithUnmappedFields() {
var plan = analyzeStatement(setUnmappedLoad("""
FROM test
| INLINE STATS s = COUNT(does_not_exist1), c = COUNT(*) BY does_not_exist2, emp_no
"""));

// TODO: golden testing
var limit = as(plan, Limit.class);
var inlineStats = as(limit.child(), InlineStats.class);
var agg = as(inlineStats.child(), Aggregate.class);
assertThat(Expressions.names(agg.groupings()), is(List.of("does_not_exist2", "emp_no")));
assertThat(Expressions.names(agg.aggregates()), is(List.of("s", "c", "does_not_exist2", "emp_no")));
var relation = as(agg.child(), EsRelation.class);
assertThat(relation.indexPattern(), is("test"));
var dneAttrs = relation.output().stream().filter(a -> a.name().startsWith("does_not_exist")).toList();
assertThat(dneAttrs, hasSize(2));
}

public void testNullifyModeAllowsFork() {
var plan = analyzeStatement(setUnmappedNullify("FROM test | FORK (WHERE emp_no > 1) (WHERE emp_no < 100)"));

// TODO: golden testing
var limit = as(plan, Limit.class);
var fork = as(limit.child(), Fork.class);
assertThat(fork.children(), hasSize(2));
}

private void verificationFailure(String statement, String expectedFailure) {
var e = expectThrows(VerificationException.class, () -> analyzeStatement(statement));
assertThat(e.getMessage(), containsString(expectedFailure));
Expand Down
Loading