Skip to content

sql: collapse FROM-less correlated existence subqueries to a filter#37442

Open
antiguru wants to merge 2 commits into
MaterializeInc:mainfrom
antiguru:hir-subquery-simplify-2613-2969
Open

sql: collapse FROM-less correlated existence subqueries to a filter#37442
antiguru wants to merge 2 commits into
MaterializeInc:mainfrom
antiguru:hir-subquery-simplify-2613-2969

Conversation

@antiguru

@antiguru antiguru commented Jul 4, 2026

Copy link
Copy Markdown
Member

A FROM-less correlated existence subquery is a pure predicate on the outer row.
Decorrelation lowers it into a semijoin or antijoin that the MIR transforms do not collapse back to a filter.
This leaves avoidable joins in the plan for the shapes reported in database-issues#2613 (1 IN (SELECT 1 WHERE p)) and database-issues#2969 (NOT EXISTS (SELECT 1 WHERE p)).

Add an HIR simplification pass, simplify_from_less_existence_subqueries, that runs after try_simplify_quantified_comparisons (which already normalizes both IN and EXISTS shapes to an Exists node).
When the Exists body is a FROM-less correlated chain of Map/Project/Filter over a single-row constant, it rewrites EXISTS(chain, preds) to (preds) IS TRUE, inlining inner column references and shifting outer ones down one level as the predicate leaves the subquery.

The IS TRUE wrapper is load-bearing for null safety.
NOT EXISTS keeps the outer row when the subquery is empty, which is when the predicate is FALSE or NULL.
NOT ((p) IS TRUE) = p IS NOT TRUE is true for both, matching.
A plain NOT p would drop the NULL row.

The guard fires only on the FROM-less correlated pure-existence shape, so subqueries with a FROM clause remain genuine anti/semi-joins.

The pass is gated behind the enable_simplify_from_less_existence feature flag, default off in production and on in CI and tests.

Tests: subquery.slt covers both issues with EXPLAIN before/after in the flag-off and flag-on states, plus an explicit NULL-row test for the antijoin rewrite.

Fixes database-issues#2613

Motivation

Fixes a plan-quality gap: FROM-less correlated existence subqueries leave avoidable semi/antijoins.

@antiguru antiguru force-pushed the hir-subquery-simplify-2613-2969 branch from 0ef6d98 to 75f4564 Compare July 4, 2026 12:15
A FROM-less correlated existence subquery is a pure predicate on the
outer row, but decorrelation lowers it into a semijoin or antijoin that
the MIR transforms do not collapse back to a filter. This leaves
avoidable joins in the plan for the shapes reported in
database-issues#2613 (`1 IN (SELECT 1 WHERE p)`) and
database-issues#2969 (`NOT EXISTS (SELECT 1 WHERE p)`).

Add an HIR simplification pass, `simplify_from_less_existence_subqueries`,
that runs after `try_simplify_quantified_comparisons` (which already
normalizes both `IN` and `EXISTS` shapes to an `Exists` node). When the
`Exists` body is a FROM-less correlated chain of `Map`/`Project`/`Filter`
over a single-row constant, it rewrites `EXISTS(chain, preds)` to
`(preds) IS TRUE`, inlining inner column references and shifting outer
ones down one level as the predicate leaves the subquery.

The `IS TRUE` wrapper is load-bearing for null safety. `NOT EXISTS`
keeps the outer row when the subquery is empty, which is when the
predicate is FALSE or NULL. `NOT ((p) IS TRUE)` = `p IS NOT TRUE` is
true for both, matching. A plain `NOT p` would drop the NULL row.

The pass is gated behind the `enable_simplify_from_less_existence`
feature flag, default off in production and on in CI and tests. The
guard fires only on the FROM-less correlated pure-existence shape, so
subqueries with a FROM clause remain genuine anti/semi-joins.

Tests: subquery.slt covers both issues with EXPLAIN before/after in the
flag-off and flag-on states, plus an explicit NULL-row test for the
antijoin rewrite. not-null-propagation.slt is unchanged with the flag
defaulting off.

