Skip to content

[SPARK-46625][SQL] Push WithCTE into CTEInChildren when materialised by ResolveIdentifierClause - #55706

Closed
stevomitric wants to merge 4 commits into
apache:masterfrom
stevomitric:stevomitric/fix-identifier-cte
Closed

[SPARK-46625][SQL] Push WithCTE into CTEInChildren when materialised by ResolveIdentifierClause#55706
stevomitric wants to merge 4 commits into
apache:masterfrom
stevomitric:stevomitric/fix-identifier-cte

Conversation

@stevomitric

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

After ResolveIdentifierClause materialises a PlanWithUnresolvedIdentifier into a CTEInChildren (e.g. InsertIntoStatement), collapse any surrounding WithCTE(c: CTEInChildren, defs) into c.withCTEDefs(defs) - restoring the placement invariant CTESubstitution.withCTEDefs already enforces at substitution time.

Why are the changes needed?

WITH t AS (...)
INSERT [INTO|OVERWRITE] TABLE IDENTIFIER('t')
SELECT * FROM t

produces an analysed plan with WithCTE wrapping the InsertIntoStatement - a structurally invalid shape. Plug-in datasources that re-analyse the InsertIntoStatement's query subtree and throw NoSuchElementException: key not found.
Bug is zero-day issue since SPARK-46625

Does this PR introduce any user-facing change?

No.

How was this patch tested?

New tests.

Was this patch authored or co-authored using generative AI tooling?

Yes.

@cloud-fancloud-fan 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.

Problem.WITH t AS (...) INSERT INTO/OVERWRITE TABLE IDENTIFIER(:p) SELECT * FROM t produces a structurally invalid analyzed plan: WithCTE(InsertIntoStatement(...), cteDefs). InsertIntoStatement is a CTEInChildren, and CTESubstitution.withCTEDefs (CTESubstitution.scala:466-471) explicitly maintains the invariant that no WithCTE may wrap a CTEInChildren — but the invariant is decided at substitution time, before ResolveIdentifierClause materializes the leaf PlanWithUnresolvedIdentifier into a CTEInChildren. Downstream consumers that re-analyze the query subtree see orphan CTERelationRefs and trip InlineCTE.buildCTEMap with NoSuchElementException.

Root cause.withIdentClause (AstBuilder.scala:97-107) lifts PlanWithUnresolvedIdentifier above the entire write/CTAS command, so CTESubstitution sees a leaf at that position and can't tell the leaf will resolve into a CTEInChildren. The PR addresses this by re-applying CTESubstitution.withCTEDefs's dispatch after materialization (a post-hoc collapse), but the structural cause is the parser placement — see the inline comment on ResolveIdentifierClause.scala for a redirect to fix this at the source.

// `InsertIntoStatement`) inside an outer `WithCTE`, push the CTE defs into the command's
// children - restoring the invariant from `CTESubstitution.withCTEDefs`.
resolved.resolveOperatorsUpWithPruning(_.containsPattern(CTE)) {
case WithCTE(c: CTEInChildren, cteDefs) => c.withCTEDefs(cteDefs)

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.

This patches the symptom rather than the root cause: withIdentClause lifts PlanWithUnresolvedIdentifier above the whole write/CTAS command, and we then have to undo the placement after materialization. Please change the parser to push the placeholder into the identifier slot of the produced plan (e.g. InsertIntoStatement.table, CreateTableAsSelect.name) instead of wrapping the entire command, and add a parallel handler in this rule — e.g.:

case i @InsertIntoStatement(p: PlanWithUnresolvedIdentifier, _, _, _, _, _, _, _, _)
if p.identifierExpr.resolved =>
i.copy(table = executor.execute(p.planBuilder.apply(
IdentifierResolution.evalIdentifierExpr(p.identifierExpr), p.children)))

Then CTESubstitution sees the actual CTEInChildren from the start and places WithCTE correctly — no post-hoc collapse needed, and the invariant is preserved by construction. The same shape applies to all withIdentClause call sites whose builder produces a CTEInChildren (INSERT, CTAS, RTAS, CACHE TABLE ASAstBuilder.scala:911-1002, 5640, 5724, 6502). Downstream matchers like case InsertIntoStatement(LogicalRelationWithTable(_), ...) aren't affected because they run after this rule, by which point table is back to a normal resolved relation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I tried this and reverted - the parser-level placement breaks legacy-mode parameter binding (spark.sql.legacy.parameterSubstitution.constantsOnly=true) for INSERT … IDENTIFIER(:p).

The problem: InsertIntoStatement.child = query (statements.scala:210), so .table is not in children. BindParameters.bind (parameters.scala:177-186) walks via resolveOperatorsDown (children-based) and transformExpressionsWithPruning (expressions only) — neither reaches a LogicalPlan-typed non-child field. Tree-pattern propagation in TreeNode.getDefaultTreePatternBits (TreeNode.scala:104-108) also flows only through children, so even containsPattern(PARAMETER) returns false on the wrapping

InsertIntoStatement and the rule prunes itself out. Result: the NamedParameter inside PlanWithUnresolvedIdentifier.identifierExpr is never bound, the placeholder never resolves, and analysis fails with [UNSUPPORTED_INSERT.RDD_BASED]. The non-legacy path works because withIdentClause short-circuits a Literal directly to UnresolvedRelation (AstBuilder.scala:85) and never creates PlanWithUnresolvedIdentifier in the first place.

Making the parser-level placement work would require teaching BindParameters (and tree-pattern propagation) about the non-child placeholder slot on every CTEInChildren write command, CTAS/RTAS name, plus future additions. That's a broader refactor than this bug warrants. Happy to do the deeper refactor as a separate change if you'd prefer.

}
}

