spark-test commented on code in PR #60091:
URL: https://github.com/apache/spark/pull/60091#discussion_r4486457776
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala:
##########
@@ -239,9 +250,37 @@ object ValidateSubqueryExpression
}
}
+ /**
+ * Returns the [[CTERelationDef]]s of `plan`'s [[WithCTE]]s that a
[[CTERelationRef]] in `plan`
+ * references, keyed by CTE id. The single-pass resolver validates a
subquery before its CTEs
+ * are inlined, so validation follows these references instead. References
inside subquery
+ * expressions count only if `includeReferencesInSubqueries` is set.
+ */
+ private def referencedCteDefinitions(
+ plan: LogicalPlan,
+ includeReferencesInSubqueries: Boolean = true): Map[Long,
CTERelationDef] = {
+ if (!plan.containsPattern(CTE)) {
+ Map.empty
+ } else {
+ val collectReferencedId: PartialFunction[LogicalPlan, Long] = {
+ case ref: CTERelationRef => ref.cteId
+ }
+ val referencedIds = if (includeReferencesInSubqueries) {
+ plan.collectWithSubqueries(collectReferencedId).toSet
+ } else {
+ plan.collect(collectReferencedId).toSet
+ }
+ plan.collect { case withCte: WithCTE => withCte.cteDefs }.flatten
Review Comment:
**Non-blocking (P2):** Definitions come only from `WithCTE` nodes inside the
plan being validated. The single-pass resolver validates a subquery as soon as
its hosting operator resolves, before it places the `WithCTE` of the enclosing
subquery or query, so a reference to a CTE defined in an enclosing scope is
still an opaque leaf here. The enclosing subquery's own correlation check
doesn't count nested references either, so it checks such a CTE only in place.
Fixed point validates after inlining, where the body sits at the reference.
That leaves the failure this PR fixes in place one scope out. Single pass
accepts
```sql
select *, (with cte as (select * from y where y1 = x1)
select count(*) from z
where exists (select * from z zz left join cte on zz.z1 =
cte.y1)) from x
```
which fixed point rejects with
`ACCESSING_OUTER_QUERY_COLUMN_IS_NOT_ALLOWED`. With
`spark.sql.optimizer.scalarSubqueryUseSingleJoin=false`, single pass also still
rejects `with c as (select * from y where y2 = 0) select *, (select count(*)
from c where c.y1 = x1 group by c.y1, c.y2) from x` with
`NON_CORRELATED_COLUMNS_IN_GROUP_BY`, while fixed point accepts it. The new
nested-EXISTS golden case can't tell the two behaviors apart, because its CTE
body already fails in place. This affects only the single-pass resolver.
**Recommended change:** Pass the enclosing-scope definitions that the
subquery's references resolve to from the single-pass resolver into
ValidateSubqueryExpression, and add them to its definition maps. Also count
references from nested subqueries in the correlation check, so a definition
used only inside a nested subquery is validated where that subquery references
it rather than in place. Add golden cases for a correlated CTE used on the
null-supplying side of a LEFT JOIN inside a nested EXISTS, and, with the single
join disabled, for a grouped scalar subquery over an outer-query CTE that pins
a grouping column to a constant.
**Why this works:** With the resolved definitions available, a nested
subquery's validation follows its reference into the enclosing body under its
own context, which is exactly where fixed point checks the inlined body. The
enclosing subquery then no longer checks that definition at its own WithCTE.
**Scope:** Supply enclosing-scope CTE definitions to single-pass subquery
validation, and validate definitions used only from nested subqueries where
those subqueries reference them.
**Risks:** Supplying a definition that a reference does not resolve to, for
example across a view boundary or behind a shadowing inner WITH, would validate
the wrong body; the lookup must use each reference's definition id.
**Constraints:** The fixed-point call site supplies no extra definitions, so
fixed-point behavior is unchanged.
**Success:** Single pass rejects a correlated CTE used on the null-supplying
side of an outer join inside a nested subquery, as fixed point does. With the
single join disabled, single pass accepts a grouped scalar subquery whose
grouped column is pinned inside an outer-query CTE, as fixed point does.
**Verification:**
- **Regression:** A scalar subquery whose correlated CTE is used only inside
a nested EXISTS, on the right side of a LEFT JOIN, fails in dual run with
ACCESSING_OUTER_QUERY_COLUMN_IS_NOT_ALLOWED and both analyzers agreeing.
- **Regression:** With the single join disabled, dual run accepts, with both
analyzers agreeing, a grouped scalar subquery over an outer-query CTE that pins
one grouping column to a constant.
- **Compatibility:** The nested-subquery golden case this pull request adds
and the existing cte-nested.sql cases keep their results.
##########
sql/core/src/test/resources/sql-tests/inputs/subquery/scalar-subquery/scalar-subquery-group-by.sql:
##########
@@ -44,3 +44,21 @@ set
spark.sql.legacy.scalarSubqueryAllowGroupByNonEqualityCorrelatedPredicate =
set spark.sql.optimizer.scalarSubqueryUseSingleJoin = false;
select * from x where (select count(*) from y where y1 > x1 group by y1) = 1;
reset
spark.sql.legacy.scalarSubqueryAllowGroupByNonEqualityCorrelatedPredicate;
+
+-- Scalar subqueries with a WITH clause are validated with their CTEs inlined.
+set spark.sql.legacy.scalarSubqueryAllowGroupByNonEqualityCorrelatedPredicate
= false;
Review Comment:
**Nit (P3):** This block sets and resets only
`spark.sql.legacy.scalarSubqueryAllowGroupByNonEqualityCorrelatedPredicate`. It
inherits `spark.sql.optimizer.scalarSubqueryUseSingleJoin = false` from the
legacy-behavior block above (line 44), which never resets it, and the shape and
group-by checks this PR changes run only under that setting. If that block
later gains a reset or moves below this one, the legal cases pass even with the
CTE look-through reverted, the two `MUST_AGGREGATE_CORRELATED_SCALAR_SUBQUERY`
cases return rows, and the `y1 > x1` case fails only at runtime with
`SCALAR_SUBQUERY_TOO_MANY_ROWS`. A regenerated golden would record those
outcomes, and the block would stop testing the new code.
See **Shared repair plan 2** in the review body.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala:
##########
@@ -38,6 +39,8 @@ object ValidateSubqueryExpression
plan: LogicalPlan,
expr: SubqueryExpression,
skipOuterReferenceValidationInFilter: Boolean = false): Unit = {
+ lazy val referencedCtes = referencedCteDefinitions(expr.plan)
Review Comment:
**Nit (P3):** This map counts references inside nested subquery expressions,
while the correlation check at line 421 doesn't. That lets the shape check skip
a `WithCTE` whose CTEs are used only from nested subqueries, so with
`spark.sql.optimizer.scalarSubqueryUseSingleJoin=false` single pass now accepts
`select *, (with cte as (select * from y) select count(*) from z where z1 = x1
and exists (select * from cte) group by z1) from x`, as fixed point does after
inlining; the base branch rejected it with
`MUST_AGGREGATE_CORRELATED_SCALAR_SUBQUERY`. No new case covers this: the only
nested-reference case fails on its FULL OUTER JOIN however references are
counted. If this counting changed, for example in the fix for references inside
unused CTEs, single pass would reject the query again and no test would fail.
See **Shared repair plan 1** in the review body.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala:
##########
@@ -239,9 +250,37 @@ object ValidateSubqueryExpression
}
}
+ /**
+ * Returns the [[CTERelationDef]]s of `plan`'s [[WithCTE]]s that a
[[CTERelationRef]] in `plan`
+ * references, keyed by CTE id. The single-pass resolver validates a
subquery before its CTEs
+ * are inlined, so validation follows these references instead. References
inside subquery
+ * expressions count only if `includeReferencesInSubqueries` is set.
+ */
+ private def referencedCteDefinitions(
+ plan: LogicalPlan,
+ includeReferencesInSubqueries: Boolean = true): Map[Long,
CTERelationDef] = {
+ if (!plan.containsPattern(CTE)) {
+ Map.empty
+ } else {
+ val collectReferencedId: PartialFunction[LogicalPlan, Long] = {
+ case ref: CTERelationRef => ref.cteId
+ }
+ val referencedIds = if (includeReferencesInSubqueries) {
+ plan.collectWithSubqueries(collectReferencedId).toSet
+ } else {
+ plan.collect(collectReferencedId).toSet
Review Comment:
**Blocking (P1):** This set counts every `CTERelationRef` in the plan,
including references inside the body of a CTE that nothing uses. The default
fixed-point analyzer validates the output of `InlineCTE(alwaysInline = true,
keepDanglingRelations = true, isAnalysis = true)`, which keeps such dangling
definitions with their original bodies (`cleanCTEMap` discounts the references
they make). So a definition referenced only from an unused definition now lands
in the map, is skipped at its `WithCTE`, and is validated at that reference,
with the reference's context.
For example, with the views from scalar-subquery-group-by.sql:
```sql
select *, (with a as (select * from y where y1 = x1),
b as (select * from z left join a on z1 = y1)
select count(*) from z) from x
```
The subquery's query uses neither CTE. On the base branch this analyzes: `a`
is checked in place and the reference inside `b` is a leaf, just as the
existing cte-nested.sql case `SELECT (WITH unreferenced AS (SELECT id) SELECT
1) FROM range(1)` analyzes and returns 1. With this change the reference on the
right side of `b`'s LEFT JOIN is followed into `a`, and the default analyzer
fails with `ACCESSING_OUTER_QUERY_COLUMN_IS_NOT_ALLOWED`. The same applies to
EXISTS, IN and lateral subqueries and to the other correlation-restricted
positions, although the description says the fixed-point analyzer's behavior is
unchanged. If the subquery's query also reads `a` (`... select count(*) from
a`), fixed point still accepts it, because inlining removes `a`, but single
pass now rejects it, which dual run reports as
`SINGLE_PASS_FAILED_FIXED_POINT_SUCCEEDED`.
See **Shared repair plan 1** in the review body.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/subquery.scala:
##########
@@ -369,7 +376,10 @@ object SubExprUtils extends PredicateHelper {
_: Range |
_: SubqueryAlias =>
ExpressionSet(plan.children.flatMap(child =>
- getCorrelatedEquivalentInnerExpressions(child)))
+ equivalentExpressions(child)))
+
+ case cteRef: CTERelationRef if cteDefinitions.contains(cteRef.cteId) =>
Review Comment:
**Non-blocking (P2):** This returns equivalences expressed in the
definition's attribute IDs for every reference it reaches. In single pass only
the first `CTERelationRef` keeps the definition's output IDs
(`ExpressionIdAssigner` gives later references fresh ones), and fixed point
inlines a re-instanced reference as an alias `Project` over a deduplicated
copy, whose equivalences carry fresh IDs. So an equality found through a later
reference is credited to the first reference's columns.
With `spark.sql.optimizer.scalarSubqueryUseSingleJoin=false`:
```sql
select *, (with cte as (select * from y where y1 = 2)
select count(*) from z left join cte a on z2 = a.y2 join cte b on
true
where z1 = x1 group by a.y1) from x
```
Single pass accepts this, because `b`'s `y1 = 2` is credited to `a.y1`,
while fixed point rejects it with `NON_CORRELATED_COLUMNS_IN_GROUP_BY` (dual
run: `FIXED_POINT_FAILED_SINGLE_PASS_SUCCEEDED`). For `x1 = 1` the subquery
returns two groups (`a.y1 = 2` and `NULL`), so with the single-pass resolver
enabled the left-outer rewrite duplicates that outer row. The same happens when
the first reference is the first branch of a `UNION ALL`. This affects only the
single-pass resolver.
**Recommended change:** In the group-by equivalence traversal, follow a CTE
reference only when it keeps its definition's output attributes, or when it is
the reference through which cleanQueryInScalarSubquery reached the aggregate
being checked; pass that path from the shape check to nonEquivalentGroupbyCols.
Add scalar-subquery group-by golden cases, with the single join disabled, for
four queries: a CTE's first reference on the null-supplying side of a LEFT JOIN
and, separately, in the first branch of a UNION ALL, while a later reference
supplies an equality on the column the query groups by; a query that groups by
a later reference's own column; and a query whose aggregate is reached through
a re-instanced reference to its CTE.
**Why this works:** Fixed point gives every re-instanced reference a private
copy, whose equivalences can only qualify an aggregate inside that copy.
Following only the references that keep the definition's attributes, plus the
reference on the path to the aggregate, gives single pass the same set of
usable equivalences.
**Scope:** Which CTE references contribute group-by equivalences in
single-pass validation.
**Risks:** Queries whose aggregate sits behind a re-instanced reference
depend on the shape path being passed through; if the path is lost, single pass
alone would reject them.
**Constraints:** The optimizer's one-row check keeps the default empty map
and is unaffected.
**Success:** Single pass rejects a grouped correlated scalar subquery whose
grouping column is pinned only through another reference to the same CTE, as
fixed point does. A CTE referenced once in a grouped scalar subquery
contributes the same equivalences as before this repair.
**Verification:**
- **Regression:** With the single join disabled, scalar subqueries that
group by a column of a CTE's first reference, where that reference sits on a
path that propagates no equivalences, such as the null-supplying side of a LEFT
JOIN or the first branch of a UNION ALL, while a later reference supplies an
equality on that column, fail in dual run with
NON_CORRELATED_COLUMNS_IN_GROUP_BY and both analyzers agreeing.
- **Behavior:** Dual run accepts, with both analyzers agreeing, a scalar
subquery whose aggregate is reached through a re-instanced reference to its CTE.
- **Compatibility:** With the single join disabled, a scalar subquery that
groups by a later reference's own column fails in dual run with
NON_CORRELATED_COLUMNS_IN_GROUP_BY and both analyzers agreeing.
- **Compatibility:** The legal and illegal CTE cases this pull request adds
keep their recorded outcomes.
##########
sql/core/src/test/resources/sql-tests/inputs/subquery/scalar-subquery/scalar-subquery-group-by.sql:
##########
@@ -44,3 +44,21 @@ set
spark.sql.legacy.scalarSubqueryAllowGroupByNonEqualityCorrelatedPredicate =
set spark.sql.optimizer.scalarSubqueryUseSingleJoin = false;
select * from x where (select count(*) from y where y1 > x1 group by y1) = 1;
reset
spark.sql.legacy.scalarSubqueryAllowGroupByNonEqualityCorrelatedPredicate;
+
+-- Scalar subqueries with a WITH clause are validated with their CTEs inlined.
+set spark.sql.legacy.scalarSubqueryAllowGroupByNonEqualityCorrelatedPredicate
= false;
+-- Legal queries
+select *, (with cte as (select y1, count(*) as cnt from y where y1 = x1 group
by y1) select cnt from cte) from x;
+select *, (with cte as (select * from y where y1 = x1) select count(*) from
cte group by y1) from x;
+select *, (with cte1 as (select * from y where y1 = x1), cte2 as (select y1,
count(*) as cnt from cte1 group by y1) select cnt from cte2) from x;
+select *, (with cte as (select * from y where y1 = x1) select count(*) from
cte left join z on y2 = z2 group by y1) from x;
+-- Illegal queries
+select *, (with cte as (select y1, count(*) as cnt from y where y1 > x1 group
by y1) select cnt from cte) from x;
Review Comment:
**Nit (P3):** The description says these illegal cases fail with the same
errors as in fixed point, but they run only through dual run, and
`HybridAnalyzer` rethrows the fixed-point exception whenever both analyzers
fail. This case and the grouped left-join case below were already rejected by
the base branch's single pass, with
`MUST_AGGREGATE_CORRELATED_SCALAR_SUBQUERY`, so their goldens are identical
with and without this change. Nothing observes the error single pass reports
for the new negative cases, so a later change that makes it report a different
error for them, as users would see once the resolver is enabled, would pass
every test. Suites such as `AsOfJoinSQLSuite` already assert single-pass errors
with `checkError` under `spark.sql.analyzer.singlePassResolver.enabled`.
**Verification:**
- **Regression:** With the single-pass resolver enabled on its own, every
new negative CTE case that both analyzers reject fails with the same error
condition as in fixed point; in particular the grouped CTE case with a
non-equality correlation fails with NON_CORRELATED_COLUMNS_IN_GROUP_BY, and the
grouped left-join CTE case fails with
ACCESSING_OUTER_QUERY_COLUMN_IS_NOT_ALLOWED.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala:
##########
@@ -402,6 +448,9 @@ object ValidateSubqueryExpression
// are on the path from the operator hosting the correlated expressions
// up to the operator producing the correlated values.
plan match {
+ case cteRef: CTERelationRef if referencedCtes.contains(cteRef.cteId) =>
Review Comment:
**Nit (P3):** This follows the reference into its body every time, with no
record of what was already validated, and the `WithCTE` case skips referenced
definitions, so a body is checked once per reference path rather than once per
definition. In single pass, `select * from x where exists (with c0 as (select *
from y), c1 as (select * from c0 union all select * from c0), ..., cK as
(select * from c(K-1) union all select * from c(K-1)) select * from cK where y1
= x1)` validates `c0`'s body 2^K times (about 10^6 at K = 20), where the base
branch visited each definition once. The new arm in
`getCorrelatedEquivalentInnerExpressions` repeats the pattern through inner
joins and INTERSECT. The verdict depends only on the body, `aggregated` and
`canContainOuter`, so the repeated visits add work without changing any result.
This affects only the single-pass resolver; the default analyzer already
validates an inlined plan of comparable size.
**Verification:**
- **Behavior:** Single-pass analysis of a subquery whose WITH clause chains
twenty CTEs, each referencing the previous one twice, finishes promptly and
validates each definition a bounded number of times.
- **Behavior:** With the single join disabled, single-pass analysis of a
correlated grouped scalar subquery whose WITH clause chains twenty CTEs, each
inner-joining two references to the previous one, computes each definition's
equivalence set once and finishes promptly.
- **Compatibility:** Subquery and CTE golden files keep their results in
dual run.
##########
sql/core/src/test/resources/sql-tests/inputs/sql-udf.sql:
##########
@@ -748,6 +748,13 @@ SELECT foo3_2e1(
ARRAY(STRUCT('2022-01-01 10:11:12', '1'), STRUCT('2022-01-01 10:11:15',
'2')),
'2022-01-01');
+-- Correlated scalar function whose body aggregates in a CTE
+CREATE FUNCTION foo3_2e2(x INT) RETURNS INT RETURN
+WITH t AS (SELECT c1, SUM(c2) AS s FROM t2 WHERE c1 = x GROUP BY c1)
+SELECT s FROM t;
+
+SELECT c1, foo3_2e2(c1) FROM t1;
Review Comment:
**Nit (P3):** The description says this case will cover the single-pass path
once SQL functions are supported, but it can't fail for the changed validation,
now or then. Today `ResolverGuard` rejects non-builtin functions, so single
pass never runs it, and fixed point inlines the CTE before validating. This
file also never sets `spark.sql.optimizer.scalarSubqueryUseSingleJoin`, and
with its default (`true`) the scalar shape and group-by checks this PR changes
are skipped. The remaining correlation check accepts the body both in place and
at the reference, so even a future single pass gives the same result with or
without this change.
See **Shared repair plan 2** in the review body.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]