diff --git a/docs/changelog/144826.yaml b/docs/changelog/144826.yaml new file mode 100644 index 0000000000000..7b33328883be8 --- /dev/null +++ b/docs/changelog/144826.yaml @@ -0,0 +1,6 @@ +area: ES|QL +issues: + - 144329 +pr: 144826 +summary: Propagate empty local relation past joins +type: bug diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/generative/GenerativeRestTest.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/generative/GenerativeRestTest.java index 5f73c2a67f166..24f4d75d66a38 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/generative/GenerativeRestTest.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/generative/GenerativeRestTest.java @@ -77,7 +77,7 @@ public abstract class GenerativeRestTest extends ESRestTestCase implements Query // full-text functions are not allowed to match on fields that come from lookup indices "cannot operate on \\[.*\\], supplied by an index \\[.*\\] in non-STANDARD mode \\[lookup\\]", "Can only use fuzzy queries on keyword and text fields - not on \\[.*\\] which is of type \\[.*\\]", - // multi_match query receiving a non-boolean value for a boolean type field + // full-text function receiving a non-boolean value for a boolean type field "Can't parse boolean value \\[.*\\], expected \\[true\\] or \\[false\\]", // full-text function trying to parse text as date field and failing "failed to parse date field \\[.*\\] with format", diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec index e95a1f30a37c8..8a8279b0fd841 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec @@ -5873,3 +5873,319 @@ warning:Line 13:24: java.lang.IllegalArgumentException: single-value function en language_name:keyword | country:text | other2:integer | YEcxuCOHsGzb:keyword | other1:keyword | date:datetime | date_nanos:date_nanos | id_int:integer | ip_addr:ip | is_active_bool:boolean | name_str:keyword ; + +whereFalseBeforeLookupJoin +required_capability: join_lookup_v12 + +FROM employees +| WHERE false +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| KEEP emp_no, language_code, language_name +; + +emp_no:integer | language_code:integer | language_name:keyword +; + +whereFalseBeforeLookupJoinWithFilterAfter +required_capability: join_lookup_v12 + +FROM employees +| WHERE false +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| WHERE emp_no > 100 +| KEEP emp_no, language_code, language_name +; + +emp_no:integer | language_code:integer | language_name:keyword +; + +whereFalseBeforeLookupJoinWithStatsAfter +required_capability: join_lookup_v12 + +FROM employees +| WHERE false +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| STATS c = COUNT(*) +; + +c:long +0 +; + +impossibleFilterBeforeLookupJoin +required_capability: join_lookup_v12 + +FROM employees +| EVAL a = 1, b = a + 1, c = b + a +| WHERE c > 10 +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| WHERE language_name IS NOT NULL +| KEEP emp_no, language_name +; + +emp_no:integer | language_name:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereFalseBeforeLookupJoinWithMatch +required_capability: join_lookup_v12 +required_capability: match_function +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| WHERE false +| LOOKUP JOIN message_types_lookup ON message +| WHERE match(message, "Connection error") +| KEEP message, type +; + +message:keyword | type:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereFalseBeforeLookupJoinWithMatchPhrase +required_capability: join_lookup_v12 +required_capability: match_phrase_function +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| WHERE false +| LOOKUP JOIN message_types_lookup ON message +| WHERE match_phrase(message, "Connection error") +| KEEP message, type +; + +message:keyword | type:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereFalseBeforeLookupJoinWithMatchOperator +required_capability: join_lookup_v12 +required_capability: match_operator_colon +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| WHERE false +| LOOKUP JOIN message_types_lookup ON message +| WHERE message:"Connection error" +| KEEP message, type +; + +message:keyword | type:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereFalseWithEvalBeforeLookupJoinAndMatch +required_capability: join_lookup_v12 +required_capability: match_function +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| WHERE false +| EVAL msg = message +| LOOKUP JOIN message_types_lookup ON message +| WHERE match(message, "Connection error") +| KEEP message, type +; + +message:keyword | type:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereFalseBeforeLookupJoinWithFullTextDisjunction +required_capability: join_lookup_v12 +required_capability: full_text_functions_disjunctions_compute_engine +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| WHERE false +| LOOKUP JOIN message_types_lookup ON message +| WHERE match(message, "Connection error") OR type LIKE "Success" +| KEEP message, type +; + +message:keyword | type:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereFalseBeforeLookupJoinWithMatchAndStats +required_capability: join_lookup_v12 +required_capability: match_function +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| WHERE false +| LOOKUP JOIN message_types_lookup ON message +| WHERE match(message, "Connection error") +| STATS c = COUNT(*) +; + +c:long +0 +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereFalseBeforeLookupJoinWithMultipleFullTextFunctions +required_capability: join_lookup_v12 +required_capability: match_function +required_capability: match_phrase_function +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| WHERE false +| LOOKUP JOIN message_types_lookup ON message +| WHERE match(message, "Connection") AND match_phrase(message, "Connection error") +| KEEP message, type +; + +message:keyword | type:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereFalseBeforeLookupJoinWithMatchOnEmployees +required_capability: join_lookup_v12 +required_capability: match_function +required_capability: propagate_empty_relation_past_joins + +FROM employees +| WHERE false +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| WHERE match(first_name, "Georgi") +| KEEP emp_no, first_name, language_name +; + +emp_no:integer | first_name:keyword | language_name:keyword +; + +whereNullBeforeLookupJoin +required_capability: join_lookup_v12 + +FROM employees +| WHERE null +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| KEEP emp_no, language_code, language_name +; + +emp_no:integer | language_code:integer | language_name:keyword +; + +evalNullFilterBeforeLookupJoin +required_capability: join_lookup_v12 + +FROM employees +| EVAL x = null +| WHERE x::int > 0 +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| KEEP emp_no, language_code, language_name +; + +emp_no:integer | language_code:integer | language_name:keyword +; + +whereNullBeforeLookupJoinWithStatsAfter +required_capability: join_lookup_v12 + +FROM employees +| WHERE null +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| STATS c = COUNT(*) +; + +c:long +0 +; + +whereNullBeforeLookupJoinWithFilterAfter +required_capability: join_lookup_v12 + +FROM employees +| WHERE null +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| WHERE emp_no > 100 +| KEEP emp_no, language_code, language_name +; + +emp_no:integer | language_code:integer | language_name:keyword +; + +nullArithmeticFilterBeforeLookupJoin +required_capability: join_lookup_v12 + +FROM employees +| WHERE 1 + null > 0 +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| KEEP emp_no, language_code, language_name +; + +emp_no:integer | language_code:integer | language_name:keyword +; + +whereNullConjunctionBeforeLookupJoin +required_capability: join_lookup_v12 + +FROM employees +| WHERE null AND emp_no > 0 +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| KEEP emp_no, language_code, language_name +; + +emp_no:integer | language_code:integer | language_name:keyword +; + +whereNullDisjunctionBeforeLookupJoin +required_capability: join_lookup_v12 + +FROM employees +| WHERE null OR false +| EVAL language_code = languages +| LOOKUP JOIN languages_lookup ON language_code +| KEEP emp_no, language_code, language_name +; + +emp_no:integer | language_code:integer | language_name:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +whereNullBeforeLookupJoinWithMatch +required_capability: join_lookup_v12 +required_capability: match_function +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| WHERE null +| LOOKUP JOIN message_types_lookup ON message +| WHERE match(message, "Connection error") +| KEEP message, type +; + +message:keyword | type:keyword +; + +// See https://github.com/elastic/elasticsearch/issues/144329 +evalNullFilterBeforeLookupJoinWithMatch +required_capability: join_lookup_v12 +required_capability: match_function +required_capability: propagate_empty_relation_past_joins + +FROM sample_data +| EVAL x = null +| WHERE x::int > 0 +| LOOKUP JOIN message_types_lookup ON message +| WHERE match(message, "Connection error") +| KEEP message, type +; + +message:keyword | type:keyword +; + diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec index 148521562450a..bde606e830b79 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/stats.csv-spec @@ -3870,3 +3870,128 @@ SUM(salary_change):double | salary_change:double 14.69 | 1.92 6.000000000000001 | 1.98 ; + +multiStatsAfterFalseFilter_ByComputedValues +from employees +| where false +| stats c = count(*), s = avg(salary) +| stats max(c), min(s) by s, c +; + + max(c):l | min(s):d | s:d | c:l +0 |null |null |0 +; + +multiStatsAfterFalseFilter_ByLiteralValue +from employees +| where false +| stats c = count(*), s = avg(salary) +| stats max(c), min(s) by x = "abc" +; + + max(c):l | min(s):d | x:keyword +0 |null |abc +; + +multiStatsAfterFalseFilter_ByField +from employees +| where false +| stats c = count(gender), s = avg(salary) by gender +| stats max(c), min(c), min(s), avg(s) +; + + max(c):l | min(c):l | min(s):d | avg(s):d +null |null |null |null +; + +whereNullBeforeStats +from employees +| where null +| stats c = count(*) +; + +c:long +0 +; + +whereNullBeforeMixedStats +from employees +| where null +| stats c = count(*), s = sum(salary), a = avg(salary) +; + +c:long | s:long | a:double +0 | null | null +; + +evalNullFilterBeforeStats +from employees +| eval x = null +| where x::int > 0 +| stats c = count(*) +; + +c:long +0 +; + +nullArithmeticFilterBeforeStats +from employees +| where 1 + null > 0 +| stats c = count(*), mn = min(salary), mx = max(salary) +; + +c:long | mn:integer | mx:integer +0 | null | null +; + +whereNullConjunctionBeforeStats +from employees +| where null and salary > 0 +| stats c = count(*) +; + +c:long +0 +; + +whereNullBeforeGroupedStats +from employees +| where null +| stats c = count(*) by gender +; + +c:long | gender:keyword +; + +evalNullFilterBeforeGroupedStats +from employees +| eval x = null +| where x::int > 0 +| stats c = count(*), s = avg(salary) by gender +; + +c:long | s:double | gender:keyword +; + +multiStatsAfterNullFilter_ByComputedValues +from employees +| where null +| stats c = count(*), s = avg(salary) +| stats max(c), min(s) by s, c +; + + max(c):l | min(s):d | s:d | c:l +0 |null |null |0 +; + +multiStatsAfterNullFilter_ByField +from employees +| where null +| stats c = count(gender), s = avg(salary) by gender +| stats max(c), min(c), min(s), avg(s) +; + + max(c):l | min(c):l | min(s):d | avg(s):d +null |null |null |null +; diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java index 8cfc76c49be5f..29477124d9b37 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java @@ -27,6 +27,7 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked; import static org.elasticsearch.xpack.esql.EsqlTestUtils.getValuesList; +import static org.elasticsearch.xpack.esql.plugin.MatchFunctionIT.createAndPopulateLookupIndex; import static org.hamcrest.CoreMatchers.containsString; public class KqlFunctionIT extends AbstractEsqlIntegTestCase { @@ -206,6 +207,30 @@ public void testKqlAfterMvExpandWithIntermediateCommands() { assertThat(error.getMessage(), containsString("[KQL] function cannot be used after MV_EXPAND")); } + public void testWhereFalseBeforeInlineStatsWithKql() { + var query = """ + FROM test + | WHERE false + | INLINE STATS max_id = MAX(id) + | WHERE kql("content: fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[KQL] function cannot be used after INLINE")); + } + + public void testWhereFalseBeforeLookupJoinWithKql() { + var query = """ + FROM test + | WHERE false + | LOOKUP JOIN test_lookup ON id + | WHERE kql("lookup_content: fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[KQL] function cannot be used after LOOKUP")); + } + private void createAndPopulateIndex() { var indexName = "test"; var client = client().admin().indices(); @@ -250,7 +275,11 @@ private void createAndPopulateIndex() { ) .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) .get(); - ensureYellow(indexName); + + var lookupIndexName = "test_lookup"; + createAndPopulateLookupIndex(client, lookupIndexName); + + ensureYellow(indexName, lookupIndexName); } @Override diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java index 731da9795f100..c72d244c17c28 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java @@ -359,6 +359,56 @@ public void testMatchOnJoinFieldWithLookupJoin() { ); } + public void testWhereFalseBeforeInlineStatsWithMatch() { + var query = """ + FROM test + | WHERE false + | INLINE STATS max_id = MAX(id) + | WHERE match(content, "fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[MATCH] function cannot be used after INLINE")); + } + + public void testImpossibleFilterBeforeInlineStatsWithMatch() { + var query = """ + FROM test + | EVAL a = 1, b = a + 1, c = b + a + | WHERE c > 10 + | INLINE STATS max_id = MAX(id) + | WHERE match(content, "fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[MATCH] function cannot be used after INLINE")); + } + + public void testWhereFalseBeforeInlineStatsWithMatchAndStats() { + var query = """ + FROM test + | WHERE false + | INLINE STATS max_id = MAX(id) + | WHERE match(content, "fox") + | STATS c = COUNT(*) + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[MATCH] function cannot be used after INLINE")); + } + + public void testWhereFalseBeforeGroupedInlineStatsWithMatch() { + var query = """ + FROM test + | WHERE false + | INLINE STATS max_id = MAX(id) BY id + | WHERE match(content, "fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[MATCH] function cannot be used after INLINE")); + } + public void testMatchWithLookupJoinOnMatch() { var query = """ FROM test diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java index 8c1a40f35f733..dbcad7f387c11 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java @@ -398,6 +398,18 @@ public void testMatchOperatorAfterMvExpandWithIntermediateCommands() { assertThat(error.getMessage(), containsString("[:] operator cannot be used after MV_EXPAND")); } + public void testWhereFalseBeforeInlineStatsWithMatchOperator() { + var query = """ + FROM test + | WHERE false + | INLINE STATS max_id = MAX(id) + | WHERE content:"fox" + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[:] operator cannot be used after INLINE")); + } + public void testMatchOperatorWithLookupJoin() { var query = """ FROM test diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchPhraseFunctionIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchPhraseFunctionIT.java index fd18c6de5c28c..bc11e8d0295ce 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchPhraseFunctionIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchPhraseFunctionIT.java @@ -352,6 +352,31 @@ public void testMatchPhraseAfterMvExpandWithIntermediateCommands() { assertThat(error.getMessage(), containsString("[MatchPhrase] function cannot be used after MV_EXPAND")); } + public void testWhereFalseBeforeInlineStatsWithMatchPhrase() { + var query = """ + FROM test + | WHERE false + | INLINE STATS max_id = MAX(id) + | WHERE match_phrase(content, "brown fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[MatchPhrase] function cannot be used after INLINE")); + } + + public void testWhereFalseWithEvalBeforeInlineStatsAndMatchPhrase() { + var query = """ + FROM test + | WHERE false + | EVAL doubled_id = id * 2 + | INLINE STATS avg_id = AVG(id) BY doubled_id + | WHERE match_phrase(content, "brown fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[MatchPhrase] function cannot be used after INLINE")); + } + public void testMatchPhraseWithLookupJoin() { var query = """ FROM test diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/QueryStringIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/QueryStringIT.java index 16d7d3f9520da..8fb3252f9335d 100644 --- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/QueryStringIT.java +++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/QueryStringIT.java @@ -275,4 +275,28 @@ public void testWhereQstrWithLookupJoin() { var error = expectThrows(VerificationException.class, () -> run(query)); assertThat(error.getMessage(), containsString("line 3:3: [QSTR] function cannot be used after LOOKUP")); } + + public void testWhereFalseBeforeInlineStatsWithQstr() { + var query = """ + FROM test + | WHERE false + | INLINE STATS max_id = MAX(id) + | WHERE qstr("content: fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[QSTR] function cannot be used after INLINE")); + } + + public void testWhereFalseBeforeLookupJoinWithQstr() { + var query = """ + FROM test + | WHERE false + | LOOKUP JOIN test_lookup ON id + | WHERE qstr("lookup_content: fox") + """; + + var error = expectThrows(VerificationException.class, () -> run(query)); + assertThat(error.getMessage(), containsString("[QSTR] function cannot be used after LOOKUP")); + } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java index fba170f44fec8..b68e85cdb2fcb 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java @@ -2396,6 +2396,8 @@ public enum Cap { */ FIX_SUM_OF_NULL_OPTIMIZATION, + PROPAGATE_EMPTY_RELATION_PAST_JOINS, + // Last capability should still have a comma for fewer merge conflicts when adding new ones :) // This comment prevents the semicolon from being on the previous capability when Spotless formats the file. ; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/PropagateEmptyRelation.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/PropagateEmptyRelation.java index 0fa4fcfd97708..b71e1b6551d56 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/PropagateEmptyRelation.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/PropagateEmptyRelation.java @@ -22,6 +22,9 @@ import org.elasticsearch.xpack.esql.plan.logical.Aggregate; import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; import org.elasticsearch.xpack.esql.plan.logical.UnaryPlan; +import org.elasticsearch.xpack.esql.plan.logical.join.Join; +import org.elasticsearch.xpack.esql.plan.logical.join.JoinTypes; +import org.elasticsearch.xpack.esql.plan.logical.local.EmptyLocalSupplier; import org.elasticsearch.xpack.esql.plan.logical.local.LocalRelation; import org.elasticsearch.xpack.esql.plan.logical.local.LocalSupplier; import org.elasticsearch.xpack.esql.planner.PlannerUtils; @@ -31,29 +34,34 @@ import java.util.List; @SuppressWarnings("removal") -public class PropagateEmptyRelation extends OptimizerRules.ParameterizedOptimizerRule +public class PropagateEmptyRelation extends OptimizerRules.ParameterizedOptimizerRule implements - OptimizerRules.LocalAware { + OptimizerRules.LocalAware { public PropagateEmptyRelation() { super(OptimizerRules.TransformDirection.DOWN); } @Override - protected LogicalPlan rule(UnaryPlan plan, LogicalOptimizerContext ctx) { - LogicalPlan p = plan; - if (plan.child() instanceof LocalRelation local && local.hasEmptySupplier()) { + protected LogicalPlan rule(LogicalPlan plan, LogicalOptimizerContext ctx) { + if (plan instanceof UnaryPlan unary && unary.child() instanceof LocalRelation local && local.hasEmptySupplier()) { // only care about non-grouped aggs might return something (count) if (plan instanceof Aggregate agg && agg.groupings().isEmpty()) { List emptyBlocks = aggsFromEmpty(ctx.foldCtx(), agg.aggregates()); - p = replacePlanByRelation( - plan, + return new LocalRelation( + plan.source(), + plan.output(), LocalSupplier.of(emptyBlocks.isEmpty() ? new Page(0) : new Page(emptyBlocks.toArray(Block[]::new))) ); - } else { - p = PruneEmptyPlans.skipPlan(plan); + } + return PruneEmptyPlans.skipPlan(unary); + } + if (plan instanceof Join join && join.left() instanceof LocalRelation lr && lr.hasEmptySupplier()) { + var type = join.config().type(); + if (type == JoinTypes.LEFT || type == JoinTypes.INNER || type == JoinTypes.CROSS) { + return new LocalRelation(join.source(), join.output(), EmptyLocalSupplier.EMPTY); } } - return p; + return plan; } private List aggsFromEmpty(FoldContext foldCtx, List aggs) { @@ -88,12 +96,8 @@ protected void aggOutput( blocks.add(wrapper.builder().build()); } - private static LogicalPlan replacePlanByRelation(UnaryPlan plan, LocalSupplier supplier) { - return new LocalRelation(plan.source(), plan.output(), supplier); - } - @Override - public Rule local() { + public Rule local() { return new LocalPropagateEmptyRelation(); } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java index 46f1072b2e5be..039172a207b69 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java @@ -3062,6 +3062,313 @@ public void testFoldInEval() { assertThat(local.supplier(), is(EmptyLocalSupplier.EMPTY)); } + /** + * Expected: + *
{@code
+     * Limit[1000[INTEGER],false,false]
+     * \_LocalRelation[[count{r}#4],Page{blocks=[LongVectorBlock[vector=ConstantLongVector[positions=1, value=0]]]}]
+     * }
+ */ + public void testPropagateEmptyRelationWithUngroupedStats() { + var plan = optimizedPlan(""" + from test + | where false + | stats count = count(*) + """); + + var limit = as(plan, Limit.class); + var local = as(limit.child(), LocalRelation.class); + assertThat(local.supplier(), not(is(EmptyLocalSupplier.EMPTY))); + } + + /** + * Expected: + *
{@code
+     * Limit[1000[INTEGER],false,false]
+     * \_LocalRelation[[s{r}#5],Page{blocks=[LongArrayBlock[positions=1, mvOrdering=UNORDERED, vector=LongArrayVector[positions=1, val
+     * ues=[0]]]]}]
+     * }
+ * + * sum() on empty input produces null (not 0), unlike count(). + */ + public void testPropagateEmptyRelationWithUngroupedNonCountStats() { + var plan = optimizedPlan(""" + from test + | where false + | stats s = sum(salary) + """); + + var limit = as(plan, Limit.class); + var local = as(limit.child(), LocalRelation.class); + assertThat(local.supplier(), not(is(EmptyLocalSupplier.EMPTY))); + } + + /** + * Expected: + *
{@code
+     * Project[[c{r}#4, s{r}#7]]
+     * \_Eval[[$$SUM$s$0{r$}#19 / $$COUNT$s$1{r$}#20 AS s#7]]
+     *   \_Limit[1000[INTEGER],false,false]
+     *     \_LocalRelation[[c{r}#4, $$SUM$s$0{r$}#19, $$COUNT$s$1{r$}#20],Page{blocks=[LongVectorBlock[vector=ConstantLongVector[positions
+     * =1, value=0]], LongArrayBlock[positions=1, mvOrdering=UNORDERED, vector=LongArrayVector[positions=1, values=[0]]],
+     * LongVectorBlock[vector=ConstantLongVector[positions=1, value=0]]]}]
+     * }
+ * + * avg(salary) is decomposed into sum(salary)/count(salary) during the substitutions phase, producing the + * Eval + Project above the Limit. PropagateEmptyRelation then folds only the raw sub-aggregates (count, sum) + * into the LocalRelation; the division is computed by the Eval. + */ + public void testPropagateEmptyRelationWithUngroupedMixedStats() { + var plan = optimizedPlan(""" + from test + | where false + | stats c = count(*), s = avg(salary) + """); + + var project = as(plan, Project.class); + var eval = as(project.child(), Eval.class); + var limit = as(eval.child(), Limit.class); + var local = as(limit.child(), LocalRelation.class); + assertThat(local.supplier(), not(is(EmptyLocalSupplier.EMPTY))); + } + + /** + * Expected: + *
{@code
+     * LocalRelation[[c{r}#5, languages{f}#9],EMPTY]
+     * }
+ * + * Grouped aggregates on empty input produce zero groups, so the entire plan collapses to an empty LocalRelation. + * This differs from ungrouped aggregates, which always produce exactly one row (e.g., count=0). + */ + public void testPropagateEmptyRelationWithGroupedStats() { + var plan = optimizedPlan(""" + from test + | where false + | stats c = count(*) by languages + """); + + var local = as(plan, LocalRelation.class); + assertThat(local.supplier(), is(EmptyLocalSupplier.EMPTY)); + } + + /** + * Expected: + *
{@code
+     * Limit[1000[INTEGER],false,false]
+     * \_LocalRelation[[count{r}#14],Page{blocks=[LongVectorBlock[vector=ConstantLongVector[positions=1, value=0]]]}]
+     * }
+ * + * Same result as testPropagateEmptyRelationWithUngroupedStats but the empty relation is produced by + * PropagateEvalFoldables + ConstantFolding (a=1, b=2, c=2, c > 10 folds to false) rather than a literal + * WHERE false. + */ + public void testPropagateEmptyRelationWithImpossibleFilterAndStats() { + var plan = optimizedPlan(""" + from test + | eval a = 1, b = a + 1, c = b + a + | where c > 10 + | stats count = count(*) + """); + + var limit = as(plan, Limit.class); + var local = as(limit.child(), LocalRelation.class); + assertThat(local.supplier(), not(is(EmptyLocalSupplier.EMPTY))); + } + + /** + * Expected: + *
{@code
+     * Limit[1000[INTEGER],false,false]
+     * \_LocalRelation[[count{r}#7],Page{blocks=[LongVectorBlock[vector=ConstantLongVector[positions=1, value=0]]]}]
+     * }
+ * + * Same final plan as testPropagateEmptyRelationWithUngroupedStats but requires two fixed-point iterations: + * first the Eval over empty LocalRelation is collapsed, then the Aggregate over empty LocalRelation is folded. + */ + public void testPropagateEmptyRelationWithInterveningEvalAndStats() { + var plan = optimizedPlan(""" + from test + | where false + | eval x = salary + 1 + | stats count = count(*) + """); + + var limit = as(plan, Limit.class); + var local = as(limit.child(), LocalRelation.class); + assertThat(local.supplier(), not(is(EmptyLocalSupplier.EMPTY))); + } + + /** + * Expected: + *
{@code
+     * LocalRelation[[_meta_field{f}#24, emp_no{f}#18, first_name{f}#19, gender{f}#20, hire_date{f}#25, job{f}#26, job.raw{f}#27, l
+     * anguages{f}#21, last_name{f}#22, long_noidx{f}#28, salary{f}#23, a{r}#4, b{r}#7, c{r}#11, language_code{r}#15, language_name{f}#30],
+     * EMPTY]
+     * }
+ */ + public void testPropagateEmptyRelationThroughLookupJoin() { + var plan = optimizedPlan(""" + from test + | eval a = 1, b = a + 1, c = b + a + | where c > 10 + | eval language_code = languages + | lookup join languages_lookup on language_code + | where emp_no > 100 + """); + + var local = as(plan, LocalRelation.class); + assertThat(local.supplier(), is(EmptyLocalSupplier.EMPTY)); + } + + /** + * Expected: + *
{@code
+     * LocalRelation[[emp_no{f}#9, language_name{f}#21],EMPTY]
+     * }
+ */ + public void testPropagateEmptyRelationThroughLookupJoinWithWhereAfterJoin() { + var plan = optimizedPlan(""" + from test + | where false + | eval language_code = languages + | lookup join languages_lookup on language_code + | keep emp_no, language_name + """); + + var local = as(plan, LocalRelation.class); + assertThat(local.supplier(), is(EmptyLocalSupplier.EMPTY)); + } + + /** + * Expected: + *
{@code
+     * Limit[1000[INTEGER],false,false]
+     * \_LocalRelation[[count{r}#8],Page{blocks=[LongVectorBlock[vector=ConstantLongVector[positions=1, value=0]]]}]
+     * }
+ */ + public void testPropagateEmptyRelationThroughLookupJoinWithStatsAfterJoin() { + var plan = optimizedPlan(""" + from test + | where false + | eval language_code = languages + | lookup join languages_lookup on language_code + | stats count = count(*) + """); + + var limit = as(plan, Limit.class); + var local = as(limit.child(), LocalRelation.class); + assertThat(local.supplier(), not(is(EmptyLocalSupplier.EMPTY))); + } + + /** + * Expected: + *
{@code
+     * LocalRelation[[emp_no{f}#5, language_code{r}#14, language_name{f}#17],EMPTY]
+     * }
+ * + * WHERE null is folded to WHERE false, then PropagateEmptyRelation collapses the entire plan. + */ + public void testPropagateEmptyRelationWithWhereNullThroughLookupJoin() { + var plan = optimizedPlan(""" + from test + | where null + | eval language_code = languages + | lookup join languages_lookup on language_code + | keep emp_no, language_code, language_name + """); + + var local = as(plan, LocalRelation.class); + assertThat(local.supplier(), is(EmptyLocalSupplier.EMPTY)); + } + + /** + * Expected: + *
{@code
+     * LocalRelation[[emp_no{f}#5, language_code{r}#17, language_name{f}#20],EMPTY]
+     * }
+ * + * EVAL x = null | WHERE x::int > 0 folds to an empty relation via FoldNull + constant folding, + * then PropagateEmptyRelation collapses the join. + */ + public void testPropagateEmptyRelationWithEvalNullFilterThroughLookupJoin() { + var plan = optimizedPlan(""" + from test + | eval x = null + | where x::int > 0 + | eval language_code = languages + | lookup join languages_lookup on language_code + | keep emp_no, language_code, language_name + """); + + var local = as(plan, LocalRelation.class); + assertThat(local.supplier(), is(EmptyLocalSupplier.EMPTY)); + } + + /** + * Expected: + *
{@code
+     * Limit[1000[INTEGER],false,false]
+     * \_LocalRelation[[count{r}#6],Page{blocks=[LongVectorBlock[vector=ConstantLongVector[positions=1, value=0]]]}]
+     * }
+ * + * WHERE null collapses the driving side; ungrouped COUNT(*) on empty input returns 0. + */ + public void testPropagateEmptyRelationWithWhereNullThroughLookupJoinAndStats() { + var plan = optimizedPlan(""" + from test + | where null + | eval language_code = languages + | lookup join languages_lookup on language_code + | stats count = count(*) + """); + + var limit = as(plan, Limit.class); + var local = as(limit.child(), LocalRelation.class); + assertThat(local.supplier(), not(is(EmptyLocalSupplier.EMPTY))); + } + + /** + * Expected: + *
{@code
+     * Limit[1000[INTEGER],false,false]
+     * \_LocalRelation[[count{r}#4],Page{blocks=[LongVectorBlock[vector=ConstantLongVector[positions=1, value=0]]]}]
+     * }
+ * + * WHERE null folds to WHERE false, producing an empty relation that is folded into + * an ungrouped COUNT(*) returning 0. + */ + public void testPropagateEmptyRelationWithWhereNull() { + var plan = optimizedPlan(""" + from test + | where null + | stats count = count(*) + """); + + var limit = as(plan, Limit.class); + var local = as(limit.child(), LocalRelation.class); + assertThat(local.supplier(), not(is(EmptyLocalSupplier.EMPTY))); + } + + /** + * Expected: + *
{@code
+     * LocalRelation[[{e}#2],EMPTY]
+     * }
+ * + * WHERE 1 + null > 0 folds the null arithmetic to null, then the comparison to null (false), + * producing an empty relation. + */ + public void testPropagateEmptyRelationWithNullArithmeticFilter() { + var plan = optimizedPlan(""" + from test + | where 1 + null > 0 + """); + + var local = as(plan, LocalRelation.class); + assertThat(local.supplier(), is(EmptyLocalSupplier.EMPTY)); + } + public void testFoldFromRow() { var plan = optimizedPlan(""" row a = 1, b = 2, c = 3