test("Analyzed plan does not leave WithCTE wrapping a CTEInChildren " +

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.

Once the fix moves into the parser per the comment on ResolveIdentifierClause.scala, please broaden the structural test to cover the other affected commands too — at minimum a WITH t AS (...) CREATE TABLE IDENTIFIER(:p) AS SELECT * FROM t variant, since CTAS goes through the same shape. Also worth knowing: the INSERT INTO/OVERWRITE smoke tests above pass even without the fix (eager command execution doesn't hit the re-analysis path that produces the NoSuchElementException); the structural assertion here is what actually anchors the regression.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Added a CTAS variant - CREATE TABLE IDENTIFIER(:p) AS WITH … SELECT * FROM t

cloud-fan added a commit that referenced this pull request May 20, 2026
### What changes were proposed in this pull request?
Root-cause fix for SPARK-46625. Supersedes #55706.
Push `PlanWithUnresolvedIdentifier` into the command's identifier slot at parse time, for every parser-built command, instead of wrapping the whole command. With this change, `CTESubstitution` sees the real `CTEInChildren` command directly and places `WithCTE` on the command's children by construction -- the invalid `WithCTE(InsertIntoStatement, ...)` / `WithCTE(CreateTableAsSelect, ...)` shape never appears.
**Parser placement (`AstBuilder`):**
- `AstBuilder.withInsertInto`, `visitCreateTable`, `visitReplaceTable` build the command directly. A new helper `buildWriteTableSlot` returns `NamedRelation` and serves both `InsertIntoStatement.table` and `OverwriteByExpression.table` (whose slot is typed `NamedRelation`). CTAS/RTAS use single-arg `withIdentClause` to put the placeholder in the `name` slot.
- `visitCacheTable`'s AS path uses a new helper `buildCacheTableAsSelectName` to build the temp-view name as an `Expression`. The non-AS path uses single-arg `withIdentClause`.
**`CacheTableAsSelect.tempViewName: String -> Expression`:**
The last identifier slot still expressed as a plain `String` is now an `Expression`. The parser produces a non-null string `Literal` for direct identifiers and `IDENTIFIER('literal')`, or an `ExpressionWithUnresolvedIdentifier` for `IDENTIFIER(<non-literal>)`. The single-part invariant is validated at parse time for literal cases and at materialization for the non-literal case. `CheckAnalysis` enforces the post-analysis invariant that `tempViewName` is a non-null string `Literal`, and `tempViewNameString` extracts the string for execution. The expression slot is naturally visited by `transformExpressions`, so `ResolveIdentifierClause`'s existing `ExpressionWithUnresolvedIdentifier` branch resolves it without any new code path.
**Placeholder shape (`unresolved.scala`):**
- `PlanWithUnresolvedIdentifier` mixes in `NamedRelation` so it can occupy `OverwriteByExpression.table` (typed `NamedRelation`) directly.
- It does **not** extend `CTEInChildren`. With the in-slot placement plus the `CacheTableAsSelect` refactor, no parser caller places the placeholder as the substitution root of a `WITH ... <command>` subtree, so a `CTEInChildren` safety net has no reachable path under the current grammar.
**Materialization (`ResolveIdentifierClause`):**
`InsertIntoStatement.table` and `V2WriteCommand.table` are non-child `LogicalPlan` slots (`child = query`), so the default `resolveOperatorsUp` traversal never visits placeholders inside them. Two special-cases recurse explicitly:
- `case i: InsertIntoStatement if i.table.isInstanceOf[PlanWithUnresolvedIdentifier] => ...`
- `case w: V2WriteCommand if w.table.isInstanceOf[PlanWithUnresolvedIdentifier] => ...`
Each case extracts the placeholder with a single `asInstanceOf` at the top of the body and inlines the `identifierExpr.resolved && childrenResolved` check, returning the unchanged command when not yet ready.
The `V2WriteCommand` match dispatches via the abstract `withNewTable(NamedRelation)` and pattern-matches the materialized result as `NamedRelation`, throwing an internal error otherwise. Only `OverwriteByExpression` is parser-built with a placeholder in `table` today; matching the trait keeps the rule consistent for any future analyzer-built node in the same shape.
**Tree-pattern propagation (`statements.scala`, `v2Commands.scala`):**
`InsertIntoStatement` and `V2WriteCommand` override `getDefaultTreePatternBits` to union `table.treePatternBits`, so `containsPattern(...)` pruning correctly reports patterns (`PARAMETER`, `PLAN_WITH_UNRESOLVED_IDENTIFIER`) living in `table`.
**Parameter binding (`parameters.scala`):**
`BindParameters.bind` pattern-matches both `InsertIntoStatement` and `V2WriteCommand` to recurse into `table`. Without this, `INSERT ... IDENTIFIER(:p)` and `INSERT INTO REPLACE WHERE ... IDENTIFIER(:p)` under `spark.sql.legacy.parameterSubstitution.constantsOnly=true` would fail to bind the parameter.
**`CreateTableAsSelect.name` / `ReplaceTableAsSelect.name`:**
Already children via `V2CreateTableAsSelectPlan.childrenToAnalyze`, so no extra traversal hook is needed for them.
The post-hoc `WithCTE(c: CTEInChildren, _) => c.withCTEDefs(cteDefs)` collapse in `ResolveIdentifierClause` from #55706 is removed entirely -- no command shape reaches the analyzer needing it.
### Why are the changes needed?
```sql
WITH t AS (...)
INSERT [INTO|OVERWRITE] TABLE IDENTIFIER('t')
SELECT * FROM t
```
previously produced an analysed plan with `WithCTE` wrapping the `InsertIntoStatement` -- a structurally invalid shape. Plug-in datasources that re-analyse the `InsertIntoStatement`'s query subtree throw `NoSuchElementException: key not found`. Zero-day issue since SPARK-46625.
#55706 fixed the symptom by collapsing `WithCTE(c: CTEInChildren, defs)` after the fact in `ResolveIdentifierClause`. That works but encodes an undo for a placement that should never have happened, and turned out to be unsafe: after a later analyzer rewrite (e.g. `RewriteDeleteFromTable` rewriting `WithCTE(DeleteFromTable, defs)` into `WithCTE(ReplaceData, defs)`), the collapse would push `WithCTE` into `ReplaceData.query` and orphan the CTE refs in `ReplaceData.condition` / `groupFilterCondition`, breaking 9 v2 DML rCTE tests on CI. The review on #55706 (#55706 (comment)) asked for the root-cause fix; this PR implements it.
Credit to stevomitric for surfacing in the original PR thread that the parser-level placement also has to work for legacy parameter substitution -- that's why this PR adds the targeted `BindParameters` and tree-pattern-bits handling for `InsertIntoStatement.table` and `V2WriteCommand.table`.
### Does this PR introduce _any_ user-facing change?
No behavior change, but a minor timing change in `visitCreateTable` / `visitReplaceTable`: CTAS/RTAS validation errors (`Schema may not be specified in a CTAS statement`, `Partition column types may not be specified...`, `Constraints may not be specified...`) for non-literal identifiers now fire at parse time rather than at identifier resolution. Same error messages and same `ctx`; only the moment of throwing moves earlier. Fail-fast improvement, not a regression.
### How was this patch tested?
New tests in `ParametersSuite`:
- `WITH ... INSERT OVERWRITE TABLE IDENTIFIER(:p) SELECT ... FROM cte`
- `WITH ... INSERT INTO IDENTIFIER(:p) SELECT ... FROM cte`
- `CREATE TABLE IDENTIFIER(:p) AS WITH ... SELECT ... FROM cte`
- `INSERT IDENTIFIER(:p) under legacy parameter substitution` (covers `spark.sql.legacy.parameterSubstitution.constantsOnly=true`, which exercises the `BindParameters` recursion into `InsertIntoStatement.table`)
- `WITH ... INSERT INTO IDENTIFIER(:p) REPLACE WHERE ... -- parser` (asserts the placeholder lives in `OverwriteByExpression.table` and no `WithCTE(CTEInChildren, _)` shape survives `CTESubstitution`)
- `BindParameters recurses into OverwriteByExpression.table` (rule-level test; full analysis would require a v2 catalog)
- `CACHE TABLE IDENTIFIER(...) AS WITH ... SELECT ... -- parser` (asserts `tempViewName` holds `ExpressionWithUnresolvedIdentifier` and no `WithCTE(CTEInChildren, _)` shape survives `CTESubstitution`)
- `REPLACE TABLE IDENTIFIER(...) AS WITH ... SELECT ... -- parser` (mirrors the CTAS test for RTAS)
Each CTE test asserts that no `WithCTE(CTEInChildren, _)` shape leaks through analysis.
`DDLParserSuite.CACHE TABLE` updated to construct `CacheTableAsSelect` with `Literal("t")` instead of `"t"` for the new `Expression` slot.
Existing suites run clean: `ParametersSuite`, `DeltaBased/GroupBased Delete/Update/Merge` suites (including the rCTE-with-DML tests that were the original CI failures), `DataSourceV2DataFrameSuite`, `SQLViewSuite`, `IdentifierClauseParserSuite`, `AnalysisSuite` / `PlanParserSuite` / `AnalysisErrorSuite` and siblings, `DataSourceV2SQLSuiteV1Filter`, `InsertSuite`, `CTEInlineSuite` / `CTEHintSuite`, `CachedTableSuite`, `CreateTableAsSelectSuite` / `DDLParserSuite`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude (Claude Code, Opus 4.7)
Closes#55949 from cloud-fan/parser-identifier-cte-placement.
Authored-by: Wenchen Fan <wenchen@databricks.com>
Signed-off-by: Wenchen Fan <wenchen@databricks.com>
cloud-fan added a commit that referenced this pull request May 20, 2026
### What changes were proposed in this pull request?
Root-cause fix for SPARK-46625. Supersedes #55706.
Push `PlanWithUnresolvedIdentifier` into the command's identifier slot at parse time, for every parser-built command, instead of wrapping the whole command. With this change, `CTESubstitution` sees the real `CTEInChildren` command directly and places `WithCTE` on the command's children by construction -- the invalid `WithCTE(InsertIntoStatement, ...)` / `WithCTE(CreateTableAsSelect, ...)` shape never appears.
**Parser placement (`AstBuilder`):**
- `AstBuilder.withInsertInto`, `visitCreateTable`, `visitReplaceTable` build the command directly. A new helper `buildWriteTableSlot` returns `NamedRelation` and serves both `InsertIntoStatement.table` and `OverwriteByExpression.table` (whose slot is typed `NamedRelation`). CTAS/RTAS use single-arg `withIdentClause` to put the placeholder in the `name` slot.
- `visitCacheTable`'s AS path uses a new helper `buildCacheTableAsSelectName` to build the temp-view name as an `Expression`. The non-AS path uses single-arg `withIdentClause`.
**`CacheTableAsSelect.tempViewName: String -> Expression`:**
The last identifier slot still expressed as a plain `String` is now an `Expression`. The parser produces a non-null string `Literal` for direct identifiers and `IDENTIFIER('literal')`, or an `ExpressionWithUnresolvedIdentifier` for `IDENTIFIER(<non-literal>)`. The single-part invariant is validated at parse time for literal cases and at materialization for the non-literal case. `CheckAnalysis` enforces the post-analysis invariant that `tempViewName` is a non-null string `Literal`, and `tempViewNameString` extracts the string for execution. The expression slot is naturally visited by `transformExpressions`, so `ResolveIdentifierClause`'s existing `ExpressionWithUnresolvedIdentifier` branch resolves it without any new code path.
**Placeholder shape (`unresolved.scala`):**
- `PlanWithUnresolvedIdentifier` mixes in `NamedRelation` so it can occupy `OverwriteByExpression.table` (typed `NamedRelation`) directly.
- It does **not** extend `CTEInChildren`. With the in-slot placement plus the `CacheTableAsSelect` refactor, no parser caller places the placeholder as the substitution root of a `WITH ... <command>` subtree, so a `CTEInChildren` safety net has no reachable path under the current grammar.
**Materialization (`ResolveIdentifierClause`):**
`InsertIntoStatement.table` and `V2WriteCommand.table` are non-child `LogicalPlan` slots (`child = query`), so the default `resolveOperatorsUp` traversal never visits placeholders inside them. Two special-cases recurse explicitly:
- `case i: InsertIntoStatement if i.table.isInstanceOf[PlanWithUnresolvedIdentifier] => ...`
- `case w: V2WriteCommand if w.table.isInstanceOf[PlanWithUnresolvedIdentifier] => ...`
Each case extracts the placeholder with a single `asInstanceOf` at the top of the body and inlines the `identifierExpr.resolved && childrenResolved` check, returning the unchanged command when not yet ready.
The `V2WriteCommand` match dispatches via the abstract `withNewTable(NamedRelation)` and pattern-matches the materialized result as `NamedRelation`, throwing an internal error otherwise. Only `OverwriteByExpression` is parser-built with a placeholder in `table` today; matching the trait keeps the rule consistent for any future analyzer-built node in the same shape.
**Tree-pattern propagation (`statements.scala`, `v2Commands.scala`):**
`InsertIntoStatement` and `V2WriteCommand` override `getDefaultTreePatternBits` to union `table.treePatternBits`, so `containsPattern(...)` pruning correctly reports patterns (`PARAMETER`, `PLAN_WITH_UNRESOLVED_IDENTIFIER`) living in `table`.
**Parameter binding (`parameters.scala`):**
`BindParameters.bind` pattern-matches both `InsertIntoStatement` and `V2WriteCommand` to recurse into `table`. Without this, `INSERT ... IDENTIFIER(:p)` and `INSERT INTO REPLACE WHERE ... IDENTIFIER(:p)` under `spark.sql.legacy.parameterSubstitution.constantsOnly=true` would fail to bind the parameter.
**`CreateTableAsSelect.name` / `ReplaceTableAsSelect.name`:**
Already children via `V2CreateTableAsSelectPlan.childrenToAnalyze`, so no extra traversal hook is needed for them.
The post-hoc `WithCTE(c: CTEInChildren, _) => c.withCTEDefs(cteDefs)` collapse in `ResolveIdentifierClause` from #55706 is removed entirely -- no command shape reaches the analyzer needing it.
### Why are the changes needed?
```sql
WITH t AS (...)
INSERT [INTO|OVERWRITE] TABLE IDENTIFIER('t')
SELECT * FROM t
```
previously produced an analysed plan with `WithCTE` wrapping the `InsertIntoStatement` -- a structurally invalid shape. Plug-in datasources that re-analyse the `InsertIntoStatement`'s query subtree throw `NoSuchElementException: key not found`. Zero-day issue since SPARK-46625.
#55706 fixed the symptom by collapsing `WithCTE(c: CTEInChildren, defs)` after the fact in `ResolveIdentifierClause`. That works but encodes an undo for a placement that should never have happened, and turned out to be unsafe: after a later analyzer rewrite (e.g. `RewriteDeleteFromTable` rewriting `WithCTE(DeleteFromTable, defs)` into `WithCTE(ReplaceData, defs)`), the collapse would push `WithCTE` into `ReplaceData.query` and orphan the CTE refs in `ReplaceData.condition` / `groupFilterCondition`, breaking 9 v2 DML rCTE tests on CI. The review on #55706 (#55706 (comment)) asked for the root-cause fix; this PR implements it.
Credit to stevomitric for surfacing in the original PR thread that the parser-level placement also has to work for legacy parameter substitution -- that's why this PR adds the targeted `BindParameters` and tree-pattern-bits handling for `InsertIntoStatement.table` and `V2WriteCommand.table`.
### Does this PR introduce _any_ user-facing change?
No behavior change, but a minor timing change in `visitCreateTable` / `visitReplaceTable`: CTAS/RTAS validation errors (`Schema may not be specified in a CTAS statement`, `Partition column types may not be specified...`, `Constraints may not be specified...`) for non-literal identifiers now fire at parse time rather than at identifier resolution. Same error messages and same `ctx`; only the moment of throwing moves earlier. Fail-fast improvement, not a regression.
### How was this patch tested?
New tests in `ParametersSuite`:
- `WITH ... INSERT OVERWRITE TABLE IDENTIFIER(:p) SELECT ... FROM cte`
- `WITH ... INSERT INTO IDENTIFIER(:p) SELECT ... FROM cte`
- `CREATE TABLE IDENTIFIER(:p) AS WITH ... SELECT ... FROM cte`
- `INSERT IDENTIFIER(:p) under legacy parameter substitution` (covers `spark.sql.legacy.parameterSubstitution.constantsOnly=true`, which exercises the `BindParameters` recursion into `InsertIntoStatement.table`)
- `WITH ... INSERT INTO IDENTIFIER(:p) REPLACE WHERE ... -- parser` (asserts the placeholder lives in `OverwriteByExpression.table` and no `WithCTE(CTEInChildren, _)` shape survives `CTESubstitution`)
- `BindParameters recurses into OverwriteByExpression.table` (rule-level test; full analysis would require a v2 catalog)
- `CACHE TABLE IDENTIFIER(...) AS WITH ... SELECT ... -- parser` (asserts `tempViewName` holds `ExpressionWithUnresolvedIdentifier` and no `WithCTE(CTEInChildren, _)` shape survives `CTESubstitution`)
- `REPLACE TABLE IDENTIFIER(...) AS WITH ... SELECT ... -- parser` (mirrors the CTAS test for RTAS)
Each CTE test asserts that no `WithCTE(CTEInChildren, _)` shape leaks through analysis.
`DDLParserSuite.CACHE TABLE` updated to construct `CacheTableAsSelect` with `Literal("t")` instead of `"t"` for the new `Expression` slot.
Existing suites run clean: `ParametersSuite`, `DeltaBased/GroupBased Delete/Update/Merge` suites (including the rCTE-with-DML tests that were the original CI failures), `DataSourceV2DataFrameSuite`, `SQLViewSuite`, `IdentifierClauseParserSuite`, `AnalysisSuite` / `PlanParserSuite` / `AnalysisErrorSuite` and siblings, `DataSourceV2SQLSuiteV1Filter`, `InsertSuite`, `CTEInlineSuite` / `CTEHintSuite`, `CachedTableSuite`, `CreateTableAsSelectSuite` / `DDLParserSuite`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude (Claude Code, Opus 4.7)
Closes#55949 from cloud-fan/parser-identifier-cte-placement.
Authored-by: Wenchen Fan <wenchen@databricks.com>
Signed-off-by: Wenchen Fan <wenchen@databricks.com>
(cherry picked from commit b643237)
Signed-off-by: Wenchen Fan <wenchen@databricks.com>
cloud-fan added a commit that referenced this pull request May 20, 2026
### What changes were proposed in this pull request?
Root-cause fix for SPARK-46625. Supersedes #55706.
Push `PlanWithUnresolvedIdentifier` into the command's identifier slot at parse time, for every parser-built command, instead of wrapping the whole command. With this change, `CTESubstitution` sees the real `CTEInChildren` command directly and places `WithCTE` on the command's children by construction -- the invalid `WithCTE(InsertIntoStatement, ...)` / `WithCTE(CreateTableAsSelect, ...)` shape never appears.
**Parser placement (`AstBuilder`):**
- `AstBuilder.withInsertInto`, `visitCreateTable`, `visitReplaceTable` build the command directly. A new helper `buildWriteTableSlot` returns `NamedRelation` and serves both `InsertIntoStatement.table` and `OverwriteByExpression.table` (whose slot is typed `NamedRelation`). CTAS/RTAS use single-arg `withIdentClause` to put the placeholder in the `name` slot.
- `visitCacheTable`'s AS path uses a new helper `buildCacheTableAsSelectName` to build the temp-view name as an `Expression`. The non-AS path uses single-arg `withIdentClause`.
**`CacheTableAsSelect.tempViewName: String -> Expression`:**
The last identifier slot still expressed as a plain `String` is now an `Expression`. The parser produces a non-null string `Literal` for direct identifiers and `IDENTIFIER('literal')`, or an `ExpressionWithUnresolvedIdentifier` for `IDENTIFIER(<non-literal>)`. The single-part invariant is validated at parse time for literal cases and at materialization for the non-literal case. `CheckAnalysis` enforces the post-analysis invariant that `tempViewName` is a non-null string `Literal`, and `tempViewNameString` extracts the string for execution. The expression slot is naturally visited by `transformExpressions`, so `ResolveIdentifierClause`'s existing `ExpressionWithUnresolvedIdentifier` branch resolves it without any new code path.
**Placeholder shape (`unresolved.scala`):**
- `PlanWithUnresolvedIdentifier` mixes in `NamedRelation` so it can occupy `OverwriteByExpression.table` (typed `NamedRelation`) directly.
- It does **not** extend `CTEInChildren`. With the in-slot placement plus the `CacheTableAsSelect` refactor, no parser caller places the placeholder as the substitution root of a `WITH ... <command>` subtree, so a `CTEInChildren` safety net has no reachable path under the current grammar.
**Materialization (`ResolveIdentifierClause`):**
`InsertIntoStatement.table` and `V2WriteCommand.table` are non-child `LogicalPlan` slots (`child = query`), so the default `resolveOperatorsUp` traversal never visits placeholders inside them. Two special-cases recurse explicitly:
- `case i: InsertIntoStatement if i.table.isInstanceOf[PlanWithUnresolvedIdentifier] => ...`
- `case w: V2WriteCommand if w.table.isInstanceOf[PlanWithUnresolvedIdentifier] => ...`
Each case extracts the placeholder with a single `asInstanceOf` at the top of the body and inlines the `identifierExpr.resolved && childrenResolved` check, returning the unchanged command when not yet ready.
The `V2WriteCommand` match dispatches via the abstract `withNewTable(NamedRelation)` and pattern-matches the materialized result as `NamedRelation`, throwing an internal error otherwise. Only `OverwriteByExpression` is parser-built with a placeholder in `table` today; matching the trait keeps the rule consistent for any future analyzer-built node in the same shape.
**Tree-pattern propagation (`statements.scala`, `v2Commands.scala`):**
`InsertIntoStatement` and `V2WriteCommand` override `getDefaultTreePatternBits` to union `table.treePatternBits`, so `containsPattern(...)` pruning correctly reports patterns (`PARAMETER`, `PLAN_WITH_UNRESOLVED_IDENTIFIER`) living in `table`.
**Parameter binding (`parameters.scala`):**
`BindParameters.bind` pattern-matches both `InsertIntoStatement` and `V2WriteCommand` to recurse into `table`. Without this, `INSERT ... IDENTIFIER(:p)` and `INSERT INTO REPLACE WHERE ... IDENTIFIER(:p)` under `spark.sql.legacy.parameterSubstitution.constantsOnly=true` would fail to bind the parameter.
**`CreateTableAsSelect.name` / `ReplaceTableAsSelect.name`:**
Already children via `V2CreateTableAsSelectPlan.childrenToAnalyze`, so no extra traversal hook is needed for them.
The post-hoc `WithCTE(c: CTEInChildren, _) => c.withCTEDefs(cteDefs)` collapse in `ResolveIdentifierClause` from #55706 is removed entirely -- no command shape reaches the analyzer needing it.
### Why are the changes needed?
```sql
WITH t AS (...)
INSERT [INTO|OVERWRITE] TABLE IDENTIFIER('t')
SELECT * FROM t
```
previously produced an analysed plan with `WithCTE` wrapping the `InsertIntoStatement` -- a structurally invalid shape. Plug-in datasources that re-analyse the `InsertIntoStatement`'s query subtree throw `NoSuchElementException: key not found`. Zero-day issue since SPARK-46625.
#55706 fixed the symptom by collapsing `WithCTE(c: CTEInChildren, defs)` after the fact in `ResolveIdentifierClause`. That works but encodes an undo for a placement that should never have happened, and turned out to be unsafe: after a later analyzer rewrite (e.g. `RewriteDeleteFromTable` rewriting `WithCTE(DeleteFromTable, defs)` into `WithCTE(ReplaceData, defs)`), the collapse would push `WithCTE` into `ReplaceData.query` and orphan the CTE refs in `ReplaceData.condition` / `groupFilterCondition`, breaking 9 v2 DML rCTE tests on CI. The review on #55706 (#55706 (comment)) asked for the root-cause fix; this PR implements it.
Credit to stevomitric for surfacing in the original PR thread that the parser-level placement also has to work for legacy parameter substitution -- that's why this PR adds the targeted `BindParameters` and tree-pattern-bits handling for `InsertIntoStatement.table` and `V2WriteCommand.table`.
### Does this PR introduce _any_ user-facing change?
No behavior change, but a minor timing change in `visitCreateTable` / `visitReplaceTable`: CTAS/RTAS validation errors (`Schema may not be specified in a CTAS statement`, `Partition column types may not be specified...`, `Constraints may not be specified...`) for non-literal identifiers now fire at parse time rather than at identifier resolution. Same error messages and same `ctx`; only the moment of throwing moves earlier. Fail-fast improvement, not a regression.
### How was this patch tested?
New tests in `ParametersSuite`:
- `WITH ... INSERT OVERWRITE TABLE IDENTIFIER(:p) SELECT ... FROM cte`
- `WITH ... INSERT INTO IDENTIFIER(:p) SELECT ... FROM cte`
- `CREATE TABLE IDENTIFIER(:p) AS WITH ... SELECT ... FROM cte`
- `INSERT IDENTIFIER(:p) under legacy parameter substitution` (covers `spark.sql.legacy.parameterSubstitution.constantsOnly=true`, which exercises the `BindParameters` recursion into `InsertIntoStatement.table`)
- `WITH ... INSERT INTO IDENTIFIER(:p) REPLACE WHERE ... -- parser` (asserts the placeholder lives in `OverwriteByExpression.table` and no `WithCTE(CTEInChildren, _)` shape survives `CTESubstitution`)
- `BindParameters recurses into OverwriteByExpression.table` (rule-level test; full analysis would require a v2 catalog)
- `CACHE TABLE IDENTIFIER(...) AS WITH ... SELECT ... -- parser` (asserts `tempViewName` holds `ExpressionWithUnresolvedIdentifier` and no `WithCTE(CTEInChildren, _)` shape survives `CTESubstitution`)
- `REPLACE TABLE IDENTIFIER(...) AS WITH ... SELECT ... -- parser` (mirrors the CTAS test for RTAS)
Each CTE test asserts that no `WithCTE(CTEInChildren, _)` shape leaks through analysis.
`DDLParserSuite.CACHE TABLE` updated to construct `CacheTableAsSelect` with `Literal("t")` instead of `"t"` for the new `Expression` slot.
Existing suites run clean: `ParametersSuite`, `DeltaBased/GroupBased Delete/Update/Merge` suites (including the rCTE-with-DML tests that were the original CI failures), `DataSourceV2DataFrameSuite`, `SQLViewSuite`, `IdentifierClauseParserSuite`, `AnalysisSuite` / `PlanParserSuite` / `AnalysisErrorSuite` and siblings, `DataSourceV2SQLSuiteV1Filter`, `InsertSuite`, `CTEInlineSuite` / `CTEHintSuite`, `CachedTableSuite`, `CreateTableAsSelectSuite` / `DDLParserSuite`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude (Claude Code, Opus 4.7)
Closes#55949 from cloud-fan/parser-identifier-cte-placement.
Authored-by: Wenchen Fan <wenchen@databricks.com>
Signed-off-by: Wenchen Fan <wenchen@databricks.com>
(cherry picked from commit b643237)
Signed-off-by: Wenchen Fan <wenchen@databricks.com>
Sign up for freeto 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

@stevomitric@cloud-fan