Fixes database-issues#2613

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@antiguru antiguru force-pushed the hir-subquery-simplify-2613-2969 branch from 75f4564 to 6df045a Compare July 4, 2026 12:38
@antiguru antiguru marked this pull request as ready for review July 4, 2026 19:10
@antiguru antiguru requested review from a team as code owners July 4, 2026 19:10
@antiguru antiguru requested a review from ggevay July 4, 2026 19:10

@def- def- left a comment

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.

Extra test:

diff --git a/test/sqllogictest/subquery.slt b/test/sqllogictest/subquery.slt
index 4ab64b8cf1..35a57451ff 100644
--- a/test/sqllogictest/subquery.slt
+++ b/test/sqllogictest/subquery.slt
@@ -895,6 +895,45 @@ Target cluster: quickstart

 EOF

+# A window function in a FROM-less quantified subquery must not be inlined into
+# the outer Filter by the collapse. `x IN/= ANY/= ALL (SELECT <expr>)` normalizes
+# to an EXISTS with `<expr>` in a Filter predicate, but a window function run over
+# the whole outer relation (row_number() = 1, 2, 3, ...) differs from its
+# single-row subquery value (always 1). All three `1` rows satisfy `f1 = 1`, so
+# the correct answer keeps all three. The buggy inlined form keeps only the one
+# row that row_number() numbers 1.
+statement ok
+CREATE TABLE flw (f1 int)
+
+statement ok
+INSERT INTO flw VALUES (1), (1), (1), (NULL)
+
+simple conn=mz_system,user=mz_system
+ALTER SYSTEM SET enable_simplify_from_less_existence TO true;
+----
+COMPLETE 0
+
+query I rowsort
+SELECT f1 FROM flw WHERE f1 IN (SELECT row_number() OVER ())
+----
+1
+1
+1
+
+query I rowsort
+SELECT f1 FROM flw WHERE f1 = ANY (SELECT row_number() OVER ())
+----
+1
+1
+1
+
+query I rowsort
+SELECT f1 FROM flw WHERE f1 = ALL (SELECT row_number() OVER ())
+----
+1
+1
+1
+
 # Restore the default so later files see production behavior.
 simple conn=mz_system,user=mz_system
 ALTER SYSTEM SET enable_simplify_from_less_existence TO false;

Fails with this PR, worked fine before:

--- test/sqllogictest/subquery.slt
    SELECT f1 FROM flw WHERE f1 IN (SELECT row_number() OVER ())
    OutputFailure:test/sqllogictest/subquery.slt:917
            expected: Values(["1", "1", "1"])
            actually: Values(["1"])
            actual raw: [Row { columns: [Column { name: "f1", table_oid: None, column_id: None, type: Int4 }] }]
    ----
    SELECT f1 FROM flw WHERE f1 = ANY (SELECT row_number() OVER ())
    OutputFailure:test/sqllogictest/subquery.slt:924
            expected: Values(["1", "1", "1"])
            actually: Values(["1"])
            actual raw: [Row { columns: [Column { name: "f1", table_oid: None, column_id: None, type: Int4 }] }]
    ----
    SELECT f1 FROM flw WHERE f1 = ALL (SELECT row_number() OVER ())
    OutputFailure:test/sqllogictest/subquery.slt:931
            expected: Values(["1", "1", "1"])
            actually: Values(["1"])
            actual raw: [Row { columns: [Column { name: "f1", table_oid: None, column_id: None, type: Int4 }] }]
    ----
    FAIL: output-failure=3 success=93 total=96

Correctness issue, so would be good to fix before merging.

`resolve_local_columns` inlined a subquery's scalars into the outer
Filter, but a window function's value depends on the relation it runs
over, not just the row. `row_number() OVER ()` over the single-row
FROM-less body is always 1, but over the multi-row outer relation it
counts 1, 2, 3, ..., so inlining changed results: `f1 IN (SELECT
row_number() OVER ())` dropped rows it must keep.

Reject window functions alongside subqueries when deciding whether a
scalar can be lifted. The classification is an exhaustive match on
`HirScalarExpr` so a new variant must be classified rather than
silently treated as liftable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@antiguru

antiguru commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Great catch. I fixed it by exhaustively matching on the HIR variant, and only allowing the optimization on non-nested expressions.

@antiguru antiguru requested a review from def- July 6, 2026 11:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants