001package gudusoft.gsqlparser.ir.semantic.builder;
002
003import gudusoft.gsqlparser.EBoundaryType;
004import gudusoft.gsqlparser.EDbObjectType;
005import gudusoft.gsqlparser.EDbVendor;
006import gudusoft.gsqlparser.EExpressionType;
007import gudusoft.gsqlparser.EGroupingSetType;
008import gudusoft.gsqlparser.EJoinType;
009import gudusoft.gsqlparser.ELimitRowType;
010import gudusoft.gsqlparser.EPseudoTableType;
011import gudusoft.gsqlparser.ESetOperatorType;
012import gudusoft.gsqlparser.ETableSource;
013import gudusoft.gsqlparser.EUniqueRowFilterType;
014import gudusoft.gsqlparser.TSourceToken;
015import gudusoft.gsqlparser.ir.semantic.ColumnRef;
016import gudusoft.gsqlparser.ir.semantic.Diagnostic;
017import gudusoft.gsqlparser.ir.semantic.DiagnosticCode;
018import gudusoft.gsqlparser.ir.semantic.Severity;
019import gudusoft.gsqlparser.ir.semantic.SourceSpan;
020import gudusoft.gsqlparser.ir.semantic.FrameBound;
021import gudusoft.gsqlparser.ir.semantic.GroupingElement;
022import gudusoft.gsqlparser.nodes.TParseTreeNode;
023import gudusoft.gsqlparser.ir.semantic.LineageEdge;
024import gudusoft.gsqlparser.ir.semantic.LineageRef;
025import gudusoft.gsqlparser.ir.semantic.OutputColumn;
026import gudusoft.gsqlparser.ir.semantic.RelationKind;
027import gudusoft.gsqlparser.ir.semantic.RelationSource;
028import gudusoft.gsqlparser.ir.semantic.RowLimit;
029import gudusoft.gsqlparser.ir.semantic.RowLimitKind;
030import gudusoft.gsqlparser.ir.semantic.SemanticProgram;
031import gudusoft.gsqlparser.ir.semantic.SetOperator;
032import gudusoft.gsqlparser.ir.semantic.StatementGraph;
033import gudusoft.gsqlparser.ir.semantic.TargetRelation;
034import gudusoft.gsqlparser.ir.semantic.WindowFrame;
035import gudusoft.gsqlparser.ir.semantic.WindowSpec;
036import gudusoft.gsqlparser.ir.semantic.binding.ColumnBinding;
037import gudusoft.gsqlparser.ir.semantic.joinanalysis.ColumnResolution;
038import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinAnalysisFacts;
039import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpoint;
040import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpointKind;
041import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEntity;
042import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinGraph;
043import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinSourceSyntax;
044import gudusoft.gsqlparser.ir.semantic.joinanalysis.Predicate;
045import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateKind;
046import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateOperand;
047import gudusoft.gsqlparser.ir.semantic.joinanalysis.QueryBlockScope;
048import gudusoft.gsqlparser.ir.semantic.joinanalysis.ScopeKind;
049import gudusoft.gsqlparser.ir.semantic.joinanalysis.SemanticJoinType;
050import gudusoft.gsqlparser.ir.semantic.binding.FromSubqueryNaming;
051import gudusoft.gsqlparser.ir.semantic.binding.NameBindingProvider;
052import gudusoft.gsqlparser.ir.semantic.binding.RelationBinding;
053import gudusoft.gsqlparser.ir.semantic.binding.UsingScope;
054import gudusoft.gsqlparser.nodes.TCTE;
055import gudusoft.gsqlparser.nodes.TCTEList;
056import gudusoft.gsqlparser.nodes.TExpression;
057import gudusoft.gsqlparser.nodes.TExpressionList;
058import gudusoft.gsqlparser.nodes.TFetchFirstClause;
059import gudusoft.gsqlparser.nodes.TFunctionCall;
060import gudusoft.gsqlparser.nodes.TLimitClause;
061import gudusoft.gsqlparser.nodes.TOffsetClause;
062import gudusoft.gsqlparser.nodes.TTopClause;
063import gudusoft.gsqlparser.nodes.TGroupBy;
064import gudusoft.gsqlparser.nodes.TGroupByItem;
065import gudusoft.gsqlparser.nodes.TGroupByItemList;
066import gudusoft.gsqlparser.nodes.TGroupingExpressionItem;
067import gudusoft.gsqlparser.nodes.TGroupingSet;
068import gudusoft.gsqlparser.nodes.TGroupingSetItem;
069import gudusoft.gsqlparser.nodes.TGroupingSetItemList;
070import gudusoft.gsqlparser.nodes.TRollupCube;
071import gudusoft.gsqlparser.nodes.TJoin;
072import gudusoft.gsqlparser.nodes.TJoinItem;
073import gudusoft.gsqlparser.nodes.TJoinItemList;
074import gudusoft.gsqlparser.nodes.TJoinList;
075import gudusoft.gsqlparser.nodes.TObjectName;
076import gudusoft.gsqlparser.nodes.TObjectNameList;
077import gudusoft.gsqlparser.nodes.TOrderBy;
078import gudusoft.gsqlparser.nodes.TOutputClause;
079import gudusoft.gsqlparser.nodes.TReturningClause;
080import gudusoft.gsqlparser.nodes.TOrderByItem;
081import gudusoft.gsqlparser.nodes.TOrderByItemList;
082import gudusoft.gsqlparser.nodes.TParseTreeVisitor;
083import gudusoft.gsqlparser.nodes.TPartitionClause;
084import gudusoft.gsqlparser.nodes.TPivotClause;
085import gudusoft.gsqlparser.nodes.TPivotInClause;
086import gudusoft.gsqlparser.nodes.TPivotedTable;
087import gudusoft.gsqlparser.nodes.TUnpivotInClause;
088import gudusoft.gsqlparser.nodes.TUnpivotInClauseItem;
089import gudusoft.gsqlparser.nodes.TResultColumn;
090import gudusoft.gsqlparser.nodes.TResultColumnList;
091import gudusoft.gsqlparser.nodes.TSelectDistinct;
092import gudusoft.gsqlparser.nodes.TTable;
093import gudusoft.gsqlparser.nodes.TWhereClause;
094import gudusoft.gsqlparser.nodes.TWindowDef;
095import gudusoft.gsqlparser.nodes.TWithinGroup;
096import gudusoft.gsqlparser.nodes.TWindowFrame;
097import gudusoft.gsqlparser.nodes.TWindowFrameBoundary;
098import gudusoft.gsqlparser.resolver2.ResolutionStatus;
099import gudusoft.gsqlparser.EInsertSource;
100import gudusoft.gsqlparser.nodes.TColumnDefinition;
101import gudusoft.gsqlparser.nodes.TColumnDefinitionList;
102import gudusoft.gsqlparser.nodes.TViewAliasClause;
103import gudusoft.gsqlparser.nodes.TViewAliasItem;
104import gudusoft.gsqlparser.nodes.TViewAliasItemList;
105import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement;
106import gudusoft.gsqlparser.stmt.TCreateViewSqlStatement;
107import gudusoft.gsqlparser.stmt.TDeleteSqlStatement;
108import gudusoft.gsqlparser.stmt.TInsertSqlStatement;
109import gudusoft.gsqlparser.stmt.TMergeSqlStatement;
110import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
111import gudusoft.gsqlparser.stmt.TUpdateSqlStatement;
112import gudusoft.gsqlparser.nodes.TMergeWhenClause;
113import gudusoft.gsqlparser.nodes.TMergeUpdateClause;
114import gudusoft.gsqlparser.nodes.TMergeInsertClause;
115
116import java.util.ArrayDeque;
117import java.util.ArrayList;
118import java.util.Collections;
119import java.util.Deque;
120import java.util.EnumSet;
121import java.util.HashMap;
122import java.util.HashSet;
123import java.util.IdentityHashMap;
124import java.util.LinkedHashMap;
125import java.util.LinkedHashSet;
126import java.util.List;
127import java.util.Locale;
128import java.util.Map;
129import java.util.Set;
130
131/**
132 * Builds a {@link SemanticProgram} from a parsed and resolved
133 * {@link TSelectSqlStatement}.
134 *
135 * <p>Current scope (after slice 9): SELECT with one or more base-table or
136 * CTE sources, optional WHERE, optional JOIN of base tables with ON
137 * conditions, optional GROUP BY (slice 6), optional WITH clause including
138 * chained CTEs (each CTE sees the ones declared strictly before it),
139 * optional FROM-clause subquery (slice 5), optional row-deduplication via
140 * {@code SELECT DISTINCT} or Oracle's {@code SELECT UNIQUE} synonym
141 * (slice 8 — see {@link StatementGraph#isDistinct()}), optional ORDER BY
142 * over physical column references or column-bearing expressions
143 * (slice 9 — see {@link StatementGraph#getOrderByColumnRefs()}).
144 * Expression projections like {@code salary * 2 AS doubled} or
145 * {@code a.x + a.y} are accepted and marked
146 * {@link OutputColumn#isDerived()}; aggregate function calls (slice 6)
147 * are flagged via {@link OutputColumn#isAggregate()}.
148 *
149 * <p>Slice 9 lifts {@code ORDER BY} for sort keys that are physical
150 * column references or expressions over them. The collected references
151 * surface as {@link StatementGraph#getOrderByColumnRefs()}. Sort
152 * direction ({@code ASC}/{@code DESC}) and null placement
153 * ({@code NULLS FIRST}/{@code NULLS LAST}) are presentation metadata
154 * and are not modelled. Ordinal forms ({@code ORDER BY 1}) and
155 * projection-alias forms ({@code SELECT id AS x ... ORDER BY x}) are
156 * rejected so the dependency information is never silently lost; a
157 * later slice can model output-position references explicitly. The
158 * canonical lineage model (slice 7) deliberately ignores ORDER BY —
159 * sort order changes presentation, not column dependency or row-set
160 * membership.
161 *
162 * <p>Row-limit clauses ({@code LIMIT}, {@code TOP}, {@code OFFSET},
163 * {@code FETCH FIRST}) are rejected statement-wide, including the
164 * SQL Server-style {@code ORDER BY ... OFFSET ... FETCH NEXT}. With
165 * a row-limit present, {@code ORDER BY} ceases to be presentation-only
166 * and starts deciding which rows survive — the canonical-model
167 * exclusion would no longer be sound, so the entire statement is out
168 * of scope until a future slice models row-limit semantics.
169 *
170 * <p>Slice 10 lifts {@code HAVING}: the predicate's column references
171 * are collected into {@link StatementGraph#getHavingColumnRefs()} via
172 * {@link #buildHavingColumnRefs}. The same visitor pattern as projection
173 * and ORDER BY rejects subqueries (scalar, EXISTS, IN-SELECT, ANY/ALL/
174 * SOME) and window functions before {@link #collectColumnRefs} runs, so
175 * inner-scope refs never leak. HAVING without GROUP BY is supported (the
176 * parser still attaches a {@link TGroupBy} node with empty items).
177 * HAVING is row-influence semantically but does not contribute to the
178 * canonical lineage model — see
179 * {@link StatementGraph#getHavingColumnRefs()} for why.
180 *
181 * <p>Slice 11 lifted uncorrelated scalar subqueries in projection;
182 * scalar bodies are extracted as their own statements via
183 * {@link #extractScalarSubqueriesAsStatements} with the synthetic-name
184 * convention {@code <scalar_subquery_<index>>}.
185 *
186 * <p>Slice 12 lifts set operations (UNION / UNION ALL / INTERSECT /
187 * INTERSECT ALL / MINUS / MINUS ALL / EXCEPT / EXCEPT ALL) at the top
188 * level and as CTE bodies. Each branch becomes its own
189 * {@link StatementGraph} with synthetic name
190 * {@code <set_op_branch_<index>>}; the outer set-op statement carries
191 * empty {@code relations} and lineage edges fan out per-position to
192 * each branch. The flatten descends the left-leaning AST iteratively
193 * (per CLAUDE.md — no recursion on {@code leftStmt}/{@code rightStmt}).
194 * See {@link #buildSetOpProgram}.
195 *
196 * <p>Slice 22 lifts window-function frame clauses
197 * ({@code ROWS}/{@code RANGE}/{@code GROUPS BETWEEN ...}); the frame
198 * unit, start bound, and optional end bound are captured in
199 * {@link WindowFrame} hung off
200 * {@link WindowSpec#getFrame()}. Frame info is presentation-only
201 * (dlineage XML harvests no frame information) and does NOT contribute
202 * to the canonical lineage model — same status as slice-13's
203 * PARTITION BY / OVER ORDER BY refs. Per-bound EXCLUDE clauses
204 * (Netezza-reachable) and non-constant offsets (PG
205 * {@code simple_object_name_t}, ANSI {@code parenthesis_t}) are still
206 * rejected.
207 *
208 * <p>Still rejected: {@code WITH RECURSIVE}, {@code DISTINCT ON (...)}
209 * and other non-{@code DISTINCT}/{@code UNIQUE} row-filters,
210 * scalar-body constant-only projections (zero column refs),
211 * correlated scalar subqueries, scalar bodies with
212 * subqueries in WHERE/JOIN ON/GROUP BY, multi-column scalar inner,
213 * scalar subqueries embedded in larger projection expressions including
214 * EXISTS-in-projection, embedded window functions in larger projection
215 * expressions, window functions in scalar-subquery bodies, window
216 * functions in WHERE/JOIN ON/GROUP BY/HAVING/ORDER BY, empty
217 * {@code OVER ()}, frame clauses with non-constant offsets (PG
218 * {@code simple_object_name_t}, ANSI {@code parenthesis_t}), frame
219 * {@code EXCLUDE} clauses (Netezza-reachable), named windows,
220 * vendor-specific window extensions ({@code FILTER (WHERE ...)},
221 * {@code WITHIN GROUP},
222 * {@code KEEP DENSE_RANK}, Hive {@code DISTRIBUTE BY}/{@code CLUSTER BY}/
223 * {@code SORT BY}/{@code PARTITION BY ... SORT (...)}), non-physical
224 * {@code PARTITION BY} / OVER {@code ORDER BY} refs (literals,
225 * subqueries, function calls, expressions, expression-alias references),
226 * window function names outside the slice-13 allowlist,
227 * (slice 63 lifts explicit {@code CROSS JOIN}, slice 64 lifts
228 * {@code JOIN ... USING (...)}, and slice 66 lifts {@code NATURAL JOIN}
229 * at outer / CTE-body / FROM-subquery-body call sites; all three stay
230 * rejected inside scalar / set-op-branch / set-op-CTE / predicate bodies;
231 * NATURAL additionally requires resolvable catalog metadata on both
232 * sides, with a side-specific reject otherwise), duplicate aliases,
233 * Oracle
234 * {@code ORDER SIBLINGS BY}, Teradata {@code ORDER BY ... RESET WHEN},
235 * row-limit clauses, ORDER BY ordinals/aliases, Teradata {@code QUALIFY}
236 * clause, set operations nested in FROM-subquery / scalar bodies,
237 * mixed-operator and mixed-{@code _ALL} set-op chains, set-op outer
238 * ORDER BY / row-limit clauses, set-op internal-node modifiers, branch
239 * column-count mismatch, set-op branches with FROM-subquery / scalar
240 * projection / their own CTE list, nested WITH on set-op CTE body. The
241 * builder fails fast outside this scope so callers see the unsupported
242 * case immediately rather than receiving a half-built IR.
243 */
244public final class SemanticIRBuilder {
245
246    /**
247     * Reserved name prefix for synthetic scalar-subquery body
248     * statements (slice 11). Names take the form
249     * {@code "<scalar_subquery_<index>>"}; the angle brackets ensure
250     * no collision with real CTE names or FROM-clause aliases.
251     * {@link #isScalarSyntheticName(String)} is the only authorised
252     * detector — both this builder and
253     * {@code SemanticIRProjector.BodyIndexes} use it so the convention
254     * lives in one place.
255     */
256    public static final String SCALAR_BODY_PREFIX = "<scalar_subquery_";
257
258    /**
259     * Strict regex for synthetic scalar-subquery-body names. Format is
260     * exactly {@code <scalar_subquery_<digits>>} — pinning the digits
261     * suffix and the closing angle bracket prevents a real (quoted)
262     * CTE alias that happens to begin with the prefix from being
263     * misclassified as a synthetic name and silently skipped by
264     * {@code BodyIndexes}.
265     */
266    private static final java.util.regex.Pattern SCALAR_NAME_PATTERN =
267            java.util.regex.Pattern.compile("<scalar_subquery_\\d+>");
268
269    /**
270     * True iff {@code name} is a synthetic scalar-subquery-body name
271     * created by this builder (slice 11). Used by
272     * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when
273     * building the CTE/FROM-subquery name lookup tables — scalar
274     * bodies are reached only via lineage edges, never via relations.
275     *
276     * <p>The match is strict: the name must be the full reserved
277     * pattern {@code <scalar_subquery_<digits>>}. A real CTE alias
278     * that happens to start with {@code <scalar_subquery_} but
279     * doesn't match the digits-and-closing-bracket suffix is NOT
280     * skipped (codex impl-review round-1 SHOULD 2).
281     */
282    public static boolean isScalarSyntheticName(String name) {
283        return name != null && SCALAR_NAME_PATTERN.matcher(name).matches();
284    }
285
286    /**
287     * Reserved name prefix for synthetic set-op-branch body statements
288     * (slice 12). Names take the form {@code "<set_op_branch_<index>>"};
289     * the angle brackets ensure no collision with real CTE names or
290     * FROM-clause aliases. {@link #isSetOpBranchSyntheticName(String)} is
291     * the only authorised detector — both this builder and
292     * {@code SemanticIRProjector.BodyIndexes} use it so the convention
293     * lives in one place (slice-11 process lesson #10 generalised).
294     */
295    public static final String SET_OP_BRANCH_PREFIX = "<set_op_branch_";
296
297    /**
298     * Strict regex for synthetic set-op-branch-body names. Format is
299     * exactly {@code <set_op_branch_<digits>>} — pinning the digits
300     * suffix and the closing angle bracket prevents a real (quoted) CTE
301     * alias that happens to begin with the prefix from being
302     * misclassified as a synthetic name and silently skipped by
303     * {@code BodyIndexes}.
304     */
305    private static final java.util.regex.Pattern SET_OP_BRANCH_NAME_PATTERN =
306            java.util.regex.Pattern.compile("^<set_op_branch_\\d+>$");
307
308    /**
309     * True iff {@code name} is a synthetic set-op-branch-body name
310     * created by this builder (slice 12). Used by
311     * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when
312     * building the CTE/FROM-subquery name lookup tables — set-op
313     * branches are reached only via lineage edges, never via relations.
314     *
315     * <p>The match is strict: the name must be the full reserved
316     * pattern {@code <set_op_branch_<digits>>}.
317     */
318    public static boolean isSetOpBranchSyntheticName(String name) {
319        return name != null && SET_OP_BRANCH_NAME_PATTERN.matcher(name).matches();
320    }
321
322    /**
323     * Reserved name prefix for synthetic predicate-subquery body statements
324     * (slice 23 — uncorrelated EXISTS extracted from outer-SELECT JOIN ON).
325     * Names take the form {@code "<predicate_subquery_<index>>"}; the angle
326     * brackets ensure no collision with real CTE names or FROM-clause aliases.
327     * {@link #isPredicateSubquerySyntheticName(String)} is the only authorised
328     * detector — both this builder and {@code SemanticIRProjector.BodyIndexes}
329     * use it so the convention lives in one place.
330     */
331    public static final String PREDICATE_BODY_PREFIX = "<predicate_subquery_";
332
333    /**
334     * Strict regex for synthetic predicate-subquery-body names. Format is
335     * exactly {@code <predicate_subquery_<digits>>}; pinning the digit suffix
336     * and the closing angle bracket prevents a real (quoted) CTE alias that
337     * happens to begin with the prefix from being misclassified as a synthetic
338     * name and silently skipped by {@code BodyIndexes}.
339     */
340    private static final java.util.regex.Pattern PREDICATE_BODY_NAME_PATTERN =
341            java.util.regex.Pattern.compile("<predicate_subquery_\\d+>");
342
343    /**
344     * True iff {@code name} is a synthetic predicate-subquery-body name
345     * created by this builder (slice 23). Used by
346     * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when
347     * building the CTE/FROM-subquery name lookup tables — predicate-subquery
348     * bodies are unreachable from outer (no relation edge, no lineage edge).
349     */
350    public static boolean isPredicateSubquerySyntheticName(String name) {
351        return name != null && PREDICATE_BODY_NAME_PATTERN.matcher(name).matches();
352    }
353
354    /**
355     * Aggregate function names recognized by the builder's per-output
356     * aggregate flag detection (slice 6 originated; slice 29 / slice 30
357     * extended). Treated as case-insensitive. Callers should go through
358     * {@link #isAggregateFunction(TExpression)} rather than reading this
359     * set directly.
360     *
361     * <p>Slice-29 extensions: dialect aggregates {@code listagg},
362     * {@code string_agg}, {@code group_concat}, {@code array_agg}.
363     * Slice-30 extension: {@code mode} (PostgreSQL ordered-set aggregate;
364     * admitted via the slice-29 WITHIN GROUP path under
365     * {@code findUnsupportedWithinGroupFunctionName}). Slice 30 also
366     * removes {@code mode} from {@link #WINDOW_FUNCTION_NAMES} via an
367     * explicit {@code s.remove("mode")} so the slice-13 window allowlist
368     * isn't widened — see {@link #WINDOW_FUNCTION_NAMES} JavaDoc and
369     * {@code DlineageXmlProjector.ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} for
370     * the matching window-vs-aggregate discriminator override.
371     */
372    private static final Set<String> AGGREGATE_FUNCTION_NAMES;
373    static {
374        Set<String> s = new HashSet<>();
375        s.add("count");
376        s.add("sum");
377        s.add("avg");
378        s.add("min");
379        s.add("max");
380        s.add("stddev");
381        s.add("variance");
382        s.add("var_samp");
383        s.add("var_pop");
384        s.add("stddev_samp");
385        s.add("stddev_pop");
386        // Common dialect-specific aggregates so the flag has fewer false negatives.
387        s.add("listagg");      // Oracle, PostgreSQL 16+
388        s.add("string_agg");   // PostgreSQL, SQL Server
389        s.add("group_concat"); // MySQL
390        s.add("array_agg");    // PostgreSQL, Snowflake, BigQuery
391        // Slice 30: PostgreSQL ordered-set aggregate. Unlike percentile_cont /
392        // percentile_disc / rank-family, mode() has no documented window form
393        // in any GSP-supported vendor; admitting it lets the WITHIN GROUP path
394        // accept it in JOIN ON predicate subqueries (slice 29 lift extension)
395        // AND lets DlineageXmlProjector mark its output aggregate=true.
396        // Defensive: WINDOW_FUNCTION_NAMES below subtracts mode after
397        // s.addAll(AGGREGATE_FUNCTION_NAMES) so mode() OVER (...) stays
398        // rejected by the slice-13 window allowlist.
399        s.add("mode");         // PostgreSQL ordered-set aggregate (slice 30)
400        AGGREGATE_FUNCTION_NAMES = Collections.unmodifiableSet(s);
401    }
402
403    /**
404     * Slice 42: hypothetical-set ordered-set aggregate function names that
405     * are ALSO valid window functions. Unlike {@link #AGGREGATE_FUNCTION_NAMES}
406     * these names are admitted as aggregates ONLY when the call carries a
407     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}
408     * {@link TWindowDef} attachment (Oracle / SQL Server parser style —
409     * {@code RANK(100) WITHIN GROUP (ORDER BY x.id)} produces
410     * {@code fn.getWindowDef()!=null}, {@code wd.isIncludingOverClause()==
411     * false}, {@code wd.getWithinGroup()!=null}). Any other shape — direct
412     * {@code fn.getWithinGroup()} (PG / Snowflake style),
413     * {@code fn.getWindowDef()} with {@code OVER (...)}, or no attachment
414     * at all — keeps the existing window-function classification.
415     *
416     * <p>The set is intentionally NOT merged into
417     * {@link #AGGREGATE_FUNCTION_NAMES} because that would also lift the PG
418     * direct-attachment hypothetical-set form ({@code rank(0.5) WITHIN GROUP
419     * (ORDER BY x.salary)}). Pre-plan probe ({@code /tmp/probe42/Probe42.java})
420     * confirmed PG dlineage XML for hypothetical-set is structurally
421     * indistinguishable from {@code rank() OVER (ORDER BY x)} (both emit
422     * {@code clauseType="orderby"} fdr) — admitting PG hypothetical-set
423     * would manufacture an {@code AGGREGATION_MISMATCH} divergence on the
424     * windowed form because the projector's
425     * {@code DlineageXmlProjector.isWindowFunctionResultset} cannot tell
426     * the two forms apart on PG.
427     *
428     * <p>The Oracle / MSSQL hypothetical-set form, by contrast, emits
429     * neither a {@code clauseType="orderby"} fdr nor a
430     * {@code clauseType="selectList"} fdr (probe-confirmed) — so the
431     * projector's slice-13 windowed-vs-aggregate discriminator returns
432     * {@code false} and the matching projector-side
433     * {@code AGGREGATE_FUNCTION_NAMES} entry marks the output aggregate.
434     * Their OVER form ({@code RANK() OVER (ORDER BY x.id)}) emits
435     * {@code clauseType="orderby"} as expected and stays correctly
436     * classified as windowed.
437     *
438     * <p>Vendor-gated to Oracle / MSSQL inside
439     * {@link #isAdmittedTopLevelWithinGroupAggregate} and
440     * {@link #findUnsupportedWithinGroupFunctionName}; the PG
441     * direct-attachment shape never satisfies the
442     * {@link #isWithinGroupOnlyWindowDef} predicate (because PG sets
443     * {@code fn.getWindowDef()==null}) so the carve-out cannot accidentally
444     * fire on PG.
445     */
446    private static final Set<String> HYPOTHETICAL_SET_AGGREGATE_NAMES;
447    static {
448        Set<String> s = new HashSet<>();
449        s.add("rank");
450        s.add("dense_rank");
451        s.add("percent_rank");
452        s.add("cume_dist");
453        HYPOTHETICAL_SET_AGGREGATE_NAMES = Collections.unmodifiableSet(s);
454    }
455
456    /**
457     * Slice 42: true iff {@code fn} is an Oracle / MSSQL hypothetical-set
458     * ordered-set aggregate call shape — {@code RANK} / {@code DENSE_RANK} /
459     * {@code PERCENT_RANK} / {@code CUME_DIST} with
460     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}
461     * {@link TWindowDef} attachment. Used both as a name-whitelist
462     * discriminator (so PG direct {@code fn.getWithinGroup()} cannot
463     * accidentally pass through, since PG sets {@code fn.getWindowDef()==
464     * null}) and as the {@link #isAggregateFunction} carve-out trigger.
465     */
466    private static boolean isHypotheticalSetWithinGroupCall(TFunctionCall fn) {
467        if (fn == null) return false;
468        if (!isWithinGroupOnlyWindowDef(fn.getWindowDef())) return false;
469        if (fn.getFunctionName() == null) return false;
470        String name = fn.getFunctionName().toString();
471        if (name == null || name.isEmpty()) return false;
472        return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains(
473                name.toLowerCase(Locale.ROOT));
474    }
475
476    /**
477     * Predicate-bearing join types accepted by the current builder.
478     * Slice 64: each must carry either an ON condition or a USING
479     * clause; the per-key {@code joinColumnRefs} emission happens in
480     * {@link #buildRelations} for USING and via
481     * {@link #collectColumnRefs} for ON. NATURAL, semi/anti,
482     * vendor-specific joins, and nested-join sources stay rejected so
483     * the IR cannot quietly drop a row-set predicate. The unqualified
484     * output-naming case for USING merged keys is deferred to S65.
485     */
486    private static final EnumSet<EJoinType> ALLOWED_PREDICATE_JOIN_TYPES = EnumSet.of(
487            EJoinType.inner,
488            EJoinType.left,
489            EJoinType.right,
490            EJoinType.full,
491            EJoinType.fullouter,
492            EJoinType.leftouter,
493            EJoinType.rightouter,
494            EJoinType.join,
495            // MySQL STRAIGHT_JOIN is an optimizer join-order hint with no
496            // effect on result semantics; it carries a normal ON/USING
497            // clause and mapSemanticJoinType() degrades it to INNER. Admit
498            // it on the predicate path so it emits a standard INNER join
499            // entity instead of aborting analysis with UNSUPPORTED_JOIN_TYPE.
500            EJoinType.straight
501    );
502
503    /**
504     * Slice 63 — join types admitted by the builder but that must NOT
505     * carry an ON or USING clause. Currently just {@code CROSS}; the
506     * tier exists so that future ON-less shapes can join the same path
507     * with the same shape contract. Slice 66 added a separate
508     * {@link #NATURAL_JOIN_TYPES} tier because NATURAL has its own
509     * catalog-required reject path that CROSS does not.
510     */
511    private static final EnumSet<EJoinType> ALLOWED_ON_LESS_JOIN_TYPES = EnumSet.of(
512            EJoinType.cross
513    );
514
515    /**
516     * Slice 66 — NATURAL join types. Each MUST NOT carry an ON or USING
517     * clause. Each MUST have resolvable catalog metadata on BOTH sides;
518     * a missing-catalog reject fires inside {@link #buildRelations}
519     * with a side-specific diagnostic. The shared-column list is
520     * inferred from the running {@link LeftOutputState} ∩ right's
521     * catalog and feeds into {@link #emitMergedJoinRefs} the same way
522     * a syntactically-declared USING list does.
523     */
524    private static final EnumSet<EJoinType> NATURAL_JOIN_TYPES = EnumSet.of(
525            EJoinType.natural,
526            EJoinType.natural_inner,
527            EJoinType.natural_left,
528            EJoinType.natural_right,
529            EJoinType.natural_leftouter,
530            EJoinType.natural_rightouter,
531            EJoinType.natural_full,
532            EJoinType.natural_fullouter
533    );
534
535    private static boolean isNaturalJoinType(EJoinType jt) {
536        return jt != null && NATURAL_JOIN_TYPES.contains(jt);
537    }
538
539    /**
540     * SQL Server lateral joins: {@code CROSS APPLY} / {@code OUTER APPLY}.
541     * The right operand is a correlated derived table (subquery or
542     * table-valued function) whose correlation lives <em>inside</em> the
543     * right operand (its WHERE clause for the subquery form, its function
544     * arguments for the TVF form) rather than on an ON/USING clause. So
545     * APPLY is admitted on the ON-less tier (like {@code CROSS}); it MUST
546     * NOT carry ON/USING. {@code mapSemanticJoinType} degrades
547     * {@code crossapply -> INNER} and {@code outerapply -> LEFT}; the
548     * lateral nature is carried by {@link JoinEntity#isLateral()} (mirrors
549     * how NATURAL is carried by {@code isNatural()} rather than its own
550     * join type). The correlation is preserved by the normal child
551     * StatementGraph the right derived table already produces, so it is
552     * NOT lifted onto the join edge (the predicate references the inner
553     * relation and the outer scope, not the right endpoint).
554     */
555    private static final EnumSet<EJoinType> LATERAL_JOIN_TYPES = EnumSet.of(
556            EJoinType.crossapply,
557            EJoinType.outerapply
558    );
559
560    private static boolean isLateralJoinType(EJoinType jt) {
561        return jt != null && LATERAL_JOIN_TYPES.contains(jt);
562    }
563
564    /**
565     * True when a join operand is an ANSI / PostgreSQL {@code LATERAL} derived
566     * table or table-valued function. Unlike the SQL Server APPLY forms (which
567     * are encoded in the join <em>type</em> and matched by
568     * {@link #isLateralJoinType}), the {@code LATERAL} keyword sits on the right
569     * operand itself: the parser captures it as that {@link TTable}'s start
570     * token (covers {@code , LATERAL (...)}, {@code CROSS JOIN LATERAL (...)},
571     * {@code JOIN LATERAL (...) ON ...}, and {@code LATERAL func(...)}). A plain
572     * derived table starts with {@code (} and a base relation with its name, so
573     * this never false-matches a non-lateral operand.
574     */
575    private static boolean isLateralTable(TTable t) {
576        if (t == null) {
577            return false;
578        }
579        // LATERAL always precedes a derived table / table-valued function /
580        // xmltable, never a base relation. So a plain object-name table that
581        // happens to be named `lateral` (legal where LATERAL is unreserved, or
582        // when quoted) is NOT a lateral join even though its start token reads
583        // `lateral` — exclude the base-relation shape before the token check.
584        if (t.getTableType() == ETableSource.objectname) {
585            return false;
586        }
587        TSourceToken st = t.getStartToken();
588        return st != null && "LATERAL".equalsIgnoreCase(st.toString());
589    }
590
591    private SemanticIRBuilder() {}
592
593    /**
594     * Thread-local sink for non-fatal {@link Severity#WARN} diagnostics
595     * raised <em>during</em> a build that should not abort the analysis.
596     *
597     * <p>The builder is fully static, so there is no instance field to
598     * accumulate warnings on and {@link SemanticProgram} is a pure data
599     * container. {@link SqlSemanticAnalyzer} is the single production
600     * consumer: it {@link #clearBuildDiagnostics() clears} this sink
601     * immediately before invoking a {@code build*} method and
602     * {@link #pendingBuildDiagnostics() drains} it immediately after a
603     * successful build, merging the result into the
604     * {@link AnalysisResult} diagnostics. The clear-before contract keeps
605     * the sink from leaking warnings across statements on the same thread.
606     *
607     * <p>Currently used only by the catalog-less {@code NATURAL JOIN}
608     * degrade path (see {@link #recordNaturalDegradeWarning}): rather than
609     * failing the whole analysis when shared-column expansion has no
610     * catalog, the builder degrades to a bare NATURAL join entity and
611     * records a {@link DiagnosticCode#NATURAL_CATALOG_REQUIRED} warning
612     * here.
613     */
614    private static final ThreadLocal<List<Diagnostic>> BUILD_DIAGNOSTICS =
615            new ThreadLocal<>();
616
617    /**
618     * Record a non-fatal build-time warning into the thread-local sink,
619     * lazily creating the backing list on first use. No-ops are
620     * impossible: the warning is always retained until the next
621     * {@link #clearBuildDiagnostics()} on this thread.
622     */
623    private static void recordBuildWarning(Diagnostic warning) {
624        List<Diagnostic> sink = BUILD_DIAGNOSTICS.get();
625        if (sink == null) {
626            sink = new ArrayList<>();
627            BUILD_DIAGNOSTICS.set(sink);
628        }
629        sink.add(warning);
630    }
631
632    /**
633     * Reset the thread-local warning sink. Consumers MUST call this before
634     * a {@code build*} invocation whose warnings they intend to read, so
635     * stale warnings from a prior build on the same thread are not
636     * observed.
637     */
638    public static void clearBuildDiagnostics() {
639        BUILD_DIAGNOSTICS.remove();
640    }
641
642    /**
643     * @return a copy of the non-fatal warnings accumulated since the last
644     *         {@link #clearBuildDiagnostics()} on this thread; never
645     *         {@code null}, possibly empty. Non-draining: the sink is left
646     *         intact (use {@link #drainBuildDiagnostics()} to read-and-clear).
647     */
648    public static List<Diagnostic> pendingBuildDiagnostics() {
649        List<Diagnostic> sink = BUILD_DIAGNOSTICS.get();
650        if (sink == null || sink.isEmpty()) {
651            return java.util.Collections.emptyList();
652        }
653        return new ArrayList<>(sink);
654    }
655
656    /**
657     * Read-and-clear: returns a copy of the accumulated warnings and removes
658     * the thread-local sink in one step. Production consumers
659     * ({@link SqlSemanticAnalyzer}) drain here so warnings never linger on a
660     * pooled thread and a later direct {@code build*} caller can't observe
661     * stale analyzer-internal warnings.
662     *
663     * @return a copy of the warnings (never {@code null}, possibly empty).
664     */
665    public static List<Diagnostic> drainBuildDiagnostics() {
666        List<Diagnostic> pending = pendingBuildDiagnostics();
667        BUILD_DIAGNOSTICS.remove();
668        return pending;
669    }
670
671    /**
672     * Catalog-less {@code NATURAL JOIN} degrade — record the
673     * {@link DiagnosticCode#NATURAL_CATALOG_REQUIRED} reject as a non-fatal
674     * {@link Severity#WARN} instead of throwing. The join still appears in
675     * the join graph as a bare NATURAL entity (empty conditions / USING
676     * columns, both endpoints populated); only the shared-column predicates
677     * are absent. Shared between the SELECT-side {@link #buildRelations} and
678     * the joined-UPDATE FROM path so the wording stays identical to the
679     * former fatal diagnostic.
680     */
681    private static void recordNaturalDegradeWarning(NaturalKeyResult r, TJoinItem item) {
682        recordBuildWarning(Diagnostic.warn(
683                DiagnosticCode.NATURAL_CATALOG_REQUIRED,
684                formatNaturalCatalogReject(r), item));
685    }
686
687    /**
688     * Terminate a clause column-collection pass that accumulated non-exact
689     * (unresolvable) column bindings. Normally fatal
690     * ({@link DiagnosticCode#COLUMN_BINDING_NON_EXACT}); but two structural
691     * anchors degrade it to a non-fatal warning (GSP R6) — record the warning
692     * and let the build continue (the unresolved refs are simply omitted,
693     * never fabricated) instead of discarding the whole statement graph:
694     *
695     * <ol>
696     *   <li><b>USING join anchor</b> — the FROM clause carries a
697     *       {@code JOIN ... USING} (the scope has merged keys). The join
698     *       structure is fully known and only the unqualified non-key SELECT/
699     *       WHERE columns are unplaceable without a catalog.</li>
700     *   <li><b>Join-graph anchor</b> — the FROM clause resolved a fully-built
701     *       join graph (two or more endpoints from explicit ON / CROSS / comma
702     *       predicates; {@link NameBindingProvider#hasJoinStructureAnchor()}).
703     *       An explicit ON / CROSS join graph is just as fully determined as a
704     *       USING join — the only thing missing without a catalog is
705     *       <em>which side</em> an unqualified column belongs to, which is
706     *       exactly the non-fatal case the USING branch already tolerates.</li>
707     * </ol>
708     *
709     * <p>BOTH anchors fire ONLY when every reject is an <em>unqualified</em>
710     * column miss ({@code allRejectsUnqualified}). A qualified reference that
711     * fails to bind (e.g. {@code b.id} pointing at the wrong side, or an
712     * unregistered catalog column under {@code JOIN ... USING}) is a genuine
713     * error, not a which-side ambiguity, and stays fatal under either anchor.
714     *
715     * <p>Catalog-less NATURAL joins are handled earlier by the
716     * {@code hasNaturalDegrade()} fallback. The fatal path remains for the
717     * genuinely structureless case (no resolvable join graph) and for any
718     * qualified-reference miss.
719     */
720    private static void rejectNonExactBindings(List<String> rejects,
721                                               NameBindingProvider provider,
722                                               boolean allRejectsUnqualified) {
723        if (provider != null && allRejectsUnqualified
724                && provider.getUsingScope().hasMergedKeys()) {
725            recordBuildWarning(Diagnostic.warn(DiagnosticCode.COLUMN_BINDING_NON_EXACT,
726                    "non-exact column bindings (USING join, no aliases): " + rejects, null));
727            return;
728        }
729        if (provider != null && provider.hasJoinStructureAnchor()
730                && allRejectsUnqualified) {
731            recordBuildWarning(Diagnostic.warn(DiagnosticCode.COLUMN_BINDING_NON_EXACT,
732                    "non-exact column bindings (join graph resolved, no aliases/catalog): "
733                            + rejects, null));
734            return;
735        }
736        throw new SemanticIRBuildException(Diagnostic.error(
737                DiagnosticCode.COLUMN_BINDING_NON_EXACT,
738                "non-exact column bindings: " + rejects, null));
739    }
740
741    public static SemanticProgram build(TSelectSqlStatement select, NameBindingProvider provider) {
742        if (select == null) {
743            throw new IllegalArgumentException("select must not be null");
744        }
745        if (provider == null) {
746            throw new IllegalArgumentException("provider must not be null");
747        }
748        List<StatementGraph> stmts = new ArrayList<>();
749        List<LineageEdge> lineage = new ArrayList<>();
750        Map<String, Integer> cteNameToStatementIndex = new HashMap<>();
751        Map<String, List<String>> ctePublishedColumns = new HashMap<>();
752        int outerIndex = buildSelectProgramInto(select, provider, stmts, lineage,
753                cteNameToStatementIndex, ctePublishedColumns);
754        // Slice 170 (S9): attach query-block scope metadata (GAP 4).
755        attachQueryBlockScopes(stmts, outerIndex, cteNameToStatementIndex);
756        return new SemanticProgram(stmts, lineage);
757    }
758
759    /**
760     * Slice 170 (S9) — populate {@link QueryBlockScope} on each emitted
761     * SELECT-program block (GAP 4): a stable {@code statementIndex}, the
762     * {@link ScopeKind}, the CTE/derived name when present, and a
763     * {@code parentStatementIndex} pointing at the <em>lexical owner</em>.
764     *
765     * <p>The flat list order is preserved (mutates entries in place via the
766     * immutable {@code withJoinAnalysisFacts} copier). The outer statement
767     * ({@code outerIndex}) is the {@link ScopeKind#MAIN} block with a null
768     * parent. CTE bodies (known via {@code cteNameToStatementIndex}) and
769     * other bodies emitted before the outer take the outer as their
770     * top-level lexical owner.
771     *
772     * <p><strong>V1 scope (plan Q-CTE):</strong> {@code parentStatementIndex}
773     * is the top-level owning statement. Precise multi-level nested-body
774     * parent edges (e.g. a subquery inside a CTE body) are a documented
775     * follow-up; for the common single-level case (CTEs / FROM-subqueries
776     * directly under one SELECT) this is exact.
777     */
778    private static void attachQueryBlockScopes(List<StatementGraph> stmts,
779                                               int outerIndex,
780                                               Map<String, Integer> cteNameToStatementIndex) {
781        if (stmts.isEmpty()) return;
782        java.util.Set<Integer> cteIndices = new java.util.HashSet<Integer>(
783                cteNameToStatementIndex.values());
784        for (int i = 0; i < stmts.size(); i++) {
785            StatementGraph sg = stmts.get(i);
786            ScopeKind kind;
787            Integer parent;
788            if (i == outerIndex) {
789                kind = ScopeKind.MAIN;
790                parent = null;
791            } else if (cteIndices.contains(Integer.valueOf(i))) {
792                kind = ScopeKind.CTE;
793                parent = Integer.valueOf(outerIndex);
794            } else {
795                // A FROM/scalar/predicate body emitted before the outer.
796                kind = ScopeKind.DERIVED_TABLE;
797                parent = Integer.valueOf(outerIndex);
798            }
799            // Slice 179 (R5): the block's own source span, set at the SELECT
800            // construction site, follows the existing 1-based/end-exclusive
801            // SourceSpan convention. Null when not threaded (e.g. DML).
802            QueryBlockScope scope = new QueryBlockScope(
803                    i, parent, kind, sg.getName(), sg.getSourceSpan());
804            stmts.set(i, sg.withJoinAnalysisFacts(
805                    sg.getJoinAnalysisFacts().withQueryBlockScope(scope)));
806        }
807    }
808
809    /**
810     * Slice 122 — build a SELECT program into a CALLER-OWNED
811     * {@code stmts}/{@code lineage} pair, seeded with a caller-supplied
812     * outer CTE context. {@link #build} delegates here with fresh empty
813     * maps (behaviour-preserving extraction of its former inline body).
814     *
815     * <p>The MERGE USING-subquery branch in {@link #buildMerge} delegates
816     * here with a COPY of the outer MERGE's {@code cteNameToStatementIndex}
817     * (and {@code ctePublishedColumns}) so a USING body that references an
818     * outer MERGE CTE
819     * ({@code WITH cte AS (...) MERGE INTO t USING (SELECT ... FROM cte) s})
820     * resolves the reference as CTE-kind and emits the cross-statement
821     * {@code STATEMENT_OUTPUT(usingIdx, col) -> STATEMENT_OUTPUT(cteIdx, col)}
822     * edge into the already-built CTE body. Building directly into the
823     * shared lists means the inner statements land at their final absolute
824     * indices with NO rebase — so edges to the pre-seeded outer CTE
825     * reference the correct index (the pre-slice-122
826     * {@code build()+rebaseLineageEdge} path could not, because the rebase
827     * offset would wrongly shift an outer-CTE index too). This closes the
828     * slice-110 documented parity gap.
829     *
830     * <p>{@code hasOuterCteListAlreadyProcessed} is derived from THIS
831     * select's own CTE list only — the seeded outer CTEs are not declared
832     * by {@code select.getCteList()}, so they must not trigger the
833     * nested-WITH reject in {@code buildSelectStatementImpl}.
834     *
835     * <p>Slice 108 Phase 0 — the two helper calls (CTE walker +
836     * outer-SELECT body) were extracted from {@link #build} into
837     * {@link #buildSelectCteList} / {@link #buildSelectBodyAfterCteWalk};
838     * calling them with {@code allowShadowOverride=false} /
839     * {@code additionalAllCteNames=null} reproduces the prior inline walker.
840     *
841     * @return the index, into the supplied {@code stmts} list, of the
842     *         program's final (outer / merged-set-op) statement — i.e. the
843     *         statement a consumer should treat as this SELECT's result.
844     */
845    private static int buildSelectProgramInto(
846            TSelectSqlStatement select,
847            NameBindingProvider provider,
848            List<StatementGraph> stmts,
849            List<LineageEdge> lineage,
850            Map<String, Integer> cteNameToStatementIndex,
851            Map<String, List<String>> ctePublishedColumns) {
852        TCTEList cteList = select.getCteList();
853        boolean hasOuterCteList = cteList != null && cteList.size() > 0;
854        buildSelectCteList(cteList, provider, stmts, lineage,
855                cteNameToStatementIndex, ctePublishedColumns,
856                /*allowShadowOverride=*/ false,
857                /*additionalAllCteNames=*/ null);
858        buildSelectBodyAfterCteWalk(select, provider, stmts, lineage,
859                cteNameToStatementIndex, ctePublishedColumns,
860                /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteList);
861        return stmts.size() - 1;
862    }
863
864    /**
865     * Slice 108 — walk a SELECT-side WITH clause and append each CTE body
866     * to {@code stmts} as a preceding statement. Extracted from the inline
867     * walker that previously lived in {@link #build} (lines ~516–663 pre-
868     * slice-108). Mirrors the slice-101 {@link #buildMergeCteList},
869     * slice-105 {@link #buildUpdateCteList}, and slice-106
870     * {@link #buildDeleteCteList} helpers.
871     *
872     * <p>Phase 0 (behaviour-preserving refactor): {@code allowShadowOverride
873     * = false} and {@code additionalAllCteNames = null} reproduce the
874     * pre-slice-108 inline walker byte-for-byte.
875     *
876     * <p>Phase 1 (shadow admit): {@code allowShadowOverride = true} enables
877     * the mixed outer+inner WITH on INSERT shadow case (slice 108). When
878     * called from {@link #buildInsert}, the OUTER pass runs with
879     * {@code allowShadowOverride=false}, populating
880     * {@code cteNameToStatementIndex} and {@code ctePublishedColumns} with
881     * outer CTE bindings. The INNER pass then runs with
882     * {@code allowShadowOverride=true} and
883     * {@code additionalAllCteNames=outerAllNames}. The inner pass:
884     * <ul>
885     *   <li>uses a fresh local {@code localVisibleSoFar} for intra-list
886     *       duplicate detection (so {@code DUPLICATE_CTE_NAME} still
887     *       fires for inner {@code x, x} even when outer also declares
888     *       {@code x});</li>
889     *   <li>snapshots {@code cteNameToStatementIndex.keySet()} at entry
890     *       into {@code outerKeysSnapshot}; the union
891     *       {@code outerKeysSnapshot ∪ localVisibleSoFar} drives BOTH
892     *       {@link #rejectForwardCteReferences} AND
893     *       {@link NameBindingProvider#withCteContext} (round-2 codex
894     *       BLOCKER 3 fix — keeps inner-y references to outer-x from
895     *       being falsely flagged as forward references);</li>
896     *   <li>on collision with an outer entry (after a successful body
897     *       build), {@link Map#put} overrides the
898     *       {@code cteNameToStatementIndex} and {@code ctePublishedColumns}
899     *       entries so the source SELECT sees the INNER body. The OUTER
900     *       body stays in {@code stmts[]} at its earlier position; its
901     *       cteMap entry is just no longer referenced by name (PG nested-
902     *       WITH inner-shadows-outer semantics).</li>
903     * </ul>
904     *
905     * <p>{@code additionalAllCteNames} is unioned into the per-call
906     * {@code allCteNames} that {@link #rejectForwardCteReferences} consults
907     * (round-1 codex BLOCKER 2 fix — keeps each scope's forward-ref check
908     * narrow so an outer CTE body referencing a base-table whose name
909     * happens to coincide with an inner CTE name does NOT falsely flag).
910     */
911    private static void buildSelectCteList(
912            TCTEList cteList,
913            NameBindingProvider provider,
914            List<StatementGraph> stmts,
915            List<LineageEdge> lineage,
916            Map<String, Integer> cteNameToStatementIndex,
917            Map<String, List<String>> ctePublishedColumns,
918            boolean allowShadowOverride,
919            Set<String> additionalAllCteNames) {
920        if (cteList == null || cteList.size() == 0) {
921            return;
922        }
923        rejectRecursiveCtes(cteList);
924
925        // Per-call allCteNames for rejectForwardCteReferences. The optional
926        // additionalAllCteNames extends this scope (Phase 1: outer names
927        // visible to inner CTE body forward-ref checks). Phase 0 path passes
928        // null, so this is just collectCteNames(cteList).
929        Set<String> allCteNames;
930        if (additionalAllCteNames != null && !additionalAllCteNames.isEmpty()) {
931            allCteNames = new HashSet<>(collectCteNames(cteList));
932            allCteNames.addAll(additionalAllCteNames);
933        } else {
934            allCteNames = collectCteNames(cteList);
935        }
936
937        // Phase 1: snapshot outer-scope CTE names at entry so subsequent
938        // iterations of this list always see the FULL outer scope for
939        // forward-ref classification and withCteContext, even if a shadow
940        // override later overwrites a name's cteMap entry.
941        Set<String> outerKeysSnapshot = allowShadowOverride
942                ? new HashSet<>(cteNameToStatementIndex.keySet())
943                : null;
944
945        // Build each CTE body left-to-right. Each CTE sees CTEs declared
946        // strictly before it (standard SQL chain semantics, slice 4).
947        // Slice 18: CTE bodies accept FROM-subqueries (mirroring the
948        // outer-SELECT extraction path) AND scalar-subquery projections
949        // (slice 11): for each CTE body, FROM-subqueries are extracted
950        // first, then scalar bodies, then the CTE body is built/appended.
951        // The per-CTE-body subqueryAliasToIndex is local to the iteration
952        // so different CTE bodies cannot collide on FROM-subquery aliases.
953        // Slice 60: running map of "CTE name → published column names"
954        // for star expansion. Each CTE's published columns are added
955        // AFTER its body is built so a CTE cannot self-reference and
956        // forward references (rejected earlier) cannot leak through.
957        // Set-op CTE bodies use the merged StatementGraph.outputColumns.
958        // For non-set-op CTE bodies the column names also come from
959        // StatementGraph.outputColumns. Explicit CTE column lists are
960        // rejected at the star expander, not at populate time.
961        Set<String> localVisibleSoFar = new HashSet<>();
962        for (int i = 0; i < cteList.size(); i++) {
963            TCTE cte = cteList.getCTE(i);
964            String cteName = cte.getTableName().toString();
965            String cteNameLower = cteName.toLowerCase(Locale.ROOT);
966            // Slice 15 MUST 9 / round-4 MUST 1: reject duplicate CTE
967            // names BEFORE rejectForwardCteReferences so duplicate-name
968            // diagnostics are not preempted by forward-reference
969            // diagnostics. cteNameToStatementIndex is keyed lower-case;
970            // a duplicate entry would silently overwrite the earlier
971            // body and leave OUTER_REFERENCE-of-CTE pointing at the
972            // wrong statement.
973            //
974            // Slice 108: intra-list duplicate check uses localVisibleSoFar
975            // (NOT outerKeysSnapshot) so an inner CTE shadowing an outer
976            // CTE is admitted while inner-x, inner-x stays rejected (round-1
977            // codex BLOCKER 1 fix).
978            if (localVisibleSoFar.contains(cteNameLower)) {
979                throw new SemanticIRBuildException(
980                        Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME,
981                        "duplicate CTE name '" + cteName
982                                + "' in WITH clause; CTE names must be unique", cte));
983            }
984            // Slice 108: effectiveVisible = outerKeysSnapshot ∪ localVisibleSoFar.
985            // Drives BOTH rejectForwardCteReferences AND
986            // bodyProvider.withCteContext so inner-y body referencing outer-x
987            // is admitted (round-2 codex BLOCKER 3 fix).
988            Set<String> effectiveVisible;
989            if (outerKeysSnapshot != null) {
990                if (outerKeysSnapshot.isEmpty()) {
991                    effectiveVisible = localVisibleSoFar;
992                } else if (localVisibleSoFar.isEmpty()) {
993                    effectiveVisible = outerKeysSnapshot;
994                } else {
995                    effectiveVisible = new HashSet<>(outerKeysSnapshot);
996                    effectiveVisible.addAll(localVisibleSoFar);
997                }
998            } else {
999                effectiveVisible = localVisibleSoFar;
1000            }
1001            rejectForwardCteReferences(cte, allCteNames, effectiveVisible);
1002            // Slice 60: bodyProvider gets the CTE-context narrowing
1003            // first; the effective-alias-keyed in-scope map is
1004            // applied LATER, after the body's own FROM-subqueries
1005            // are extracted (so we can walk the body's FROM clause
1006            // and resolve each relation to its effective alias).
1007            // This deferred narrowing replaces the slice-60 v1 path
1008            // that put the running ctePublishedColumns map (CTE-name
1009            // keyed) directly on the provider — that keying class
1010            // could collide when a subquery alias matched a CTE
1011            // name (codex diff-review).
1012            NameBindingProvider bodyProvider = provider.withCteContext(effectiveVisible);
1013            TSelectSqlStatement cteBody = cte.getSubquery();
1014            // Slice 103 — snapshot lineage size BEFORE the body branch so
1015            // the slice-102 rename helper can rewrite outgoing
1016            // STATEMENT_OUTPUT refs in [lineageSize0, lineage.size())
1017            // without touching prior CTE bodies' edges. Covers BOTH the
1018            // set-op and non-set-op branches (mirrors slice-102
1019            // buildMergeCteList at line ~5820).
1020            int lineageSize0 = lineage.size();
1021            int bodyIdx;
1022            if (cteBody != null
1023                    && cteBody.getSetOperatorType() != null
1024                    && cteBody.getSetOperatorType() != ESetOperatorType.none) {
1025                // Slice 12: set-op CTE body. The outer set-op statement
1026                // carries the CTE name so BodyIndexes.cteByConsumerAndName
1027                // resolves it (slice-18 consumer-keyed projector lookup).
1028                // The CTE body's CTE list (if any) is rejected as a
1029                // nested-WITH inside buildSetOpProgram.
1030                bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, lineage,
1031                        cteNameToStatementIndex, cteName,
1032                        /*hasOuterCteListAlreadyProcessed=*/ false);
1033                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
1034            } else {
1035                // Slice 18: snapshot/rollback around recursive
1036                // FROM-subquery extraction inside this CTE body.
1037                // Mirrors the outer-SELECT wrapper below and the
1038                // slice-16 set-op wrapper. Currently defensive: a
1039                // thrown exception in a deeper level would otherwise
1040                // leak siblings/ancestors at this CTE's level into
1041                // stmts/lineage. The wrapper truncates back to the
1042                // pre-extraction boundary and rethrows. Per-CTE
1043                // granularity: earlier CTE bodies in the same WITH
1044                // list are NOT rolled back (they're already complete).
1045                int cteStmtsSize0 = stmts.size();
1046                int cteLineageSize0 = lineage.size();
1047                Map<String, Integer> cteSubqueryAliasToIndex;
1048                try {
1049                    // Slice 60: pass the running ctePublishedColumns
1050                    // so the body's own FROM-subqueries see earlier
1051                    // CTEs at every recursion level.
1052                    cteSubqueryAliasToIndex =
1053                            extractFromSubqueriesAsStatements(cteBody, bodyProvider,
1054                                    stmts, lineage, cteNameToStatementIndex,
1055                                    ctePublishedColumns);
1056                } catch (RuntimeException ex) {
1057                    while (stmts.size() > cteStmtsSize0) stmts.remove(stmts.size() - 1);
1058                    while (lineage.size() > cteLineageSize0) lineage.remove(lineage.size() - 1);
1059                    throw ex;
1060                }
1061                EnclosingScope cteEnclosing = buildEnclosingScope(cteBody,
1062                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
1063                        /*parent=*/ null);
1064                Map<Integer, ScalarInfo> cteScalarMap =
1065                        extractScalarSubqueriesAsStatements(cteBody,
1066                                bodyProvider, stmts, lineage,
1067                                cteNameToStatementIndex, cteEnclosing,
1068                                /*allowRecursiveScalarSubqueryExtraction=*/ true);
1069                // Slice 60 (codex diff-review): build the per-CTE
1070                // effective-alias-keyed in-scope map by walking the
1071                // CTE body's FROM list. CTE references and
1072                // FROM-subquery aliases live in the same FROM
1073                // namespace (preflight rejects duplicates), so
1074                // effective-alias keying makes a name collision
1075                // physically impossible.
1076                Map<String, List<String>> cteBodyInScope =
1077                        buildEffectiveAliasInScopeMap(cteBody, bodyProvider,
1078                                ctePublishedColumns, cteSubqueryAliasToIndex,
1079                                stmts);
1080                NameBindingProvider cteBodyProviderWithStar = bodyProvider
1081                        .withInScopeRelationColumns(cteBodyInScope);
1082                // Slice 114 — switch from the 7-arg buildSelectStatement
1083                // to the 14-arg buildSelectStatementImpl so the CTE
1084                // body's WHERE clause can extract uncorrelated predicate
1085                // subqueries (IN-SELECT / EXISTS / NOT EXISTS / scalar
1086                // comparison / ANY-ALL-SOME) as their own statements.
1087                // The wrapper mirrors the outer-SELECT entry pattern in
1088                // {@link #build}: if the build appends predicate bodies
1089                // and then a later post-extraction reject fires, the
1090                // try/catch truncates stmts/lineage back to the
1091                // pre-call boundary so a partial extraction doesn't
1092                // leak into the program. The slice-113 set-op branch
1093                // call site is itself enclosed by the slice-16
1094                // SET-OP-WIDE rollback at {@link #buildSetOpProgram};
1095                // the CTE-body call sites do NOT inherit a similar
1096                // enclosing wrapper, which is why slice 114 adds one
1097                // here. The from-subquery / scalar-subquery
1098                // extractions above this point have their own
1099                // slice-17/18 wrappers, so the pre-CALL snapshot
1100                // bounds the truncate exactly to whatever
1101                // buildSelectStatementImpl appended.
1102                int cteBodyStmtsSnapshot = stmts.size();
1103                int cteBodyLineageSnapshot = lineage.size();
1104                StatementGraph body;
1105                try {
1106                    if (isPivotSelect(cteBody)) {
1107                        // Slice 139 — a nested PIVOT/UNPIVOT as a CTE body.
1108                        // The slice-129 pivot router in
1109                        // buildSelectStatementImpl is gated to the OUTER
1110                        // SELECT (name == null && !isPredicateBody), so a
1111                        // pivot built here (name == cteName) would otherwise
1112                        // fall to the normal path and reject with the
1113                        // misleading TABLE_BINDING_UNRESOLVED
1114                        // ("null(piviot_table)"). Route it to buildPivotSelect
1115                        // with the CTE name as the statement name — mirroring
1116                        // the slice-138 processDirectSubqueryTable branch for a
1117                        // FROM-subquery body. The CTE becomes a proper pivot
1118                        // StatementGraph; the outer query references the CTE
1119                        // name (CTE-kind) so the standard cross-stmt CTE path
1120                        // emits STATEMENT_OUTPUT(outer) → STATEMENT_OUTPUT(cte),
1121                        // and the emitLineageForStatement(body, ...) call below
1122                        // wires the pivot's own output → source edges
1123                        // (base-table TABLE_COLUMN, or — when the pivot's source
1124                        // is itself a subquery, slice 136 — STATEMENT_OUTPUT via
1125                        // cteSubqueryAliasToIndex). All pivot deferrals are
1126                        // preserved by buildPivotSelect's own guards
1127                        // (PIVOT_WITH_QUERY_CLAUSE / MULTIPLE_CLAUSES /
1128                        // UNPIVOT / STAR_CATALOG_REQUIRED).
1129                        body = buildPivotSelect(cteBody,
1130                                cteBodyProviderWithStar, cteName);
1131                    } else {
1132                        body = buildSelectStatementImpl(cteBody,
1133                            cteBodyProviderWithStar, cteName,
1134                            /*hasOuterCteListAlreadyProcessed=*/ false,
1135                            /*allowFromSubqueries=*/ true,
1136                            /*allowScalarProjectionSubqueries=*/ true,
1137                            /*allowWindowProjection=*/ true,
1138                            // Slice 114 — keep JOIN-ON predicate
1139                            // subqueries rejected inside CTE bodies
1140                            // (preserve slice 23/26 contract; the lift
1141                            // is WHERE-only; the two flags are
1142                            // independent per slice 113 split).
1143                            /*allowJoinOnPredicateSubqueries=*/ false,
1144                            /*stmtsForExtraction=*/ stmts,
1145                            /*lineageForExtraction=*/ lineage,
1146                            /*cteMapForExtraction=*/ cteNameToStatementIndex,
1147                            /*isPredicateBody=*/ false,
1148                            /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE,
1149                            /*allowWherePredicateSubqueries=*/ true);
1150                    }
1151                } catch (RuntimeException ex) {
1152                    while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
1153                    while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
1154                    throw ex;
1155                }
1156                bodyIdx = stmts.size();
1157                stmts.add(body);
1158                // Slice 108 — emit lineage BEFORE the cteMap.put so that
1159                // in the shadow case (allowShadowOverride=true with
1160                // cteNameLower already in cteMap from outer pass), the
1161                // body's column refs to <cteNameLower> still resolve to
1162                // the OUTER body (PG inner-x body sees outer-x via the
1163                // closer-enclosing-not-yet-shadowed fallback). Non-shadow
1164                // cases are unaffected because cteMap does not yet contain
1165                // cteNameLower at this point and the body cannot reference
1166                // its own name without going through the recursive-CTE
1167                // path (already rejected upstream).
1168                emitLineageForStatement(body, bodyIdx, lineage,
1169                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
1170                        cteScalarMap);
1171                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
1172            }
1173            // Slice 103 — apply the slice-102 rename helper if the CTE
1174            // declares an explicit column list (no-op otherwise). The
1175            // helper returns the published column list (renamed if
1176            // explicit, else body's inner names). Slice-60's
1177            // `ctePublishedColumns.put` is collapsed into this single
1178            // call site (covers both branches above).
1179            List<String> publishedCols = applyExplicitCteColumnListRename(
1180                    cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT");
1181            ctePublishedColumns.put(cteNameLower, publishedCols);
1182            localVisibleSoFar.add(cteNameLower);
1183        }
1184    }
1185
1186    /**
1187     * Slice 108 — outer-SELECT processing extracted from the previous inline
1188     * body of {@link #build} (lines ~665–763 pre-slice-108).
1189     *
1190     * <p>{@code hasOuterCteListAlreadyProcessed} is an EXPLICIT boolean
1191     * parameter (round-2 codex BLOCKER 4 fix). Previously this was inferred
1192     * from {@code select.getCteList() != null && size > 0}; after the
1193     * slice-108 buildInsert shadow path nulls {@code source.getCteList()}
1194     * before calling, that inference would be wrong. The caller passes the
1195     * truth.
1196     *
1197     * <p>The post-walk {@code cteNameToStatementIndex.keySet()} replaces the
1198     * pre-walk {@code allCteNames} because the walker has populated every
1199     * declared CTE name by lowercase key — they are equal sets.
1200     */
1201    private static void buildSelectBodyAfterCteWalk(
1202            TSelectSqlStatement select,
1203            NameBindingProvider provider,
1204            List<StatementGraph> stmts,
1205            List<LineageEdge> lineage,
1206            Map<String, Integer> cteNameToStatementIndex,
1207            Map<String, List<String>> ctePublishedColumns,
1208            boolean hasOuterCteListAlreadyProcessed) {
1209        Set<String> allCteNames = cteNameToStatementIndex.isEmpty()
1210                ? Collections.<String>emptySet()
1211                : new HashSet<>(cteNameToStatementIndex.keySet());
1212
1213        // Slice 12: top-level set-op dispatch. CTE list (if any) was
1214        // already processed above; pass hasOuterCteListAlreadyProcessed=true
1215        // so buildSetOpProgram doesn't re-flag it as a nested WITH.
1216        if (select.getSetOperatorType() != null
1217                && select.getSetOperatorType() != ESetOperatorType.none) {
1218            NameBindingProvider outerProvider = provider.withCteContext(allCteNames);
1219            buildSetOpProgram(select, outerProvider, stmts, lineage,
1220                    cteNameToStatementIndex, /*setOpName=*/ null,
1221                    /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteListAlreadyProcessed);
1222            return;
1223        }
1224
1225        // Outer statement: pre-extract any FROM-clause subqueries as their
1226        // own statements, then any scalar-subquery projections, then build
1227        // the outer body, then emit lineage with the global CTE map, the
1228        // outer-local subquery alias map, AND the scalar-projection map.
1229        // Slice 60: outerProvider gets the CTE-context narrowing here;
1230        // the effective-alias-keyed in-scope map is applied LATER, after
1231        // outer FROM-subqueries are extracted. The same deferred
1232        // narrowing pattern as the CTE-body branch — see the codex
1233        // diff-review note on alias/CTE-name collision.
1234        NameBindingProvider outerProvider = provider.withCteContext(allCteNames);
1235        // Slice 17: snapshot/rollback around recursive FROM-subquery
1236        // extraction. The recursive extractor mutates stmts/lineage as
1237        // each level's bodies land; if a deeper-level rejection fires
1238        // after sibling/ancestor mutations, this wrapper truncates the
1239        // lists back to the pre-call boundary and rethrows. Mirrors the
1240        // slice-16 buildSetOpProgram wrapper (§14.18 process lesson #21:
1241        // when a class of mutation-free checks can fire after partial
1242        // mutation, close it transactionally instead of point-fixing).
1243        //
1244        // The rollback is currently defensive: build() allocates fresh
1245        // stmts/lineage per invocation, so a thrown exception's caller
1246        // cannot directly observe leaked state. The wrapper is kept
1247        // because (a) the slice-17 preflight closes the most direct
1248        // partial-mutation classes BEFORE the recursive extraction
1249        // runs, but recursive levels can still fail at deeper rejection
1250        // points (e.g. a nested set-op-in-FROM-subquery body inside a
1251        // sibling that succeeds at the preflight); (b) consistency with
1252        // slice 16's pattern means a future refactor that lifts the
1253        // build() per-call list allocation does not silently re-open
1254        // the partial-mutation class.
1255        int stmtsSize0 = stmts.size();
1256        int lineageSize0 = lineage.size();
1257        Map<String, Integer> outerSubqueryAliasToIndex;
1258        try {
1259            outerSubqueryAliasToIndex =
1260                    extractFromSubqueriesAsStatements(select, outerProvider,
1261                            stmts, lineage, cteNameToStatementIndex,
1262                            ctePublishedColumns);
1263        } catch (RuntimeException ex) {
1264            while (stmts.size() > stmtsSize0) stmts.remove(stmts.size() - 1);
1265            while (lineage.size() > lineageSize0) lineage.remove(lineage.size() - 1);
1266            throw ex;
1267        }
1268        EnclosingScope outerEnclosing = buildEnclosingScope(select,
1269                cteNameToStatementIndex, outerSubqueryAliasToIndex,
1270                /*parent=*/ null);
1271        Map<Integer, ScalarInfo> outerScalarMap =
1272                extractScalarSubqueriesAsStatements(select, outerProvider,
1273                        stmts, lineage, cteNameToStatementIndex, outerEnclosing,
1274                        /*allowRecursiveScalarSubqueryExtraction=*/ true);
1275        // Slice 60 (codex diff-review): build the outer's
1276        // effective-alias-keyed in-scope map by walking the outer
1277        // SELECT's FROM list. Effective-alias keying eliminates the
1278        // CTE-name vs subquery-alias collision class.
1279        Map<String, List<String>> outerInScope = buildEffectiveAliasInScopeMap(
1280                select, outerProvider, ctePublishedColumns,
1281                outerSubqueryAliasToIndex, stmts);
1282        NameBindingProvider outerProviderWithStar = outerProvider
1283                .withInScopeRelationColumns(outerInScope);
1284        // Slice 23: outer-SELECT path uses buildSelectStatementImpl directly so
1285        // the slice-23 EXISTS-extraction can append predicate-body statements
1286        // to `stmts`/`lineage`. Snapshot/rollback wrapper around the call
1287        // matches the slice-16/17/20 pattern: a partial extraction (e.g. third
1288        // EXISTS rejected after first two extracted) truncates the lists.
1289        int outerStmtsSnapshot = stmts.size();
1290        int outerLineageSnapshot = lineage.size();
1291        StatementGraph outer;
1292        try {
1293            outer = buildSelectStatementImpl(select, outerProviderWithStar, null,
1294                    /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteListAlreadyProcessed,
1295                    /*allowFromSubqueries=*/ true,
1296                    /*allowScalarProjectionSubqueries=*/ true,
1297                    /*allowWindowProjection=*/ true,
1298                    /*allowJoinOnPredicateSubqueries=*/ true,
1299                    stmts, lineage,
1300                    /*cteMapForExtraction=*/ cteNameToStatementIndex,
1301                    /*isPredicateBody=*/ false,
1302                    /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE,
1303                    /*allowWherePredicateSubqueries=*/ true);
1304        } catch (RuntimeException e) {
1305            while (stmts.size() > outerStmtsSnapshot) stmts.remove(stmts.size() - 1);
1306            while (lineage.size() > outerLineageSnapshot) lineage.remove(lineage.size() - 1);
1307            throw e;
1308        }
1309        int outerIndex = stmts.size();
1310        stmts.add(outer);
1311        emitLineageForStatement(outer, outerIndex, lineage,
1312                cteNameToStatementIndex, outerSubqueryAliasToIndex, outerScalarMap);
1313    }
1314
1315    /**
1316     * Slice 78 — admit a single {@code INSERT INTO target SELECT ...}
1317     * statement. Builds the source SELECT via {@link #build} (reusing
1318     * the existing pipeline unchanged), then appends an {@code "INSERT"}-
1319     * kind {@link StatementGraph} carrying the target relation and
1320     * cross-statement lineage edges.
1321     *
1322     * <p>Admitted shape: {@code INSERT INTO <target> [(c1, c2, ...)]
1323     * <subquery-SELECT>}. Rejections:
1324     * <ul>
1325     *   <li>{@link EInsertSource#values}, {@code values_empty},
1326     *       {@code default_values}, {@code execute},
1327     *       {@code values_function}, {@code values_multi_table},
1328     *       {@code hive_query}, {@code values_oracle_record},
1329     *       {@code set_column_value}, {@code value_table} →
1330     *       {@link DiagnosticCode#INSERT_SOURCE_NOT_SUPPORTED}.</li>
1331     *   <li>Oracle {@code INSERT ALL} / {@code INSERT FIRST} →
1332     *       {@link DiagnosticCode#INSERT_MULTI_TABLE_NOT_SUPPORTED}.
1333     *       Hive multi-insert ({@code multiInsertStatements} non-empty) is
1334     *       routed to {@link #buildHiveMultiInsert} instead of rejected.</li>
1335     *   <li>Missing target table (defensive — the parser usually rejects
1336     *       first) → {@link DiagnosticCode#INSERT_TARGET_MISSING}.</li>
1337     *   <li>Explicit column list arity ≠ source SELECT output count →
1338     *       {@link DiagnosticCode#INSERT_COLUMN_COUNT_MISMATCH}.</li>
1339     * </ul>
1340     *
1341     * <p>The source SELECT is built first via {@code build()} and its
1342     * full {@link SemanticProgram} (CTE bodies + scalar bodies +
1343     * FROM-subquery bodies + outer SELECT + cross-stmt lineage) is
1344     * appended verbatim to the returned program. The INSERT
1345     * {@link StatementGraph} is appended LAST; its
1346     * {@link StatementGraph#getRelations() relations} lists the source
1347     * SELECT as a single {@link RelationKind#SUBQUERY} entry whose
1348     * {@code qualifiedName} is the source SELECT's outer-statement name
1349     * (synthesised when needed). All other column-ref lists stay empty
1350     * on the INSERT — an INSERT has no projection of its own.
1351     *
1352     * <p>Cross-statement {@link LineageEdge}s for the INSERT are
1353     * {@code from = TABLE_COLUMN(target_qname, target_col_i_name)}
1354     * and {@code to = STATEMENT_OUTPUT(selectIdx, source_output_i_name)}.
1355     * Target column names are the explicit INSERT column-list spellings
1356     * when supplied, else the source SELECT's positional output names.
1357     */
1358    public static SemanticProgram buildInsert(TInsertSqlStatement insert,
1359                                              NameBindingProvider provider) {
1360        if (insert == null) {
1361            throw new IllegalArgumentException("insert must not be null");
1362        }
1363        if (provider == null) {
1364            throw new IllegalArgumentException("provider must not be null");
1365        }
1366
1367        // Oracle INSERT ALL / FIRST rejects: their multi-value AST shape
1368        // is fundamentally different from the Hive multi-insert path.
1369        // Slice 78 scopes single-target INSERT SELECT; slice 93 lifts
1370        // the Hive multi-insert case via buildHiveMultiInsert.
1371        if (insert.isInsertAll() || insert.isInsertFirst()) {
1372            throw new SemanticIRBuildException(Diagnostic.error(
1373                    DiagnosticCode.INSERT_MULTI_TABLE_NOT_SUPPORTED,
1374                    "multi-table INSERT (INSERT ALL / INSERT FIRST) is not "
1375                            + "supported by SemanticIRBuilder.buildInsert; "
1376                            + "slice 78 admits single-target INSERT INTO <target> SELECT ...",
1377                    insert));
1378        }
1379        // Slice 109 — outer-WITH on Hive multi-insert
1380        // (`WITH x AS (...) FROM x INSERT INTO t1 SELECT ... INSERT INTO t2
1381        // SELECT ...`) is now admitted via buildHiveMultiInsert's CTE-aware
1382        // path. The slice-104 early reject for this shape is removed; the
1383        // helper builds the outer CTE bodies ONCE upfront and reuses the
1384        // shared cteMap/publishedMap across every sub-SELECT.
1385        // INSERT_OUTER_WITH_ON_HIVE_MULTI_INSERT_NOT_SUPPORTED stays declared-
1386        // but-unreached for API stability (slice 71/72/82/86/95/96/97/98/108
1387        // retain-for-documentation precedent).
1388        //
1389        // Hive multi-insert: FROM src INSERT INTO t1 SELECT ... INSERT INTO t2 SELECT ...
1390        // Each sub-SELECT already carries the shared FROM source in its fromClause.
1391        if (!insert.getMultiInsertStatements().isEmpty()) {
1392            return buildHiveMultiInsert(insert, provider);
1393        }
1394
1395        EInsertSource src = insert.getInsertSource();
1396        if (src != EInsertSource.subquery) {
1397            throw new SemanticIRBuildException(Diagnostic.error(
1398                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
1399                    "INSERT source '" + src + "' is not supported by "
1400                            + "SemanticIRBuilder.buildInsert; slice 78 admits "
1401                            + "subquery-source INSERT only (INSERT INTO <target> SELECT ...)",
1402                    insert));
1403        }
1404
1405        // Slice 85 — cheap statement-level OUTPUT_INTO reject runs
1406        // BEFORE the source SELECT is built so a multi-violation
1407        // shape (e.g. `INSERT INTO t OUTPUT INSERTED.x INTO #log
1408        // SELECT ... FROM bad_join`) routes to the cheaper structural
1409        // code first.
1410        if (insert.getOutputClause() != null
1411                && insert.getOutputClause().getIntoTable() != null) {
1412            throw new SemanticIRBuildException(Diagnostic.error(
1413                    DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED,
1414                    "INSERT OUTPUT ... INTO <target> writes a second target; "
1415                            + "slice 85 admits projection-only OUTPUT",
1416                    insert));
1417        }
1418
1419        TTable targetTable = insert.getTargetTable();
1420        if (targetTable == null || targetTable.getTableName() == null) {
1421            throw new SemanticIRBuildException(Diagnostic.error(
1422                    DiagnosticCode.INSERT_TARGET_MISSING,
1423                    "INSERT statement has no resolvable target table",
1424                    insert));
1425        }
1426        String targetQName = targetTable.getTableName().toString();
1427        if (targetQName.isEmpty()) {
1428            throw new SemanticIRBuildException(Diagnostic.error(
1429                    DiagnosticCode.INSERT_TARGET_MISSING,
1430                    "INSERT target table name is empty",
1431                    insert));
1432        }
1433
1434        TSelectSqlStatement source = insert.getSubQuery();
1435        if (source == null) {
1436            // Defensive: getInsertSource() == subquery but subQuery is
1437            // null. Surface as INSERT_TARGET_MISSING's source half.
1438            throw new SemanticIRBuildException(Diagnostic.error(
1439                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
1440                    "INSERT source is declared as subquery but no SELECT "
1441                            + "statement was attached",
1442                    insert));
1443        }
1444
1445        // Slice 104 — outer-WITH on INSERT. The parser attaches the outer
1446        // WITH clause to insert.getCteList(), NOT to source.getCteList().
1447        // Before slice 104 buildInsert ignored insert.getCteList(), which
1448        // silently mis-bound CTE references in the source SELECT as
1449        // TABLE-kind relations with phantom columns. The slice-104 fix is
1450        // an AST handoff: move insert.getCteList() onto source.getCteList()
1451        // for the duration of the inner build(source) call so the
1452        // slice-103 SELECT-side CTE walker handles construction, rename,
1453        // and rejects (recursive / duplicate / forward-reference / arity
1454        // mismatch). Restore in finally so the AST is observably
1455        // unchanged to the caller (Java field references — token-chain
1456        // state is perturbed by setCteList(null)'s removeTokens() but
1457        // observably benign for downstream Semantic IR).
1458        //
1459        // Slice 107 / 108 — mixed outer-WITH + inner-WITH on INSERT. PG /
1460        // Oracle / Snowflake admit at parse. Three sub-cases:
1461        //   (a) Only outer WITH populated. AST handoff (slice 104): move
1462        //       insert.cteList onto source.cteList and call build(source).
1463        //   (b) Only inner WITH populated. Pass through unchanged (the
1464        //       walker handles it on its own).
1465        //   (c) Both outer and inner WITH populated. Slice 107 admitted
1466        //       this for disjoint names via a flat-merge; slice 108 admits
1467        //       it for the SHADOWING case too (`WITH x ... INSERT ... WITH
1468        //       x ... SELECT ... FROM x` — inner shadows outer per
1469        //       PG/Oracle/Snowflake nested-WITH semantics). The slice-108
1470        //       implementation uses a TWO-PASS walker invocation in this
1471        //       method: outer pass first (allowShadowOverride=false), then
1472        //       inner pass (allowShadowOverride=true,
1473        //       additionalAllCteNames=outer-names). The walker's two-set
1474        //       visibility model (outerKeysSnapshot ∪ localVisibleSoFar)
1475        //       keeps PG semantics correct: inner-x's body sees outer-x via
1476        //       the cteMap (override is post-build), and inner CTEs declared
1477        //       after inner-x see inner-x. The OUTER body stays in stmts[]
1478        //       at its position; its cteMap entry is just no longer
1479        //       referenced by name (shadowed). Source SELECT's `FROM x`
1480        //       resolves to inner-x.
1481        //
1482        // INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED stays declared but
1483        // is no longer reached by slice 108. Slice107Test §F/§Q (cross-
1484        // boundary duplicate rejects) are deleted; positive coverage moves
1485        // to Slice108Test.
1486        TCTEList outerCtes = insert.getCteList();
1487        TCTEList savedSourceCtes = source.getCteList();
1488        boolean handoffApplied = false;
1489        SemanticProgram inner;
1490        boolean haveOuterCtes = outerCtes != null && outerCtes.size() > 0;
1491        boolean haveInnerCtes = savedSourceCtes != null && savedSourceCtes.size() > 0;
1492        if (haveOuterCtes && haveInnerCtes) {
1493            // Slice 108 — two-pass walker. Null both AST CTE lists before
1494            // calling buildSelectBodyAfterCteWalk so the helper does not
1495            // re-process source.getCteList(). hasOuterCteListAlreadyProcessed
1496            // is passed true (round-2 codex BLOCKER 4 fix).
1497            source.setCteList(null);
1498            insert.setCteList(null);
1499            handoffApplied = true;
1500            try {
1501                List<StatementGraph> innerStmts = new ArrayList<>();
1502                List<LineageEdge> innerLineage = new ArrayList<>();
1503                Map<String, Integer> cteMap = new HashMap<>();
1504                Map<String, List<String>> publishedMap = new HashMap<>();
1505                // Outer pass: outerAllNames as its own scope.
1506                buildSelectCteList(outerCtes, provider, innerStmts, innerLineage,
1507                        cteMap, publishedMap,
1508                        /*allowShadowOverride=*/ false,
1509                        /*additionalAllCteNames=*/ null);
1510                // Inner pass: outerAllNames also visible for forward-ref
1511                // classification (round-1 codex BLOCKER 2 fix); shadow
1512                // override admits cross-boundary duplicate names.
1513                Set<String> outerAllNames = collectCteNames(outerCtes);
1514                buildSelectCteList(savedSourceCtes, provider, innerStmts, innerLineage,
1515                        cteMap, publishedMap,
1516                        /*allowShadowOverride=*/ true,
1517                        /*additionalAllCteNames=*/ outerAllNames);
1518                // Source SELECT body sees the post-pass cteMap (inner wins
1519                // for shadowed names).
1520                buildSelectBodyAfterCteWalk(source, provider, innerStmts, innerLineage,
1521                        cteMap, publishedMap,
1522                        /*hasOuterCteListAlreadyProcessed=*/ true);
1523                inner = new SemanticProgram(innerStmts, innerLineage);
1524            } finally {
1525                source.setCteList(savedSourceCtes);
1526                insert.setCteList(outerCtes);
1527            }
1528        } else {
1529            // Single-sided cases. Slice 104 AST handoff for outer-only;
1530            // pass-through for inner-only or no CTEs.
1531            if (haveOuterCtes) {
1532                source.setCteList(outerCtes);
1533                insert.setCteList(null);
1534                handoffApplied = true;
1535            }
1536            try {
1537                inner = build(source, provider);
1538            } finally {
1539                if (handoffApplied) {
1540                    source.setCteList(savedSourceCtes);
1541                    insert.setCteList(outerCtes);
1542                }
1543            }
1544        }
1545
1546        // Slice 93 — delegate INSERT-graph assembly to the shared helper
1547        // used by both single-target (slice 78) and Hive multi-insert
1548        // (slice 93). out is freshly empty so the helper's rebase offset
1549        // is 0 (no-op for inner lineage). RETURNING/OUTPUT clauses are
1550        // passed directly (slice 85 still owns the projection build).
1551        List<StatementGraph> out = new ArrayList<>(inner.getStatements().size() + 1);
1552        List<LineageEdge> outLineage = new ArrayList<>();
1553        assembleInsertGraphAndLineage(
1554                insert, targetTable, targetQName, inner,
1555                "INSERT",
1556                insert.getReturningClause(),
1557                insert.getOutputClause(),
1558                out, outLineage, provider);
1559        return new SemanticProgram(out, outLineage);
1560    }
1561
1562    /**
1563     * Slice 93 — admit a Hive multi-insert block of the form
1564     * {@code FROM src INSERT INTO t1 SELECT col1 INSERT INTO t2 SELECT col2}.
1565     *
1566     * <p>The parser represents the whole block as one {@link TInsertSqlStatement}
1567     * whose first INSERT-SELECT pair is the primary statement and whose
1568     * additional pairs are in {@link TInsertSqlStatement#getMultiInsertStatements()}.
1569     * Crucially, each sub-SELECT already carries the shared FROM source in its own
1570     * {@code fromClause} / {@code fromSourceTable} — no post-processing is needed.
1571     *
1572     * <p>Produces a flat {@link SemanticProgram} containing per-pair blocks of
1573     * statements concatenated in INSERT order: each block contributes its source
1574     * SELECT's inner statements (CTE bodies / FROM-subquery bodies extracted by
1575     * {@link #build}) followed by its outer SELECT followed by an INSERT graph.
1576     * The minimum is {@code 2N} statements (one SELECT + one INSERT per target);
1577     * sub-SELECTs with extracted inner programs produce more. Each INSERT carries
1578     * cross-statement lineage edges pointing at its preceding SELECT via
1579     * {@link LineageRef#statementOutput}; per-pair inner lineage edges are
1580     * rebased by the current {@code out.size()} so absolute statement indices
1581     * remain valid across the concatenated program.
1582     *
1583     * <p>Safety note on the source-table fallback: this method enables
1584     * {@code provider.withSourceTableFallback(true)} so secondary sub-SELECTs
1585     * (which Resolver2 does not traverse) can still bind their column refs.
1586     * The fallback is constrained at the provider level to fire only when
1587     * Phase 2 did not run AND any explicit qualifier matches Phase 1's source —
1588     * see {@link Resolver2NameBindingProvider#bindColumn}. Current Hive
1589     * multi-insert parses always present a single FROM source, so Phase 1's
1590     * unqualified-column resolution is unambiguous in practice.
1591     */
1592    private static SemanticProgram buildHiveMultiInsert(TInsertSqlStatement insert,
1593                                                        NameBindingProvider provider) {
1594        // Slice 93 — source-table fallback strategy for Hive multi-insert.
1595        //
1596        // TSQLResolver2 does NOT process the secondary inserts in
1597        // getMultiInsertStatements(): their column refs have
1598        // resolution == null (Phase 2 did not run) even though Phase 1's
1599        // linkColumnToTable sets sourceTable. To let collectColumnRefs
1600        // accept these bindings, we enable a narrow source-table fallback
1601        // in the provider — but ONLY when every sub-SELECT has a SINGLE
1602        // FROM source (the common Hive multi-insert shape that current
1603        // parser support admits). In single-source contexts, Phase 1's
1604        // unqualified-column resolution is unambiguous; the fallback is
1605        // safe (round-2 codex Q1 BLOCKING).
1606        //
1607        // If any sub-SELECT has multiple FROM sources, Phase 1 may have
1608        // heuristically picked one source for an unqualified column —
1609        // promoting that to EXACT_MATCH could silently mis-bind. In that
1610        // case the fallback stays disabled; users must qualify column
1611        // references in the secondary branch (the qualifier-matches-source
1612        // safety in bindColumn still allows qualified refs through).
1613        boolean singleSource = isSingleSourceMultiInsert(insert);
1614        NameBindingProvider effectiveProvider = singleSource
1615                ? provider.withSourceTableFallback(true)
1616                : provider;
1617
1618        List<StatementGraph> out = new ArrayList<>();
1619        List<LineageEdge> outLineage = new ArrayList<>();
1620
1621        // Slice 109 — outer WITH on multi-insert: build the CTE bodies ONCE
1622        // upfront so each sub-SELECT's `FROM x` resolves against the shared
1623        // cteMap/publishedMap. The parser attaches the outer WITH to the
1624        // primary insert's getCteList(); sub-INSERTs in
1625        // getMultiInsertStatements() carry null cteLists. The AST handoff
1626        // mirrors the slice-104 single-target pattern but only nulls
1627        // insert.getCteList() — there is no source SELECT to move it onto
1628        // because each sub-INSERT has its own.
1629        TCTEList outerCtes = insert.getCteList();
1630        Map<String, Integer> cteMap = new HashMap<>();
1631        Map<String, List<String>> publishedMap = new HashMap<>();
1632        boolean handoffApplied = false;
1633        if (outerCtes != null && outerCtes.size() > 0) {
1634            insert.setCteList(null);
1635            handoffApplied = true;
1636            try {
1637                buildSelectCteList(outerCtes, effectiveProvider, out, outLineage,
1638                        cteMap, publishedMap,
1639                        /*allowShadowOverride=*/ false,
1640                        /*additionalAllCteNames=*/ null);
1641            } catch (RuntimeException ex) {
1642                // Restore eagerly on CTE-build failure so a downstream caller
1643                // observing the AST sees the original cteList.
1644                insert.setCteList(outerCtes);
1645                throw ex;
1646            }
1647        }
1648
1649        try {
1650            // Primary INSERT (first target)
1651            appendOneHiveInsert(insert, effectiveProvider, out, outLineage,
1652                    cteMap, publishedMap);
1653
1654            // Additional INSERTs from getMultiInsertStatements()
1655            for (Object miObj : insert.getMultiInsertStatements()) {
1656                appendOneHiveInsert((TInsertSqlStatement) miObj, effectiveProvider,
1657                        out, outLineage, cteMap, publishedMap);
1658            }
1659        } finally {
1660            if (handoffApplied) {
1661                insert.setCteList(outerCtes);
1662            }
1663        }
1664
1665        return new SemanticProgram(out, outLineage);
1666    }
1667
1668    /**
1669     * Slice 93 — true when every INSERT-SELECT pair in a Hive multi-insert
1670     * block has a single FROM source (i.e., one entry in
1671     * {@code subQuery.getTables()}). Guards the source-table fallback so
1672     * Phase 1's heuristic source assignment is only trusted in contexts
1673     * where it is provably unambiguous (round-2 codex Q1 BLOCKING).
1674     */
1675    private static boolean isSingleSourceMultiInsert(TInsertSqlStatement insert) {
1676        if (!isSingleSourceSubQuery(insert.getSubQuery())) {
1677            return false;
1678        }
1679        for (Object miObj : insert.getMultiInsertStatements()) {
1680            TInsertSqlStatement mi = (TInsertSqlStatement) miObj;
1681            if (!isSingleSourceSubQuery(mi.getSubQuery())) {
1682                return false;
1683            }
1684        }
1685        return true;
1686    }
1687
1688    private static boolean isSingleSourceSubQuery(TSelectSqlStatement sel) {
1689        return sel != null && sel.getTables() != null && sel.getTables().size() == 1;
1690    }
1691
1692    /**
1693     * Build one INSERT-SELECT pair into {@code out} / {@code outLineage}.
1694     * Called by {@link #buildHiveMultiInsert} for the primary and each
1695     * additional INSERT in a Hive multi-insert block. Each call validates
1696     * the target/source, builds the source SELECT via {@link #build}, and
1697     * delegates the post-build INSERT-graph assembly to
1698     * {@link #assembleInsertGraphAndLineage} so the layout exactly mirrors
1699     * the single-target slice-78 INSERT path (the helper also handles
1700     * inner-lineage rebasing when {@code out} is non-empty).
1701     */
1702    private static void appendOneHiveInsert(TInsertSqlStatement insert,
1703                                            NameBindingProvider provider,
1704                                            List<StatementGraph> out,
1705                                            List<LineageEdge> outLineage,
1706                                            Map<String, Integer> cteMap,
1707                                            Map<String, List<String>> publishedMap) {
1708        TTable targetTable = insert.getTargetTable();
1709        if (targetTable == null || targetTable.getTableName() == null) {
1710            throw new SemanticIRBuildException(Diagnostic.error(
1711                    DiagnosticCode.INSERT_TARGET_MISSING,
1712                    "Hive multi-insert: INSERT has no resolvable target table",
1713                    insert));
1714        }
1715        String targetQName = targetTable.getTableName().toString();
1716        if (targetQName.isEmpty()) {
1717            throw new SemanticIRBuildException(Diagnostic.error(
1718                    DiagnosticCode.INSERT_TARGET_MISSING,
1719                    "Hive multi-insert: INSERT target table name is empty",
1720                    insert));
1721        }
1722        TSelectSqlStatement source = insert.getSubQuery();
1723        if (source == null) {
1724            throw new SemanticIRBuildException(Diagnostic.error(
1725                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
1726                    "Hive multi-insert: INSERT has no source SELECT",
1727                    insert));
1728        }
1729
1730        // Slice 109 — when outer CTEs are present (cteMap non-empty), build
1731        // the source SELECT via buildSelectBodyAfterCteWalk directly into
1732        // out/outLineage so it sees the shared cteMap/publishedMap. The
1733        // slice-93 path (no outer CTEs) keeps the build(source, provider) +
1734        // assembleInsertGraphAndLineage flow unchanged.
1735        if (cteMap.isEmpty()) {
1736            SemanticProgram inner = build(source, provider);
1737            // Hive has no RETURNING/OUTPUT — pass null clauses directly.
1738            assembleInsertGraphAndLineage(
1739                    insert, targetTable, targetQName, inner,
1740                    "Hive multi-insert: INSERT",
1741                    /*returningClause=*/ null,
1742                    /*outputClause=*/ null,
1743                    out, outLineage, provider);
1744            return;
1745        }
1746
1747        // Slice 109 — defensive: parser probe shows sub-SELECTs in Hive
1748        // multi-insert do NOT carry their own cteList. If a future parser
1749        // change ever attached one, mixed outer+inner WITH semantics would
1750        // need slice-107/108-style two-pass walker support; until then the
1751        // shape rejects with the existing mixed-WITH code.
1752        if (source.getCteList() != null && source.getCteList().size() > 0) {
1753            throw new SemanticIRBuildException(Diagnostic.error(
1754                    DiagnosticCode.INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED,
1755                    "Hive multi-insert: mixed outer + inner WITH on a "
1756                            + "sub-SELECT is not supported by "
1757                            + "SemanticIRBuilder.buildHiveMultiInsert; "
1758                            + "slice 109 admits outer-only WITH on multi-insert",
1759                    insert));
1760        }
1761
1762        // Snapshot out.size() so the source SELECT and its inner extractions
1763        // are pinned to known positions. The slice-23 EXISTS-extraction and
1764        // FROM-subquery extraction paths inside buildSelectBodyAfterCteWalk
1765        // append directly to out/outLineage; the source SELECT lands LAST.
1766        int beforeSelectIdx = out.size();
1767        buildSelectBodyAfterCteWalk(source, provider, out, outLineage,
1768                cteMap, publishedMap,
1769                /*hasOuterCteListAlreadyProcessed=*/ true);
1770        if (out.size() <= beforeSelectIdx) {
1771            // Defensive: buildSelectBodyAfterCteWalk always appends at least
1772            // the source SELECT; this branch is unreachable in practice.
1773            throw new SemanticIRBuildException(Diagnostic.error(
1774                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
1775                    "Hive multi-insert: INSERT source built no statements",
1776                    insert));
1777        }
1778        int selectIdx = out.size() - 1;
1779        assembleInsertTargetGraphFromAppended(
1780                insert, targetTable, targetQName, selectIdx,
1781                "Hive multi-insert: INSERT",
1782                /*returningClause=*/ null,
1783                /*outputClause=*/ null,
1784                out, outLineage, provider);
1785    }
1786
1787    /**
1788     * Slice 93 — shared INSERT-graph assembly used by both the slice-78
1789     * single-target {@link #buildInsert} and the slice-93 Hive multi-insert
1790     * {@link #appendOneHiveInsert}. Appends {@code inner.getStatements()}
1791     * to {@code out} (rebasing {@code inner.getLineage()}'s STATEMENT_OUTPUT
1792     * indices when {@code out} is non-empty), then appends an INSERT-kind
1793     * {@link StatementGraph} and per-source-output cross-statement
1794     * {@link LineageEdge}s.
1795     *
1796     * <p>Discriminators between the two callers:
1797     * <ul>
1798     *   <li>{@code diagnosticPrefix} is woven into column-count-mismatch
1799     *       and empty-inner-source error messages so the originating call
1800     *       site is identifiable.</li>
1801     *   <li>{@code returningClause} / {@code outputClause} are passed
1802     *       directly to {@link #buildReturningColumns} (slice 78 supplies
1803     *       the INSERT's RETURNING/OUTPUT clauses; slice 93's Hive path
1804     *       passes {@code null}/{@code null} since Hive has no
1805     *       RETURNING/OUTPUT). Passing the clauses directly keeps the
1806     *       discriminator visible at every call site rather than hidden
1807     *       behind a boolean (round-2 codex Q3 suggestion).</li>
1808     * </ul>
1809     *
1810     * <p>Mutates both {@code out} and {@code outLineage}.
1811     */
1812    private static void assembleInsertGraphAndLineage(
1813            TInsertSqlStatement insert,
1814            TTable targetTable,
1815            String targetQName,
1816            SemanticProgram inner,
1817            String diagnosticPrefix,
1818            TReturningClause returningClause,
1819            TOutputClause outputClause,
1820            List<StatementGraph> out,
1821            List<LineageEdge> outLineage,
1822            NameBindingProvider provider) {
1823        List<StatementGraph> innerStmts = inner.getStatements();
1824        if (innerStmts.isEmpty()) {
1825            // Defensive: build() always returns at least one statement when
1826            // it doesn't throw. This branch is unreachable in practice but
1827            // surfaces a structured diagnostic instead of an
1828            // IndexOutOfBoundsException on the sourceOuter access below.
1829            throw new SemanticIRBuildException(Diagnostic.error(
1830                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
1831                    diagnosticPrefix + " source built no statements",
1832                    insert));
1833        }
1834
1835        // Rebase inner lineage edges by the current out.size() offset
1836        // (round-2 codex Q4 BLOCKING). For the slice-78 single-target
1837        // path out is empty (offset=0) so rebase is a no-op; for the
1838        // slice-93 Hive path each subsequent INSERT-SELECT pair adds
1839        // an offset matching the absolute position of its inner block.
1840        int offset = out.size();
1841        int selectIdx = offset + innerStmts.size() - 1;
1842        out.addAll(innerStmts);
1843        for (LineageEdge e : inner.getLineage()) {
1844            outLineage.add(rebaseLineageEdge(e, offset));
1845        }
1846
1847        StatementGraph sourceOuter = innerStmts.get(innerStmts.size() - 1);
1848        List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns();
1849        int sourceOutCount = sourceOutputs.size();
1850
1851        // Optional explicit INSERT column list. Verbatim bare-name
1852        // spelling per slice-78 contract; arity mismatch rejects.
1853        TObjectNameList colList = insert.getColumnList();
1854        List<String> targetColumnNames = new ArrayList<>();
1855        if (colList != null && colList.size() > 0) {
1856            for (int i = 0; i < colList.size(); i++) {
1857                TObjectName n = colList.getObjectName(i);
1858                targetColumnNames.add(n == null ? "" : n.toString());
1859            }
1860            if (targetColumnNames.size() != sourceOutCount) {
1861                throw new SemanticIRBuildException(Diagnostic.error(
1862                        DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH,
1863                        diagnosticPrefix + " column list has "
1864                                + targetColumnNames.size()
1865                                + " column(s) but source SELECT produced "
1866                                + sourceOutCount + " output(s)",
1867                        insert));
1868            }
1869        }
1870
1871        // INSERT StatementGraph — slice-78 single-target shape with the
1872        // source SELECT as a SUBQUERY-kind relation entry.
1873        String sourceName = sourceOuter.getName();
1874        String sourceRelAlias = (sourceName != null && !sourceName.isEmpty())
1875                ? sourceName : "__insert_source__";
1876        RelationBinding sourceBinding = new RelationBinding(
1877                RelationKind.SUBQUERY, sourceRelAlias);
1878        List<RelationSource> insertRelations = new ArrayList<>();
1879        insertRelations.add(new RelationSource(sourceRelAlias, sourceBinding));
1880
1881        RelationBinding targetBinding = new RelationBinding(
1882                RelationKind.TABLE, targetQName);
1883        TargetRelation target = new TargetRelation(targetBinding, targetColumnNames);
1884
1885        int insertIdx = out.size();
1886        String insertTargetAlias = effectiveAliasOf(targetTable);
1887        if (insertTargetAlias == null || insertTargetAlias.isEmpty()) {
1888            insertTargetAlias = targetQName;
1889        }
1890        // Slice 85: RETURNING/OUTPUT projections. Clauses are passed
1891        // through directly from the call site (slice-78 single-target
1892        // forwards the INSERT's own clauses; slice-93 Hive multi-insert
1893        // forwards null/null since Hive has no RETURNING/OUTPUT).
1894        List<OutputColumn> returningCols = buildReturningColumns(
1895                returningClause,
1896                outputClause,
1897                "INSERT",
1898                targetQName,
1899                insertTargetAlias,
1900                targetTable,
1901                /*fromSideRelations=*/ Collections.<RelationSource>emptyList(),
1902                /*fromSideAliasToStmtIndex=*/ Collections.<String, Integer>emptyMap(),
1903                provider,
1904                insertIdx,
1905                outLineage,
1906                insert);
1907
1908        StatementGraph insertOuter = new StatementGraph(
1909                /*name=*/ null,
1910                "INSERT",
1911                insertRelations,
1912                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
1913                returningCols,
1914                /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(),
1915                /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(),
1916                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
1917                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
1918                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
1919                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
1920                /*distinct=*/ false,
1921                /*setOperator=*/ null,
1922                /*rowLimit=*/ null,
1923                target);
1924        out.add(insertOuter);
1925
1926        // Cross-statement lineage: target.col_i ← STATEMENT_OUTPUT(selectIdx, srcName_i)
1927        for (int i = 0; i < sourceOutCount; i++) {
1928            String srcName = sourceOutputs.get(i).getName();
1929            String tgtName = (i < targetColumnNames.size())
1930                    ? targetColumnNames.get(i) : srcName;
1931            if (tgtName == null || tgtName.isEmpty()) {
1932                continue;
1933            }
1934            outLineage.add(new LineageEdge(
1935                    LineageRef.tableColumn(targetQName, tgtName),
1936                    LineageRef.statementOutput(selectIdx, srcName)));
1937        }
1938    }
1939
1940    /**
1941     * Slice 109 — assemble the INSERT-target half (TargetRelation, INSERT
1942     * StatementGraph, RETURNING/OUTPUT projections, and cross-statement
1943     * lineage edges) when the source SELECT and its inner extractions have
1944     * ALREADY been appended directly to {@code out}/{@code outLineage} by
1945     * {@link #buildSelectBodyAfterCteWalk}. The slice-93
1946     * {@link #assembleInsertGraphAndLineage} helper, by contrast, takes a
1947     * pre-built {@link SemanticProgram} and rebases STATEMENT_OUTPUT
1948     * indices on the way in — that path is unused here because the source
1949     * SELECT was already built into absolute positions in {@code out}.
1950     *
1951     * <p>{@code selectIdx} must be the position of the source SELECT in
1952     * {@code out} (last statement appended by the caller before this helper
1953     * runs). RETURNING/OUTPUT clauses are passed directly (Hive multi-
1954     * insert callers pass {@code null}/{@code null}); other DMLs that
1955     * adopt this helper later can forward their own.
1956     */
1957    private static void assembleInsertTargetGraphFromAppended(
1958            TInsertSqlStatement insert,
1959            TTable targetTable,
1960            String targetQName,
1961            int selectIdx,
1962            String diagnosticPrefix,
1963            TReturningClause returningClause,
1964            TOutputClause outputClause,
1965            List<StatementGraph> out,
1966            List<LineageEdge> outLineage,
1967            NameBindingProvider provider) {
1968        StatementGraph sourceOuter = out.get(selectIdx);
1969        List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns();
1970        int sourceOutCount = sourceOutputs.size();
1971
1972        TObjectNameList colList = insert.getColumnList();
1973        List<String> targetColumnNames = new ArrayList<>();
1974        if (colList != null && colList.size() > 0) {
1975            for (int i = 0; i < colList.size(); i++) {
1976                TObjectName n = colList.getObjectName(i);
1977                targetColumnNames.add(n == null ? "" : n.toString());
1978            }
1979            if (targetColumnNames.size() != sourceOutCount) {
1980                throw new SemanticIRBuildException(Diagnostic.error(
1981                        DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH,
1982                        diagnosticPrefix + " column list has "
1983                                + targetColumnNames.size()
1984                                + " column(s) but source SELECT produced "
1985                                + sourceOutCount + " output(s)",
1986                        insert));
1987            }
1988        }
1989
1990        String sourceName = sourceOuter.getName();
1991        String sourceRelAlias = (sourceName != null && !sourceName.isEmpty())
1992                ? sourceName : "__insert_source__";
1993        RelationBinding sourceBinding = new RelationBinding(
1994                RelationKind.SUBQUERY, sourceRelAlias);
1995        List<RelationSource> insertRelations = new ArrayList<>();
1996        insertRelations.add(new RelationSource(sourceRelAlias, sourceBinding));
1997
1998        RelationBinding targetBinding = new RelationBinding(
1999                RelationKind.TABLE, targetQName);
2000        TargetRelation target = new TargetRelation(targetBinding, targetColumnNames);
2001
2002        int insertIdx = out.size();
2003        String insertTargetAlias = effectiveAliasOf(targetTable);
2004        if (insertTargetAlias == null || insertTargetAlias.isEmpty()) {
2005            insertTargetAlias = targetQName;
2006        }
2007        List<OutputColumn> returningCols = buildReturningColumns(
2008                returningClause,
2009                outputClause,
2010                "INSERT",
2011                targetQName,
2012                insertTargetAlias,
2013                targetTable,
2014                /*fromSideRelations=*/ Collections.<RelationSource>emptyList(),
2015                /*fromSideAliasToStmtIndex=*/ Collections.<String, Integer>emptyMap(),
2016                provider,
2017                insertIdx,
2018                outLineage,
2019                insert);
2020
2021        StatementGraph insertOuter = new StatementGraph(
2022                /*name=*/ null,
2023                "INSERT",
2024                insertRelations,
2025                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
2026                returningCols,
2027                /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2028                /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2029                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2030                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2031                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2032                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2033                /*distinct=*/ false,
2034                /*setOperator=*/ null,
2035                /*rowLimit=*/ null,
2036                target);
2037        out.add(insertOuter);
2038
2039        for (int i = 0; i < sourceOutCount; i++) {
2040            String srcName = sourceOutputs.get(i).getName();
2041            String tgtName = (i < targetColumnNames.size())
2042                    ? targetColumnNames.get(i) : srcName;
2043            if (tgtName == null || tgtName.isEmpty()) {
2044                continue;
2045            }
2046            outLineage.add(new LineageEdge(
2047                    LineageRef.tableColumn(targetQName, tgtName),
2048                    LineageRef.statementOutput(selectIdx, srcName)));
2049        }
2050    }
2051
2052    /**
2053     * Slice 93 — rebase a {@link LineageEdge}'s {@code STATEMENT_OUTPUT}
2054     * statement indices by {@code offset}. {@code TABLE_COLUMN} refs are
2055     * returned unchanged. Used to concatenate inner {@link SemanticProgram}s
2056     * into a larger one (Hive multi-insert: each INSERT-SELECT pair's inner
2057     * program contributes its own block of statements).
2058     */
2059    private static LineageEdge rebaseLineageEdge(LineageEdge e, int offset) {
2060        if (offset == 0) {
2061            return e;
2062        }
2063        LineageRef from = rebaseLineageRef(e.getFrom(), offset);
2064        LineageRef to = rebaseLineageRef(e.getTo(), offset);
2065        if (from == e.getFrom() && to == e.getTo()) {
2066            return e;
2067        }
2068        return new LineageEdge(from, to);
2069    }
2070
2071    private static LineageRef rebaseLineageRef(LineageRef ref, int offset) {
2072        if (ref == null) {
2073            return null;
2074        }
2075        if (ref.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) {
2076            return ref;
2077        }
2078        return LineageRef.statementOutput(
2079                ref.getStatementIndex() + offset, ref.getOutputName());
2080    }
2081
2082    /**
2083     * Slice 79 — admit a single {@code CREATE TABLE target [(c1, ...)] AS
2084     * SELECT ...} (CTAS) statement. Builds the source SELECT via
2085     * {@link #build} unchanged, then appends a {@code "CREATE_TABLE"}-
2086     * kind {@link StatementGraph} carrying the target relation and
2087     * cross-statement lineage edges (mirrors slice-78 INSERT).
2088     *
2089     * <p>Admitted shape: {@code CREATE [OR REPLACE] TABLE target
2090     * [(c1, c2, ...)] AS <subquery-SELECT>}. Plain
2091     * {@code CREATE TABLE target (a INT, b VARCHAR)} (column DDL with
2092     * no AS SELECT) is rejected via
2093     * {@link DiagnosticCode#CREATE_AS_NO_SOURCE_SELECT}. Explicit
2094     * column-list arity mismatch surfaces as
2095     * {@link DiagnosticCode#CREATE_AS_COLUMN_COUNT_MISMATCH}; a
2096     * missing / empty target name surfaces (defensively) as
2097     * {@link DiagnosticCode#CREATE_AS_TARGET_MISSING}.
2098     *
2099     * <p>For CTAS the explicit column-list spellings come from
2100     * {@link TCreateTableSqlStatement#getColumnList()} — only the bare
2101     * column name from each {@link TColumnDefinition} is consumed;
2102     * data-type tokens are ignored by slice 79.
2103     */
2104    public static SemanticProgram buildCreateTable(TCreateTableSqlStatement create,
2105                                                   NameBindingProvider provider) {
2106        if (create == null) {
2107            throw new IllegalArgumentException("create must not be null");
2108        }
2109        if (provider == null) {
2110            throw new IllegalArgumentException("provider must not be null");
2111        }
2112
2113        // Target name extraction. CTAS exposes the target via the
2114        // TCustomSqlStatement-inherited getTargetTable(); the explicit
2115        // getTableName() is a thin wrapper around tables[0].getTableName()
2116        // and also works. Use getTableName() for symmetry with the
2117        // slice-78 INSERT path.
2118        TObjectName targetName = create.getTableName();
2119        if (targetName == null) {
2120            throw new SemanticIRBuildException(Diagnostic.error(
2121                    DiagnosticCode.CREATE_AS_TARGET_MISSING,
2122                    "CREATE TABLE has no resolvable target table name",
2123                    create));
2124        }
2125        String targetQName = targetName.toString();
2126        if (targetQName == null || targetQName.isEmpty()) {
2127            throw new SemanticIRBuildException(Diagnostic.error(
2128                    DiagnosticCode.CREATE_AS_TARGET_MISSING,
2129                    "CREATE TABLE target table name is empty",
2130                    create));
2131        }
2132
2133        TSelectSqlStatement source = create.getSubQuery();
2134        if (source == null) {
2135            throw new SemanticIRBuildException(Diagnostic.error(
2136                    DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT,
2137                    "CREATE TABLE has no AS SELECT subquery; slice 79 admits "
2138                            + "CTAS (CREATE TABLE <target> [(c1, ...)] AS SELECT ...) only",
2139                    create));
2140        }
2141
2142        // Pull explicit column-list spellings BEFORE building the inner
2143        // — keeps the error path cheap for the structural-invalid case
2144        // (CTAS with column count mismatch is detected after the inner
2145        // build because we don't know the source output count yet).
2146        List<String> targetColumnNames = new ArrayList<>();
2147        TColumnDefinitionList colList = create.getColumnList();
2148        if (colList != null && colList.size() > 0) {
2149            for (int i = 0; i < colList.size(); i++) {
2150                TColumnDefinition cd = colList.getColumn(i);
2151                TObjectName n = (cd == null) ? null : cd.getColumnName();
2152                String spelling = (n == null) ? "" : n.toString();
2153                targetColumnNames.add(spelling);
2154            }
2155        }
2156
2157        return assembleCreateLikeProgram(create, source, provider,
2158                "CREATE_TABLE", targetQName, targetColumnNames);
2159    }
2160
2161    /**
2162     * Slice 79 — admit a single
2163     * {@code CREATE [OR REPLACE] VIEW v [(c1, ...)] AS SELECT ...}
2164     * statement. Mirrors {@link #buildCreateTable} except the source
2165     * SELECT is fetched via {@link TCreateViewSqlStatement#getSubquery()}
2166     * (lowercase 'q'), the target name from
2167     * {@link TCreateViewSqlStatement#getViewName()}, and the explicit
2168     * column-list spellings from {@link TViewAliasClause} on the AST.
2169     */
2170    public static SemanticProgram buildCreateView(TCreateViewSqlStatement create,
2171                                                  NameBindingProvider provider) {
2172        if (create == null) {
2173            throw new IllegalArgumentException("create must not be null");
2174        }
2175        if (provider == null) {
2176            throw new IllegalArgumentException("provider must not be null");
2177        }
2178
2179        TObjectName viewName = create.getViewName();
2180        if (viewName == null) {
2181            throw new SemanticIRBuildException(Diagnostic.error(
2182                    DiagnosticCode.CREATE_AS_TARGET_MISSING,
2183                    "CREATE VIEW has no resolvable view name",
2184                    create));
2185        }
2186        String targetQName = viewName.toString();
2187        if (targetQName == null || targetQName.isEmpty()) {
2188            throw new SemanticIRBuildException(Diagnostic.error(
2189                    DiagnosticCode.CREATE_AS_TARGET_MISSING,
2190                    "CREATE VIEW target view name is empty",
2191                    create));
2192        }
2193
2194        TSelectSqlStatement source = create.getSubquery();
2195        if (source == null) {
2196            throw new SemanticIRBuildException(Diagnostic.error(
2197                    DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT,
2198                    "CREATE VIEW has no AS SELECT subquery; slice 79 admits "
2199                            + "CREATE VIEW <target> [(c1, ...)] AS SELECT ... only",
2200                    create));
2201        }
2202
2203        // View-side explicit column aliases via viewAliasClause. Items
2204        // whose alias is null are preserved as empty-string entries so
2205        // a parser-quirk gap doesn't silently collapse the list and
2206        // shift later aliases onto wrong source-output positions —
2207        // count-mismatch detection downstream stays accurate
2208        // (codex diff-review round 1 P2 catch).
2209        List<String> targetColumnNames = new ArrayList<>();
2210        TViewAliasClause aliasClause = create.getViewAliasClause();
2211        if (aliasClause != null) {
2212            TViewAliasItemList items = aliasClause.getViewAliasItemList();
2213            if (items != null) {
2214                for (int i = 0; i < items.size(); i++) {
2215                    TViewAliasItem item = items.getViewAliasItem(i);
2216                    TObjectName alias = (item == null) ? null : item.getAlias();
2217                    String spelling = (alias == null) ? "" : alias.toString();
2218                    targetColumnNames.add(spelling);
2219                }
2220            }
2221        }
2222
2223        return assembleCreateLikeProgram(create, source, provider,
2224                "CREATE_VIEW", targetQName, targetColumnNames);
2225    }
2226
2227    /**
2228     * Shared assembly path for slice-79 CTAS / CREATE VIEW. Given a
2229     * pre-validated target name and the (possibly empty) list of
2230     * explicit column-list spellings, builds the source SELECT,
2231     * validates column-list arity, and emits the outer
2232     * StatementGraph + cross-stmt lineage edges. Mirrors the
2233     * post-source half of slice-78 {@link #buildInsert}.
2234     */
2235    private static SemanticProgram assembleCreateLikeProgram(
2236            TParseTreeNode anchor, TSelectSqlStatement source,
2237            NameBindingProvider provider, String outerKind,
2238            String targetQName, List<String> targetColumnNames) {
2239        SemanticProgram inner = build(source, provider);
2240        List<StatementGraph> innerStmts = inner.getStatements();
2241        if (innerStmts.isEmpty()) {
2242            throw new SemanticIRBuildException(Diagnostic.error(
2243                    DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT,
2244                    "CREATE source built no statements",
2245                    anchor));
2246        }
2247        int selectIdx = innerStmts.size() - 1;
2248        StatementGraph sourceOuter = innerStmts.get(selectIdx);
2249        List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns();
2250        int sourceOutCount = sourceOutputs.size();
2251
2252        if (!targetColumnNames.isEmpty()
2253                && targetColumnNames.size() != sourceOutCount) {
2254            throw new SemanticIRBuildException(Diagnostic.error(
2255                    DiagnosticCode.CREATE_AS_COLUMN_COUNT_MISMATCH,
2256                    outerKind.equals("CREATE_TABLE")
2257                            ? ("CREATE TABLE column list has " + targetColumnNames.size()
2258                                    + " column(s) but source SELECT produced "
2259                                    + sourceOutCount + " output(s)")
2260                            : ("CREATE VIEW alias list has " + targetColumnNames.size()
2261                                    + " column(s) but source SELECT produced "
2262                                    + sourceOutCount + " output(s)"),
2263                    anchor));
2264        }
2265
2266        String sourceName = sourceOuter.getName();
2267        String sourceRelAlias = (sourceName != null && !sourceName.isEmpty())
2268                ? sourceName : "__create_source__";
2269        RelationBinding sourceBinding = new RelationBinding(
2270                RelationKind.SUBQUERY, sourceRelAlias);
2271        List<RelationSource> createRelations = new ArrayList<>();
2272        createRelations.add(new RelationSource(sourceRelAlias, sourceBinding));
2273
2274        RelationBinding targetBinding = new RelationBinding(
2275                RelationKind.TABLE, targetQName);
2276        TargetRelation target = new TargetRelation(targetBinding, targetColumnNames);
2277
2278        List<StatementGraph> out = new ArrayList<>(innerStmts.size() + 1);
2279        out.addAll(innerStmts);
2280        List<LineageEdge> outLineage = new ArrayList<>(inner.getLineage());
2281
2282        StatementGraph createOuter = new StatementGraph(
2283                /*name=*/ null,
2284                outerKind,
2285                createRelations,
2286                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
2287                /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2288                /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2289                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2290                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2291                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2292                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2293                /*distinct=*/ false,
2294                /*setOperator=*/ null,
2295                /*rowLimit=*/ null,
2296                target);
2297        out.add(createOuter);
2298
2299        for (int i = 0; i < sourceOutCount; i++) {
2300            String srcName = sourceOutputs.get(i).getName();
2301            String tgtName = (i < targetColumnNames.size())
2302                    ? targetColumnNames.get(i) : srcName;
2303            if (tgtName == null || tgtName.isEmpty()) {
2304                continue;
2305            }
2306            outLineage.add(new LineageEdge(
2307                    LineageRef.tableColumn(targetQName, tgtName),
2308                    LineageRef.statementOutput(selectIdx, srcName)));
2309        }
2310
2311        return new SemanticProgram(out, outLineage);
2312    }
2313
2314    /**
2315     * Slice 80 / 82 — admit {@code UPDATE target SET c1 = expr1,
2316     * c2 = expr2, ... [FROM source_list] [WHERE pred]} statements.
2317     * Emits one {@code "UPDATE"}-kind {@link StatementGraph} carrying
2318     * the target relation plus synthetic {@link OutputColumn} entries
2319     * per SET assignment (output name = SET LHS verbatim spelling;
2320     * sources = column refs collected from the RHS expression).
2321     * Optional WHERE refs surface on
2322     * {@link StatementGraph#getFilterColumnRefs()}.
2323     *
2324     * <p>Slice 82 lifts the slice-80 {@code UPDATE_JOINED_NOT_SUPPORTED}
2325     * reject for the common PG / MSSQL / BigQuery / Snowflake / Redshift
2326     * FROM-side joined UPDATE shapes. The IR shape gains two slots:
2327     * {@code relations[]} now carries TABLE-kind RelationSources for
2328     * FROM-side sources (slice 80 left empty), and
2329     * {@code joinColumnRefs[]} now carries ON-clause column refs from
2330     * FROM-side JOINs. The target stays on
2331     * {@link StatementGraph#getTarget()}; a reference-identity filter
2332     * excludes the target's own TTable instance from {@code relations[]}.
2333     *
2334     * <p>Admitted shape:
2335     * <ul>
2336     *   <li>Single-target UPDATE without FROM (slice 80) —
2337     *       {@code relations[]} stays empty.</li>
2338     *   <li>PG / BQ / SF / RS {@code UPDATE t SET ... FROM source}
2339     *       (single FROM source).</li>
2340     *   <li>PG / BQ {@code UPDATE t SET ... FROM s1, s2, ...}
2341     *       (comma-FROM list).</li>
2342     *   <li>PG / MSSQL {@code UPDATE t SET ... FROM s1 [INNER|LEFT|RIGHT|FULL OUTER] JOIN s2 ON ...}
2343     *       — ON refs populate {@code joinColumnRefs[]}.</li>
2344     *   <li>MSSQL {@code UPDATE t SET ... FROM t INNER JOIN s ON ...}
2345     *       — target may appear in FROM; reference-identity filter
2346     *       excludes the target's own TTable instance from
2347     *       {@code relations[]}.</li>
2348     *   <li>Explicit {@code CROSS JOIN} (no ON; semantically equivalent
2349     *       to comma-FROM).</li>
2350     *   <li>SET LHS is a {@link EExpressionType#simple_object_name_t}
2351     *       column reference (qualified {@code t.x} or bare {@code x}).
2352     *       Oracle tuple {@code SET (a, b) = (...)} (LHS = list_t)
2353     *       rejects via
2354     *       {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}.</li>
2355     *   <li>SET RHS may be any expression NOT containing a scalar
2356     *       subquery and NOT containing a window function. Subqueries
2357     *       reject via
2358     *       {@link DiagnosticCode#UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED};
2359     *       window functions reuse the existing
2360     *       {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK}
2361     *       routed through {@link #rejectWindowFunctionInScope}.</li>
2362     *   <li>Optional WHERE clause — existing WHERE-side rejects
2363     *       (subqueries, window functions) continue to apply via the
2364     *       shared {@link #containsAnySubquery} +
2365     *       {@code rejectWindowFunctionInScope} helpers used by SELECT
2366     *       WHERE.</li>
2367     * </ul>
2368     *
2369     * <p>Slice 82 reject scope, with slice 83 admitting subquery FROM
2370     * sources (the slice-82 {@code UPDATE_FROM_SUBQUERY_NOT_SUPPORTED}
2371     * code stays declared but unreached — slice-71/72
2372     * retain-for-documentation precedent):
2373     * <ul>
2374     *   <li>Subquery as a FROM source — slice 83 admits via the
2375     *       SELECT-side {@code processDirectSubqueryTable} extractor,
2376     *       publishing a SUBQUERY-kind {@link RelationSource} and a
2377     *       cross-statement {@link LineageEdge} per subquery-bound
2378     *       output source.</li>
2379     *   <li>USING in any FROM-side join item —
2380     *       {@link DiagnosticCode#UPDATE_FROM_JOIN_USING_NOT_SUPPORTED}.</li>
2381     *   <li>NATURAL JOIN in any FROM-side join item —
2382     *       {@link DiagnosticCode#UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED}.</li>
2383     *   <li>Subquery in any ON condition —
2384     *       {@link DiagnosticCode#UPDATE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED}.</li>
2385     *   <li>Window function in any ON condition — reuses
2386     *       {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} via
2387     *       {@link #rejectWindowFunctionInScope}.</li>
2388     * </ul>
2389     *
2390     * <p>Deferred (rejected at the outer level before any SET
2391     * processing):
2392     * <ul>
2393     *   <li>Top-level WITH on UPDATE →
2394     *       {@link DiagnosticCode#UPDATE_CTE_NOT_SUPPORTED}.</li>
2395     *   <li>RETURNING projection (PG / Oracle) →
2396     *       {@link DiagnosticCode#UPDATE_RETURNING_CLAUSE_NOT_SUPPORTED}.</li>
2397     *   <li>OUTPUT projection (SQL Server) →
2398     *       {@link DiagnosticCode#UPDATE_OUTPUT_CLAUSE_NOT_SUPPORTED}.</li>
2399     *   <li>ORDER BY / LIMIT on UPDATE (MySQL / Couchbase) →
2400     *       {@link DiagnosticCode#UPDATE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED}.</li>
2401     *   <li>Empty / missing SET clause, Couchbase UNSET-only updates →
2402     *       {@link DiagnosticCode#UPDATE_NO_SET_CLAUSE}.</li>
2403     *   <li>Missing target table (defensive) →
2404     *       {@link DiagnosticCode#UPDATE_TARGET_MISSING}.</li>
2405     * </ul>
2406     *
2407     * <p>Cross-statement {@link LineageEdge}s, one per SET assignment:
2408     * <pre>
2409     *   from = LineageRef.tableColumn(targetQName, target_col_i)
2410     *   to   = LineageRef.statementOutput(0, output_name_i)
2411     * </pre>
2412     * Statement index 0 is the UPDATE statement itself — the synthetic
2413     * output IS the per-assignment "projection" that flows into the
2414     * target column. This is the slice-78 INSERT contract
2415     * (TABLE_COLUMN → STATEMENT_OUTPUT) with the source SELECT replaced
2416     * by the UPDATE's own per-assignment outputs; consumers read
2417     * {@code outputs[i].sources} to enumerate the RHS column refs that
2418     * feed the target column.
2419     */
2420    public static SemanticProgram buildUpdate(TUpdateSqlStatement update,
2421                                              NameBindingProvider provider) {
2422        if (update == null) {
2423            throw new IllegalArgumentException("update must not be null");
2424        }
2425        if (provider == null) {
2426            throw new IllegalArgumentException("provider must not be null");
2427        }
2428
2429        // Slice 86 — defensive UsingScope reset at entry so a parent
2430        // scope cannot leak into UPDATE's binding decisions. Mirrors
2431        // SELECT-side buildSelectStatementImpl (slice 65). The UPDATE's
2432        // own UsingScope is installed after the FROM-join walker (step
2433        // 5.8 below).
2434        provider = provider.withUsingScope(UsingScope.EMPTY);
2435
2436        // 1) Slice 105 — admit top-level WITH on UPDATE. Walks the CTE
2437        // list left-to-right, building each body as a preceding
2438        // StatementGraph and producing cteNameToStatementIndex +
2439        // ctePublishedColumns for the FROM-as-CTE branch in
2440        // buildUpdateRelation below. Mirrors the slice-101 MERGE walker.
2441        // `stmts` / `lineage` allocated here (hoisted from the prior
2442        // slice-83 location) so the CTE walker can append.
2443        // UPDATE_CTE_NOT_SUPPORTED stays declared-but-unreached
2444        // (slice 71/72/82/86/95/96/97/98/99/100/101/102/103/104 precedent).
2445        List<StatementGraph> stmts = new ArrayList<>();
2446        List<LineageEdge> lineage = new ArrayList<>();
2447        Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>();
2448        Map<String, Integer> cteNameToStatementIndex = buildUpdateCteList(
2449                update, provider, stmts, lineage, ctePublishedColumns);
2450
2451        // 2) Target table — defensive (parser usually rejects first).
2452        TTable targetTable = update.getTargetTable();
2453        if (targetTable == null || targetTable.getTableName() == null) {
2454            throw new SemanticIRBuildException(Diagnostic.error(
2455                    DiagnosticCode.UPDATE_TARGET_MISSING,
2456                    "UPDATE statement has no resolvable target table",
2457                    update));
2458        }
2459        String targetQName = targetTable.getTableName().toString();
2460        if (targetQName == null || targetQName.isEmpty()) {
2461            throw new SemanticIRBuildException(Diagnostic.error(
2462                    DiagnosticCode.UPDATE_TARGET_MISSING,
2463                    "UPDATE target table name is empty",
2464                    update));
2465        }
2466
2467        // 3) Slice 82 — FROM-side joined UPDATE is now admitted. The
2468        // slice-80 UPDATE_JOINED_NOT_SUPPORTED rejects (which fired on
2469        // update.tables.size() > 1 and update.getFromSourceJoin() != null)
2470        // are removed. The shape-specific rejects below (subquery in
2471        // FROM, USING, NATURAL, subquery in ON, window in ON) replace
2472        // them. UPDATE_JOINED_NOT_SUPPORTED remains declared but
2473        // unreached for API stability (the residual join-form-target
2474        // shape `UPDATE (a JOIN b) SET ...` does not parse in any
2475        // supported dialect — verified by AST probe).
2476        //
2477        // Reject ordering within buildUpdate: WITH / target-missing /
2478        // RETURNING / OUTPUT / ORDER BY / LIMIT / SET-empty all run
2479        // before the per-source FROM walk so a single rejection wins
2480        // on multi-violation shapes (e.g. `UPDATE t ... FROM s
2481        // RETURNING ...` rejects RETURNING before the FROM walk).
2482
2483        // 4) Slice 85 lifts the RETURNING / OUTPUT rejects — projections
2484        // are now admitted via {@link #buildReturningColumns} called after
2485        // SET / WHERE / FROM walks complete (the projection expressions
2486        // need the providerWithStar binding constructed in step 5.5).
2487        // The cheap statement-level OUTPUT_INTO reject fires here so a
2488        // multi-violation shape (OUTPUT … INTO target with RETURNING
2489        // content errors) routes to the cheaper structural code first.
2490        // {@code UPDATE_RETURNING_CLAUSE_NOT_SUPPORTED} and
2491        // {@code UPDATE_OUTPUT_CLAUSE_NOT_SUPPORTED} stay declared but
2492        // unreached (slice 71/72 retain-for-documentation precedent).
2493        if (update.getOutputClause() != null
2494                && update.getOutputClause().getIntoTable() != null) {
2495            throw new SemanticIRBuildException(Diagnostic.error(
2496                    DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED,
2497                    "UPDATE OUTPUT ... INTO <target> writes a second target; "
2498                            + "slice 85 admits projection-only OUTPUT",
2499                    update));
2500        }
2501        if (update.getOrderByClause() != null
2502                || update.getLimitClause() != null) {
2503            throw new SemanticIRBuildException(Diagnostic.error(
2504                    DiagnosticCode.UPDATE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED,
2505                    "UPDATE with ORDER BY / LIMIT (MySQL / Couchbase) is "
2506                            + "not supported by SemanticIRBuilder.buildUpdate; "
2507                            + "slice 80 admits no row-pruning on UPDATE",
2508                    update));
2509        }
2510
2511        // 5) SET / UNSET — slice 80 requires a non-empty SET clause; a
2512        // Couchbase UNSET-only update (UnSetTerms populated, SET empty)
2513        // routes through the same code with discriminating message text.
2514        TResultColumnList sets = update.getResultColumnList();
2515        boolean hasUnSet = update.getUnSetTerms() != null
2516                && update.getUnSetTerms().size() > 0;
2517        if (sets == null || sets.size() == 0) {
2518            String reason = hasUnSet
2519                    ? "UPDATE has only an UNSET clause (Couchbase); slice 80 "
2520                            + "requires a non-empty SET clause"
2521                    : "UPDATE has no SET clause";
2522            throw new SemanticIRBuildException(Diagnostic.error(
2523                    DiagnosticCode.UPDATE_NO_SET_CLAUSE,
2524                    reason,
2525                    update));
2526        }
2527
2528        // 5.5) Slice 83 — extract FROM subqueries as their own
2529        // StatementGraphs (after slice 105's CTE walker so the CTE
2530        // bodies precede any extracted FROM-subquery in the program).
2531        //
2532        // The extractor reuses the SELECT-side
2533        // {@link #processDirectSubqueryTable} verbatim — passing the
2534        // slice-105 cteNameToStatementIndex + ctePublishedColumns so a
2535        // nested SELECT inside a FROM-subquery can still resolve outer
2536        // CTE references through CTEScope (Resolver2 already binds CTE
2537        // refs in UPDATE correctly; the maps are passed for parity with
2538        // the SELECT/MERGE call sites). Inner predicate subqueries in
2539        // WHERE / JOIN ON / GROUP BY are caught by the slice-17 leak
2540        // guard ({@link #rejectSubqueriesInFromSubqueryBodyClauses}).
2541        //
2542        // No snapshot/rollback wrapper here (codex round-1 Q5 NICE):
2543        // buildUpdate owns fresh local stmts/lineage lists and
2544        // propagates exceptions to the caller — no observer can see
2545        // partial mutation.
2546        //
2547        // Slice 110 — decorate `provider` with `withCteContext` BEFORE
2548        // passing it to `extractUpdateFromSubqueries` so a nested SELECT
2549        // inside an extracted FROM-subquery body (e.g.
2550        // `UPDATE t SET col = sub.x FROM (SELECT id, x FROM cte) sub`)
2551        // routes CTE refs through `RelationKind.CTE`. Mirrors the
2552        // slice-106 DELETE-side `providerWithCte` pattern at line ~3205
2553        // (codex round-2 Q2 BLOCKING fix in slice 106). The slice-105
2554        // UPDATE site missed this decoration; slice 110 closes the gap
2555        // here since it also adds the same decoration on the WHERE-side
2556        // predicate-subquery extraction (line ~2370 below).
2557        NameBindingProvider providerWithCte = cteNameToStatementIndex.isEmpty()
2558                ? provider
2559                : provider.withCteContext(cteNameToStatementIndex.keySet());
2560        Map<String, Integer> subqueryAliasToIndex =
2561                extractUpdateFromSubqueries(update, providerWithCte, stmts, lineage,
2562                        cteNameToStatementIndex, ctePublishedColumns);
2563        // Build the in-scope map (subquery-alias → published column
2564        // names, plus CTE-bound alias → CTE published columns) so
2565        // `provider.withInScopeRelationColumns(map)` recognises
2566        // `sub.x` AND `cte.x` for the consuming UPDATE. Base-table
2567        // FROM-side relations don't need an entry; their column
2568        // resolution stays on the Resolver2 catalog path.
2569        Map<String, List<String>> updateInScope = buildUpdateInScopeMap(
2570                update, subqueryAliasToIndex, stmts,
2571                cteNameToStatementIndex, ctePublishedColumns);
2572        // Slice 110 — base `providerWithStar` on `providerWithCte`
2573        // (instead of raw `provider`) so SET RHS / WHERE / RETURNING
2574        // collectors and the slice-86 USING/NATURAL walker all see the
2575        // outer CTE context. Without this, a CTE-bound reference inside
2576        // a JOIN ON expression or a SET RHS scalar would bind as TABLE-
2577        // kind even when the CTE is declared at the UPDATE level.
2578        NameBindingProvider providerWithStar = updateInScope.isEmpty()
2579                ? providerWithCte
2580                : providerWithCte.withInScopeRelationColumns(updateInScope);
2581
2582        // 5.7) Slice 86 — relocated from slice-82 step 8. The FROM-side
2583        // join walker now runs BEFORE SET RHS / WHERE collection so the
2584        // slice-86 UsingScope (step 5.8 below) can be applied to those
2585        // collectors. Slice 65 SELECT-side ordering: buildRelations →
2586        // buildUsingScope → buildOutputColumns / buildFilter / etc.
2587        // The join walker uses `providerWithStar` (inScope only — no
2588        // UsingScope yet) because USING/NATURAL emit joinColumnRefs[]
2589        // directly via emitMergedJoinRefs without consulting UsingScope.
2590        //
2591        // The walker treats `update.getJoins()` as the authoritative
2592        // FROM-list representation:
2593        //  - PG plain `FROM s` →   joins=[{table=s, items=[]}]
2594        //  - PG comma-FROM   →     joins=[{s1, items=[]}, {s2, items=[]}, ...]
2595        //  - PG / MSSQL explicit JOIN → joins=[{driver, items=[item1,...]}]
2596        //  - MSSQL target-in-FROM → joins=[{target_alias, items=[other,...]}]
2597        //
2598        // For each TJoin: the driver table goes through buildUpdateRelation
2599        // (which applies the slice-82 FROM-source rejects + identity
2600        // filter); each JoinItem is walked through buildUpdateJoinItem
2601        // which (slice 86) admits USING / NATURAL via slice-64/65/66
2602        // shared helpers in addition to ON / CROSS.
2603        List<RelationSource> relations = new ArrayList<>();
2604        // Slice 82 codex round-1 Q2 BLOCKING — LinkedHashSet dedup spans
2605        // the whole FROM so a column appearing in two ON clauses
2606        // produces one entry. Slice 86 USING/NATURAL emit refs also flow
2607        // through this dedup.
2608        java.util.LinkedHashSet<ColumnRef> joinRefsSet =
2609                new java.util.LinkedHashSet<>();
2610        for (TJoin join : update.getJoins()) {
2611            TTable leftTable = join.getTable();
2612            buildUpdateRelation(leftTable, targetTable, relations, update,
2613                    cteNameToStatementIndex);
2614            TJoinItemList items = join.getJoinItems();
2615            if (items == null) continue;
2616            // Slice 86 — per top-level TJoin LeftOutputState seeded
2617            // with providerWithStar (codex round-1 B2 BLOCKING: inScope
2618            // installed so extracted FROM-subquery drivers' published
2619            // columns are visible to lookupRelationColumnNames for
2620            // NATURAL inference). Reset between top-level TJoins so
2621            // comma-FROM groups stay independent (matches SELECT-side
2622            // buildRelations slice-66 behavior).
2623            LeftOutputState leftState = new LeftOutputState();
2624            seedLeftOutput(leftState, leftTable, providerWithStar);
2625            for (int i = 0; i < items.size(); i++) {
2626                TJoinItem item = items.getJoinItem(i);
2627                // Slice 86 — extended buildUpdateJoinItem signature
2628                // threads the join context (topJoin / items / itemIndex)
2629                // and LeftOutputState to the USING/NATURAL admit paths
2630                // so they can call the SELECT-side slice-64/65/66
2631                // shared helpers verbatim.
2632                // Slice 105 — threads cteNameToStatementIndex so the
2633                // join walker's per-item buildUpdateRelation call can
2634                // route objectname-typed CTE references to a SUBQUERY-
2635                // kind RelationSource pointing at the CTE statement.
2636                buildUpdateJoinItem(join, items, i, targetTable,
2637                        providerWithStar, relations, joinRefsSet, leftState,
2638                        update, cteNameToStatementIndex);
2639            }
2640        }
2641        List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet);
2642
2643        // 5.8) Slice 86 — install the UPDATE's own UsingScope on
2644        // providerWithStar AFTER the join walker so SET RHS / WHERE /
2645        // RETURNING refs see merged-key resolution (mirrors SELECT-side
2646        // buildSelectStatementImpl slice 65 ordering). The join walker
2647        // itself emits joinColumnRefs via direct emit-refs helpers, so
2648        // UsingScope is irrelevant to ON refs (matches SELECT-side
2649        // contract).
2650        UsingScope updateUsingScope = buildUpdateUsingScope(update, providerWithStar);
2651        if (!updateUsingScope.isEmpty()) {
2652            providerWithStar = providerWithStar.withUsingScope(updateUsingScope);
2653        }
2654
2655        // 5.9) Slice 115 — extract uncorrelated scalar subqueries on SET
2656        // RHS as their own <scalar_subquery_<idx>> StatementGraphs
2657        // appended to `stmts` BEFORE the UPDATE statement. Mirrors slice
2658        // 11 SELECT-side scalar projection extraction. A SET assignment
2659        // whose RHS is exactly a top-level subquery_t admits as a scalar
2660        // SET RHS: the body is built via buildSelectStatement (with the
2661        // slice-11 scalar-body invariants: allowFromSubqueries=false,
2662        // allowScalarProjectionSubqueries=false, allowWindowProjection=
2663        // false), inner predicate-leak guards run, and the resulting
2664        // ScalarInfo (extracted body index + inner output name) is stored
2665        // for the per-assignment loop and lineage emission below.
2666        //
2667        // Correlated scalar subqueries (whose inner refs would resolve to
2668        // an outer alias such as the UPDATE target or a FROM-side
2669        // relation) STILL reject via the slice-11 promoter called with
2670        // EnclosingScope.empty() — the inner ref's alias does not match
2671        // any local relation and no enclosing scope is provided, so
2672        // promoteCorrelatedRefsToOuterReference throws
2673        // SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS. Lifting UPDATE-side
2674        // correlation is a follow-up slice (slice 14 SELECT analogue
2675        // extended to UPDATE).
2676        Map<Integer, List<ScalarInfo>> setRhsScalarInfo =
2677                extractScalarSubqueriesFromUpdateSetRhs(update, providerWithStar,
2678                        stmts, lineage, cteNameToStatementIndex,
2679                        subqueryAliasToIndex);
2680
2681        // 6) Per-assignment processing. Each TResultColumn carries an
2682        // assignment_t TExpression whose leftOperand is the SET LHS
2683        // (target column reference) and whose rightOperand is the value
2684        // expression. We collect:
2685        //   - target column spelling   → TargetRelation.columns[i]
2686        //   - synthetic output name    → outputs[i].name (verbatim LHS
2687        //                                   spelling, mirrors slice-78
2688        //                                   INSERT column-list contract)
2689        //   - RHS source column refs   → outputs[i].sources
2690        List<OutputColumn> outputs = new ArrayList<>();
2691        List<String> targetColumnNames = new ArrayList<>();
2692        for (int i = 0; i < sets.size(); i++) {
2693            TResultColumn rc = sets.getResultColumn(i);
2694            TExpression assignment = (rc == null) ? null : rc.getExpr();
2695            // Defensive: per TUpdateSqlStatement's javadoc each SET term
2696            // is an assignment_t. If the parser produced something else
2697            // (no AST shape observed in the tested corpora) we still
2698            // route through TUPLE_ASSIGNMENT_NOT_SUPPORTED so an
2699            // unexpected shape surfaces a stable diagnostic.
2700            if (assignment == null
2701                    || assignment.getExpressionType() != EExpressionType.assignment_t) {
2702                throw new SemanticIRBuildException(Diagnostic.error(
2703                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
2704                        "UPDATE SET assignment #" + (i + 1) + " is not a "
2705                                + "simple column-value assignment_t; slice 80 "
2706                                + "admits target_col = expr assignments only",
2707                        update));
2708            }
2709            TExpression lhs = assignment.getLeftOperand();
2710            TExpression rhs = assignment.getRightOperand();
2711            if (lhs == null || rhs == null) {
2712                throw new SemanticIRBuildException(Diagnostic.error(
2713                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
2714                        "UPDATE SET assignment #" + (i + 1)
2715                                + " is missing an operand",
2716                        update));
2717            }
2718            // Tuple LHS (Oracle) - SET (a, b) = (SELECT c1, c2 FROM ...)
2719            // surfaces as list_t. Reject before any subquery-on-RHS
2720            // walk so the diagnostic clearly identifies the tuple shape.
2721            if (lhs.getExpressionType() == EExpressionType.list_t) {
2722                throw new SemanticIRBuildException(Diagnostic.error(
2723                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
2724                        "UPDATE SET tuple assignment '(a, b) = ...' is not "
2725                                + "supported by SemanticIRBuilder.buildUpdate; "
2726                                + "slice 80 admits target_col = expr only",
2727                        update));
2728            }
2729            if (lhs.getExpressionType() != EExpressionType.simple_object_name_t) {
2730                throw new SemanticIRBuildException(Diagnostic.error(
2731                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
2732                        "UPDATE SET assignment #" + (i + 1) + " LHS is "
2733                                + "expressionType=" + lhs.getExpressionType()
2734                                + "; slice 80 admits simple column references only",
2735                        update));
2736            }
2737            TObjectName targetCol = lhs.getObjectOperand();
2738            if (targetCol == null) {
2739                throw new SemanticIRBuildException(Diagnostic.error(
2740                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
2741                        "UPDATE SET assignment #" + (i + 1) + " LHS has no "
2742                                + "TObjectName operand",
2743                        update));
2744            }
2745            String colSpelling = targetCol.toString();
2746
2747            // Slice 115 — top-level subquery_t SET RHS already extracted
2748            // in step 5.9 as a <scalar_subquery_<idx>> StatementGraph.
2749            // OutputColumn carries empty sources; slice-115/119 cross-stmt
2750            // edge below wires the consumer to the extracted body.
2751            if (rhs.getExpressionType() == EExpressionType.subquery_t) {
2752                targetColumnNames.add(colSpelling);
2753                outputs.add(new OutputColumn(colSpelling,
2754                        /*derived=*/ true, /*aggregate=*/ false,
2755                        Collections.<ColumnRef>emptyList()));
2756                continue;
2757            }
2758            // Slice 119 — mixed-expression scalar subquery path: subquery
2759            // nested inside a compound RHS (e.g. `SET col = (SELECT...) + 1`).
2760            // The scalar(s) were already extracted in step 5.9; collect only
2761            // the non-subquery column refs by skipping extracted subq nodes.
2762            if (containsAnySubqueryExpression(rhs)) {
2763                List<TExpression> subqRootsList =
2764                        collectNestedSubqueryExpressions(rhs);
2765                if (subqRootsList.isEmpty()) {
2766                    // P2-1 codex-review: containsAnySubqueryExpression
2767                    // returned true (via getSubQuery() != null) but no
2768                    // subquery_t nodes were found by acceptChildren
2769                    // traversal (e.g. EXISTS or non-scalar predicate
2770                    // subquery). Preserve the original reject so lineage
2771                    // is not silently dropped.
2772                    throw new SemanticIRBuildException(Diagnostic.error(
2773                            DiagnosticCode.UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED,
2774                            "UPDATE SET assignment #" + (i + 1) + " right-hand "
2775                                    + "side contains a non-scalar subquery "
2776                                    + "(slice 119 admits only scalar subquery_t "
2777                                    + "inside compound expressions)",
2778                            update));
2779                }
2780                Set<TExpression> subqRoots = Collections.newSetFromMap(
2781                        new IdentityHashMap<TExpression, Boolean>());
2782                subqRoots.addAll(subqRootsList);
2783                // P2-2 codex-review: window functions in the non-subquery
2784                // part of a compound RHS are still illegal. Use the
2785                // skipping variant so scalar body contents are not scanned
2786                // (window functions inside a scalar SELECT are legitimate).
2787                rejectWindowFunctionInScopeSkipping(rhs, "UPDATE SET RHS",
2788                        subqRoots);
2789                List<ColumnRef> sources = collectColumnRefsSkipping(
2790                        rhs, providerWithStar, subqRoots);
2791                targetColumnNames.add(colSpelling);
2792                outputs.add(new OutputColumn(colSpelling,
2793                        /*derived=*/ true, /*aggregate=*/ false, sources));
2794                continue;
2795            }
2796            // Window function on RHS — reuse the existing scope reject.
2797            rejectWindowFunctionInScope(rhs, "UPDATE SET RHS");
2798
2799            // Collect physical column refs from the RHS expression.
2800            List<ColumnRef> sources = collectColumnRefs(rhs, providerWithStar);
2801            boolean derived =
2802                    rhs.getExpressionType() != EExpressionType.simple_object_name_t;
2803            targetColumnNames.add(colSpelling);
2804            outputs.add(new OutputColumn(colSpelling,
2805                    derived, /*aggregate=*/ false, sources));
2806        }
2807
2808        // 7) WHERE refs — slice 110 lifts the slice-80 blanket subquery
2809        // reject by routing uncorrelated predicate-subquery wrappers
2810        // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
2811        // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction pipeline
2812        // refactored to take a PredicateClauseContext. Each extracted
2813        // wrapper lands as its own <predicate_subquery_<i>> StatementGraph
2814        // BEFORE the UPDATE statement (so updateIdx already accounts for
2815        // them via stmts.size() below). Remaining non-subquery refs flow
2816        // into filterColumnRefs via collectColumnRefsSkipping. SET-RHS
2817        // subqueries still reject (slice-110 scope excludes SET RHS).
2818        // Window functions in non-subquery subtrees still reject via
2819        // the existing rejectWindowFunctionInScopeSkipping helper.
2820        List<ColumnRef> filterRefs;
2821        TWhereClause where = update.getWhereClause();
2822        if (where == null || where.getCondition() == null) {
2823            filterRefs = Collections.<ColumnRef>emptyList();
2824        } else {
2825            Set<TExpression> extractedWhereRoots =
2826                    Collections.<TExpression>emptySet();
2827            if (containsAnySubquery(where)) {
2828                // Slice 110 — `providerWithStar` already carries
2829                // `withCteContext(cteNameToStatementIndex.keySet())`
2830                // (applied at the providerWithCte → providerWithStar
2831                // chain above) so the predicate body's inner SELECT's
2832                // `FROM cte` refs route through `RelationKind.CTE`.
2833                // Without that, emitLineageForStatement would lose the
2834                // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge to the CTE
2835                // body.
2836                extractedWhereRoots =
2837                        extractUncorrelatedPredicateSubqueriesFromClause(
2838                                where.getCondition(), providerWithStar,
2839                                stmts, lineage, cteNameToStatementIndex,
2840                                PredicateClauseContext.UPDATE_WHERE);
2841                rejectAnyRemainingSubqueriesFromClause(
2842                        where.getCondition(), extractedWhereRoots,
2843                        PredicateClauseContext.UPDATE_WHERE);
2844            }
2845            rejectWindowFunctionInScopeSkipping(where, "WHERE clause",
2846                    extractedWhereRoots);
2847            // Slice 83 — providerWithStar so WHERE refs against
2848            // extracted subquery aliases bind correctly. Slice 110 —
2849            // skip extracted predicate-subquery subtrees so inner refs
2850            // do not leak into outer filterColumnRefs (mirrors the
2851            // slice-23 JOIN-ON ref collector).
2852            filterRefs = collectColumnRefsSkipping(where, providerWithStar,
2853                    extractedWhereRoots);
2854        }
2855
2856        // (Step 8 of slice 82 was relocated to step 5.7 by slice 86 so
2857        // the slice-86 UsingScope built at step 5.8 can apply to the
2858        // SET RHS / WHERE collectors above. The walker logic itself is
2859        // unchanged from slice 82's contract — only its position moved.)
2860
2861        RelationBinding targetBinding = new RelationBinding(
2862                RelationKind.TABLE, targetQName);
2863        TargetRelation target = new TargetRelation(targetBinding, targetColumnNames);
2864
2865        // Slice 85 — build RETURNING / OUTPUT projection columns BEFORE
2866        // the StatementGraph so the new returningColumns slot can be
2867        // populated. updateIdx is computed first (deterministic — the
2868        // DML's position is stmts.size() at the moment of the
2869        // upcoming stmts.add(updateStmt)). LineageEdges are emitted
2870        // here via the shared helper (consumer ← producer).
2871        int updateIdx = stmts.size();
2872        // UPDATE target alias = effective alias from the target's
2873        // TTable (slice-82 / slice-83 use the same convention for
2874        // FROM-side reference identity). FROM-side relations is the
2875        // walked `relations[]` list already built above.
2876        String updateTargetAlias = effectiveAliasOf(targetTable);
2877        if (updateTargetAlias == null || updateTargetAlias.isEmpty()) {
2878            updateTargetAlias = targetQName;
2879        }
2880        // Slice 123 — hoist the slice-105 combined map (slice-83
2881        // subqueryAliasToIndex + slice-105 CTE-as-relation alias→cteIdx)
2882        // ABOVE buildReturningColumns so the slice-85 walker can promote a
2883        // RETURNING/OUTPUT ref to a SUBQUERY-kind FROM-side relation column
2884        // to a cross-stmt STATEMENT_OUTPUT → STATEMENT_OUTPUT edge. The same
2885        // map instance is reused below for emitUpdateSubquerySourceEdges
2886        // (slice 105), so the hoist is behaviour-preserving for that call.
2887        Map<String, Integer> combinedAliasToSubIdx =
2888                buildUpdateCombinedAliasToSubIdx(update,
2889                        subqueryAliasToIndex, cteNameToStatementIndex);
2890        List<OutputColumn> returningColumns = buildReturningColumns(
2891                update.getReturningClause(),
2892                update.getOutputClause(),
2893                "UPDATE",
2894                targetQName,
2895                updateTargetAlias,
2896                /*targetTable=*/ targetTable,
2897                relations,
2898                combinedAliasToSubIdx,
2899                providerWithStar,
2900                updateIdx,
2901                lineage,
2902                update);
2903
2904        StatementGraph updateStmt = new StatementGraph(
2905                /*name=*/ null,
2906                "UPDATE",
2907                relations,
2908                outputs,
2909                returningColumns,
2910                filterRefs,
2911                /*joinColumnRefs=*/ joinRefs,
2912                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2913                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2914                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2915                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2916                /*distinct=*/ false,
2917                /*setOperator=*/ null,
2918                /*rowLimit=*/ null,
2919                target);
2920
2921        // Slice 83 — updateIdx is dynamic: stmts already contains any
2922        // extracted FROM-subquery statements from step 5.5. The
2923        // slice-78/80 contract `target.col_i ← STATEMENT_OUTPUT(idx,
2924        // out_i)` is preserved by indexing the UPDATE's own statement
2925        // position rather than the slice-80 hardcoded 0.
2926        stmts.add(updateStmt);
2927
2928        // Slice 78/80 cross-stmt edges — one per SET assignment:
2929        //   target.col_i ← STATEMENT_OUTPUT(updateIdx, out_i)
2930        for (int i = 0; i < outputs.size(); i++) {
2931            String tgtName = targetColumnNames.get(i);
2932            String outName = outputs.get(i).getName();
2933            if (tgtName == null || tgtName.isEmpty()
2934                    || outName == null || outName.isEmpty()) {
2935                // Defensive — both should be the same verbatim spelling.
2936                continue;
2937            }
2938            lineage.add(new LineageEdge(
2939                    LineageRef.tableColumn(targetQName, tgtName),
2940                    LineageRef.statementOutput(updateIdx, outName)));
2941        }
2942
2943        // Slice 115 — for each SET assignment whose RHS was extracted as
2944        // a top-level scalar subquery in step 5.9, emit the cross-stmt
2945        // wire edge:
2946        //   STATEMENT_OUTPUT(updateIdx, outName) →
2947        //       STATEMENT_OUTPUT(scalarIdx, innerOutputName)
2948        // mirrors the SELECT-side slice-11 emission in
2949        // emitLineageForStatement (line ~7440). Runs AFTER the slice-78/
2950        // 80 target edge loop so the target edge is always emitted
2951        // first; the scalar-bound assignment's OutputColumn.sources is
2952        // empty by construction so the slice-83
2953        // emitUpdateSubquerySourceEdges call below is a no-op for these
2954        // outputs.
2955        // Slice 115/119 — one edge per extracted scalar per SET assignment:
2956        //   STATEMENT_OUTPUT(updateIdx, outName) → STATEMENT_OUTPUT(scalarIdx, innerOutputName)
2957        if (!setRhsScalarInfo.isEmpty()) {
2958            for (Map.Entry<Integer, List<ScalarInfo>> e : setRhsScalarInfo.entrySet()) {
2959                int ord = e.getKey();
2960                if (ord < 0 || ord >= outputs.size()) continue;
2961                String outName = outputs.get(ord).getName();
2962                if (outName == null || outName.isEmpty()) continue;
2963                for (ScalarInfo info : e.getValue()) {
2964                    lineage.add(new LineageEdge(
2965                            LineageRef.statementOutput(updateIdx, outName),
2966                            LineageRef.statementOutput(info.statementIndex,
2967                                    info.innerOutputName)));
2968                }
2969            }
2970        }
2971
2972        // Slice 83 — emit STATEMENT_OUTPUT(updateIdx, out_i) →
2973        // STATEMENT_OUTPUT(subIdx, col) edges for output sources that
2974        // bind to a SUBQUERY-kind relation in this UPDATE's
2975        // relations[]. Base-table FROM-side sources stay as
2976        // outputs[i].sources only — preserves the slice-82 contract
2977        // that joined UPDATE without subqueries emits exactly ONE
2978        // cross-stmt edge per SET assignment (the target edge above).
2979        // Slice 105 — combine the slice-83 subqueryAliasToIndex with
2980        // the slice-105 CTE-as-relation alias→cteIdx entries so a SET
2981        // RHS reference to a CTE column (which lives on a SUBQUERY-
2982        // kind relation per slice 105) still produces a cross-stmt
2983        // STATEMENT_OUTPUT edge to the CTE body. Without the merge the
2984        // visible OutputColumn.sources stays correct but lineage[]
2985        // silently drops the canonical edge (codex round-2 Q5).
2986        // Slice 123 — combinedAliasToSubIdx is now built above (hoisted
2987        // for buildReturningColumns); reuse it here.
2988        if (!combinedAliasToSubIdx.isEmpty()) {
2989            emitUpdateSubquerySourceEdges(updateStmt, updateIdx,
2990                    combinedAliasToSubIdx, lineage);
2991        }
2992
2993        return new SemanticProgram(stmts, lineage);
2994    }
2995
2996    /**
2997     * Slice 83 — emit STATEMENT_OUTPUT → STATEMENT_OUTPUT edges from
2998     * each UPDATE output to its subquery-bound source column. Walks
2999     * {@code outputs[i].sources} and, for any source whose
3000     * {@code relationAlias} matches a SUBQUERY-kind entry in the
3001     * statement's {@link RelationSource} list, emits an edge to the
3002     * corresponding extracted subquery's STATEMENT_OUTPUT position.
3003     *
3004     * <p>Why not call {@link #emitLineageForStatement}? The SELECT-path
3005     * helper emits edges for ALL output sources (TABLE-kind →
3006     * TABLE_COLUMN; CTE/SUBQUERY-kind → STATEMENT_OUTPUT). For UPDATE
3007     * the slice-78/80 contract is intentionally narrower: the only
3008     * cross-stmt edge per SET assignment is the target edge. Adding
3009     * STATEMENT_OUTPUT → TABLE_COLUMN edges for base-table FROM-side
3010     * sources would change the cross-stmt edge count contract that
3011     * slice-82 tests assert ({@code edges.size() == numAssignments}).
3012     * The slice-83 emitter therefore is SUBQUERY-only — base-table
3013     * FROM-side refs continue to surface via {@code outputs[i].sources}
3014     * but emit no extra LineageEdge.
3015     */
3016    private static void emitUpdateSubquerySourceEdges(
3017            StatementGraph updateStmt,
3018            int updateIdx,
3019            Map<String, Integer> subqueryAliasToIndex,
3020            List<LineageEdge> lineage) {
3021        // Codex slice-83 diff-review Q1 BLOCKING — both the map and
3022        // the lookup must use the same casing policy so SQL like
3023        // `... FROM (SELECT ...) sub WHERE ... SUB.x = …` (resolver-2
3024        // may surface either case in `src.getRelationAlias()` depending
3025        // on dialect and quoting) still finds the SUBQUERY-kind entry.
3026        // The slice-83 inScope map and `subqueryAliasToIndex` are both
3027        // keyed lowercase; do the same here. (SELECT-side
3028        // `emitLineageForStatement` uses case-sensitive equality —
3029        // pre-existing limitation; a separate refactor.)
3030        Map<String, RelationSource> aliasToRelation = new HashMap<>();
3031        for (RelationSource rs : updateStmt.getRelations()) {
3032            String key = rs.getAlias();
3033            // Skip null / empty aliases — empty-string would produce a
3034            // vacuous "" key and could spuriously match other empty-alias
3035            // relations (codex round-2 Q2 advisory).
3036            if (key == null || key.isEmpty()) continue;
3037            aliasToRelation.put(key.toLowerCase(Locale.ROOT), rs);
3038        }
3039        for (OutputColumn out : updateStmt.getOutputColumns()) {
3040            String outName = out.getName();
3041            if (outName == null || outName.isEmpty()) continue;
3042            for (ColumnRef src : out.getSources()) {
3043                String srcAlias = src.getRelationAlias();
3044                if (srcAlias == null || srcAlias.isEmpty()) continue;
3045                RelationSource rel = aliasToRelation.get(
3046                        srcAlias.toLowerCase(Locale.ROOT));
3047                if (rel == null) continue;
3048                if (rel.getBinding() == null
3049                        || rel.getBinding().getKind() != RelationKind.SUBQUERY) {
3050                    continue;
3051                }
3052                Integer subIdx = subqueryAliasToIndex.get(
3053                        rel.getAlias().toLowerCase(Locale.ROOT));
3054                if (subIdx == null) continue;
3055                lineage.add(new LineageEdge(
3056                        LineageRef.statementOutput(updateIdx, outName),
3057                        LineageRef.statementOutput(subIdx, src.getColumnName())));
3058            }
3059        }
3060    }
3061
3062    /**
3063     * Slice 115 — walk the UPDATE's SET clause and extract each
3064     * assignment whose RHS is exactly a top-level
3065     * {@link EExpressionType#subquery_t} as its own
3066     * {@code <scalar_subquery_<idx>>} {@link StatementGraph} appended to
3067     * {@code stmts} BEFORE the UPDATE. Mirrors the SELECT-side
3068     * {@link #extractScalarSubqueriesAsStatementsInternal} slice-11
3069     * pipeline but iterates SET assignments instead of result columns.
3070     * Returns {@code assignmentOrdinal → ScalarInfo} so {@link #buildUpdate}
3071     * can wire the cross-stmt edge for each extracted body.
3072     *
3073     * <p>Scope rejects (mirroring slice 11):
3074     * <ul>
3075     *   <li>Multi-column inner SELECT —
3076     *       {@link DiagnosticCode#SCALAR_SUBQUERY_COLUMN_COUNT}.</li>
3077     *   <li>Inner projection has no alias and no column name —
3078     *       {@link DiagnosticCode#SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED}.</li>
3079     *   <li>Subqueries in scalar body's WHERE / JOIN ON / GROUP BY —
3080     *       slice-11 {@link #rejectSubqueriesInScalarBodyClauses}.</li>
3081     *   <li>FROM-subqueries inside scalar body —
3082     *       {@code allowFromSubqueries=false} (slice-15 invariant).</li>
3083     *   <li>Nested scalar projections inside scalar body —
3084     *       {@code allowScalarProjectionSubqueries=false} (set-op-branch
3085     *       precedent; slice 115 initial scope).</li>
3086     *   <li>Window functions in scalar body —
3087     *       {@code allowWindowProjection=false} (slice-11 precedent).</li>
3088     *   <li>Correlated scalar subqueries (inner refs to outer aliases) —
3089     *       {@link #promoteCorrelatedRefsToOuterReference} called with
3090     *       {@link EnclosingScope#empty()} throws
3091     *       {@link DiagnosticCode#SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS}.
3092     *       Lifting UPDATE-side correlation is a follow-up slice
3093     *       (slice 14 SELECT analogue extended to UPDATE).</li>
3094     * </ul>
3095     *
3096     * <p>Snapshot/rollback wrapper around the loop body mirrors
3097     * {@link #extractScalarSubqueriesAsStatements} so a partial
3098     * extraction (e.g. second of two scalar SET RHS fails on shape
3099     * validation) truncates {@code stmts}/{@code lineage} back to the
3100     * pre-call boundary.
3101     *
3102     * <p>Assignments whose RHS is not a top-level {@code subquery_t} are
3103     * silently skipped here; they fall through to the per-assignment
3104     * loop's existing slice-80 / slice-115 mixed-expression reject path.
3105     */
3106    private static Map<Integer, List<ScalarInfo>> extractScalarSubqueriesFromUpdateSetRhs(
3107            TUpdateSqlStatement update,
3108            NameBindingProvider provider,
3109            List<StatementGraph> stmts,
3110            List<LineageEdge> lineage,
3111            Map<String, Integer> cteNameToStatementIndex,
3112            Map<String, Integer> subqueryAliasToIndex) {
3113        TResultColumnList sets = update.getResultColumnList();
3114        if (sets == null || sets.size() == 0) {
3115            return Collections.<Integer, List<ScalarInfo>>emptyMap();
3116        }
3117        // Fast pre-scan: any SET RHS that contains a subquery (top-level
3118        // subquery_t or nested inside a compound expression)? Avoids the
3119        // snapshot/rollback wrapper overhead when none are present.
3120        // Slice 115 handled top-level subquery_t only; slice 119 extends
3121        // to mixed-expression RHS (e.g. `SET col = (SELECT...) + 1`).
3122        // Tuple-LHS assignments (Oracle SET (a, b) = ...) are
3123        // intentionally skipped so the per-assignment loop's
3124        // UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED reject (slice 80
3125        // contract) wins.
3126        boolean anySubquery = false;
3127        for (int i = 0; i < sets.size(); i++) {
3128            TResultColumn rc = sets.getResultColumn(i);
3129            if (rc == null || rc.getExpr() == null) continue;
3130            TExpression assignment = rc.getExpr();
3131            if (assignment.getExpressionType() != EExpressionType.assignment_t) {
3132                continue;
3133            }
3134            TExpression lhs = assignment.getLeftOperand();
3135            if (lhs == null
3136                    || lhs.getExpressionType() == EExpressionType.list_t) {
3137                continue;
3138            }
3139            TExpression rhs = assignment.getRightOperand();
3140            if (rhs != null && containsAnySubqueryExpression(rhs)) {
3141                anySubquery = true;
3142                break;
3143            }
3144        }
3145        if (!anySubquery) {
3146            return Collections.<Integer, List<ScalarInfo>>emptyMap();
3147        }
3148        int stmtsSnapshot = stmts.size();
3149        int lineageSnapshot = lineage.size();
3150        try {
3151            return extractScalarSubqueriesFromUpdateSetRhsInternal(
3152                    update, provider, stmts, lineage,
3153                    cteNameToStatementIndex, subqueryAliasToIndex, sets);
3154        } catch (RuntimeException ex) {
3155            while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1);
3156            while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1);
3157            throw ex;
3158        }
3159    }
3160
3161    /**
3162     * Internal body of {@link #extractScalarSubqueriesFromUpdateSetRhs};
3163     * wrapped with snapshot/rollback by the public entry point. Do not
3164     * call directly from non-wrapper sites.
3165     */
3166    private static Map<Integer, List<ScalarInfo>> extractScalarSubqueriesFromUpdateSetRhsInternal(
3167            TUpdateSqlStatement update,
3168            NameBindingProvider provider,
3169            List<StatementGraph> stmts,
3170            List<LineageEdge> lineage,
3171            Map<String, Integer> cteNameToStatementIndex,
3172            Map<String, Integer> subqueryAliasToIndex,
3173            TResultColumnList sets) {
3174        Map<Integer, List<ScalarInfo>> ordinalToInfo = new HashMap<>();
3175        for (int i = 0; i < sets.size(); i++) {
3176            TResultColumn rc = sets.getResultColumn(i);
3177            if (rc == null || rc.getExpr() == null) continue;
3178            TExpression assignment = rc.getExpr();
3179            if (assignment.getExpressionType() != EExpressionType.assignment_t) {
3180                continue;
3181            }
3182            TExpression lhs = assignment.getLeftOperand();
3183            // Skip tuple-LHS assignments — the per-assignment loop's
3184            // slice-80 UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED reject
3185            // should win for these (e.g. Oracle `SET (a, b) = (SELECT
3186            // c1, c2 FROM ...)`). Without this skip, the inner SELECT's
3187            // multi-column projection would surface as
3188            // SCALAR_SUBQUERY_COLUMN_COUNT here instead.
3189            if (lhs == null
3190                    || lhs.getExpressionType() == EExpressionType.list_t) {
3191                continue;
3192            }
3193            TExpression rhs = assignment.getRightOperand();
3194            if (rhs == null || !containsAnySubqueryExpression(rhs)) {
3195                continue; // no subquery in this RHS — handled by per-assignment loop
3196            }
3197            // "outer alias" used in diagnostic messages — the SET LHS
3198            // column spelling. Mirrors the slice-11 `outerAlias` role.
3199            String outerAlias = (lhs.getExpressionType() == EExpressionType.simple_object_name_t
3200                    && lhs.getObjectOperand() != null)
3201                    ? lhs.getObjectOperand().toString()
3202                    : ("SET assignment #" + (i + 1));
3203
3204            // Determine which subquery TExpression nodes to extract.
3205            // Slice 115 path: RHS is exactly a top-level subquery_t →
3206            //   single-element list.
3207            // Slice 119 path: RHS is a compound expression (arithmetic,
3208            //   CASE, function) containing one or more subquery_t nodes
3209            //   at any depth → list in traversal order.
3210            List<TExpression> subqExprs;
3211            if (rhs.getExpressionType() == EExpressionType.subquery_t) {
3212                subqExprs = Collections.singletonList(rhs);
3213            } else {
3214                subqExprs = collectNestedSubqueryExpressions(rhs);
3215            }
3216            if (subqExprs.isEmpty()) continue; // defensive (containsAnySubqueryExpression true but none found)
3217
3218            // Build the UPDATE-side enclosing scope once per assignment
3219            // (used by each per-scalar correlation promotion below).
3220            EnclosingScope innerEnclosing = buildUpdateEnclosingScope(update,
3221                    cteNameToStatementIndex, subqueryAliasToIndex,
3222                    /*parent=*/ null);
3223
3224            List<ScalarInfo> infos = new ArrayList<>();
3225            for (TExpression subqExpr : subqExprs) {
3226                TSelectSqlStatement inner = subqExpr.getSubQuery();
3227                if (inner == null) {
3228                    throw new SemanticIRBuildException(
3229                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_NO_INNER_SELECT,
3230                            "scalar subquery on UPDATE SET RHS for '" + outerAlias
3231                                    + "' has no inner SELECT", rc));
3232                }
3233                // Pre-recursion validation (matches slice 11 ordering):
3234                // inspect inner column count and naming before recursive
3235                // build so the diagnostic is scalar-specific.
3236                TResultColumnList innerRcl = inner.getResultColumnList();
3237                if (innerRcl == null || innerRcl.size() == 0) {
3238                    throw new SemanticIRBuildException(
3239                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT,
3240                            "scalar subquery on UPDATE SET RHS for '" + outerAlias
3241                                    + "' must project exactly one column, got 0", rc));
3242                }
3243                if (innerRcl.size() != 1) {
3244                    throw new SemanticIRBuildException(
3245                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT,
3246                            "scalar subquery on UPDATE SET RHS for '" + outerAlias
3247                                    + "' must project exactly one column, got "
3248                                    + innerRcl.size(), rc));
3249                }
3250                TResultColumn innerCol = innerRcl.getResultColumn(0);
3251                String innerAlias = innerCol.getColumnAlias();
3252                String innerColName = innerCol.getColumnNameOnly();
3253                boolean innerHasName =
3254                        (innerAlias != null && !innerAlias.isEmpty())
3255                                || (innerColName != null && !innerColName.isEmpty());
3256                if (!innerHasName && !isConstantExpression(innerCol.getExpr())) {
3257                    throw new SemanticIRBuildException(
3258                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED,
3259                            "scalar subquery on UPDATE SET RHS for '" + outerAlias
3260                                    + "' inner projection has no alias and no column "
3261                                    + "name; add an explicit alias inside the subquery",
3262                            rc));
3263                }
3264                // Predicate-leak guard: scalar body's WHERE / JOIN ON /
3265                // GROUP BY must not contain subqueries. Slice 121: the
3266                // UPDATE SET-RHS scalar path keeps the WHERE reject
3267                // (allowWherePredicateSubqueries=false) — only the
3268                // projection scalar path lifts WHERE predicate subqueries.
3269                rejectSubqueriesInScalarBodyClauses(inner, outerAlias,
3270                        /*allowWherePredicateSubqueries=*/ false);
3271
3272                // Slice 117 / 119 — decorate provider with the inner
3273                // SELECT's local FROM aliases for tolerant outer-binding.
3274                Set<String> innerLocalAliases = precomputeInnerLocalAliases(inner);
3275                NameBindingProvider tolerantProvider = innerLocalAliases.isEmpty()
3276                        ? provider
3277                        : provider.withTolerantOuterBinding(innerLocalAliases);
3278
3279                String scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">";
3280                StatementGraph innerStmt = buildSelectStatement(inner, tolerantProvider,
3281                        scalarName,
3282                        /*hasOuterCteListAlreadyProcessed=*/ false,
3283                        /*allowFromSubqueries=*/ false,
3284                        /*allowScalarProjectionSubqueries=*/ false,
3285                        /*allowWindowProjection=*/ false);
3286                innerStmt = promoteCorrelatedRefsToOuterReference(
3287                        innerStmt, outerAlias, innerEnclosing);
3288                int idx = stmts.size();
3289                stmts.add(innerStmt);
3290                String innerOutName = effectiveOutputName(innerCol);
3291                infos.add(new ScalarInfo(idx, innerOutName));
3292                emitLineageForStatement(innerStmt, idx, lineage,
3293                        cteNameToStatementIndex,
3294                        innerEnclosing.flattenSubqueryAliasToIndex(),
3295                        Collections.<Integer, ScalarInfo>emptyMap());
3296            }
3297            ordinalToInfo.put(i, infos);
3298        }
3299        return ordinalToInfo;
3300    }
3301
3302    /**
3303     * Slice 82 — process one FROM-side source table for joined
3304     * {@link #buildUpdate}. Applies the slice-82 reject contract for
3305     * non-table FROM sources, then appends a TABLE-kind
3306     * {@link RelationSource} unless the table is the target
3307     * (reference-identity filter — clean IR semantics: relations[]
3308     * models read-side sources only).
3309     */
3310    private static void buildUpdateRelation(TTable t, TTable targetTable,
3311                                            List<RelationSource> relations,
3312                                            TUpdateSqlStatement update,
3313                                            Map<String, Integer> cteNameToStatementIndex) {
3314        if (t == null) {
3315            return; // defensive — parser should never produce a null table
3316        }
3317        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
3318            // Slice 83 — admit FROM-side subqueries. The inner SELECT
3319            // has already been extracted as its own StatementGraph by
3320            // {@link #extractUpdateFromSubqueries} (step 5.5 of
3321            // buildUpdate). Here we publish the SUBQUERY-kind
3322            // {@link RelationSource} so {@code outputs[i].sources}
3323            // resolved via the inScope-enhanced provider can route to
3324            // it. Alias and qualifiedName both use
3325            // {@code effectiveAliasOf(t)} — matching slice-14 / slice-58
3326            // SUBQUERY-kind convention used by SELECT.
3327            //
3328            // {@code UPDATE_FROM_SUBQUERY_NOT_SUPPORTED} stays declared
3329            // but unreached (slice-71/72 retain-for-documentation
3330            // precedent — keeps the public DiagnosticCode enum stable
3331            // for consumers that route by code).
3332            String subAlias = effectiveAliasOf(t);
3333            if (subAlias != null && !subAlias.isEmpty()) {
3334                relations.add(new RelationSource(subAlias,
3335                        new RelationBinding(RelationKind.SUBQUERY, subAlias)));
3336            }
3337            return;
3338        }
3339        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
3340            // Defensive: TTable wrapping a TJoin. Not reached by any
3341            // observed parser path on the supported dialects (slice-82
3342            // probe set), but gets its own DiagnosticCode so consumers
3343            // can route this distinct shape without parsing message
3344            // text — per slice-80's message-text-discrimination
3345            // contract (codex round-1 Q4 BLOCKING).
3346            throw new SemanticIRBuildException(Diagnostic.error(
3347                    DiagnosticCode.UPDATE_FROM_NESTED_JOIN_NOT_SUPPORTED,
3348                    "UPDATE FROM source is a nested join wrapper; "
3349                            + "slice 82 admits simple table FROM sources only",
3350                    update));
3351        }
3352        // Reference-identity filter: target's own TTable instance is
3353        // excluded from relations[]. In MSSQL `UPDATE T2 ... FROM Table2 T2 …`,
3354        // tables[0] / joins[0].getTable() IS the same instance as
3355        // update.getTargetTable(); excluding it keeps the IR clean
3356        // (relations[] models reads, target models writes). The
3357        // catalog-miss WARN walker's target-first ordering handles the
3358        // cross-instance-same-name MSSQL self-join edge case where two
3359        // distinct TTable instances share the same qualified name.
3360        if (t == targetTable) {
3361            return;
3362        }
3363        TObjectName tName = t.getTableName();
3364        if (tName == null) {
3365            return; // defensive
3366        }
3367        // Slice 105 — FROM-side CTE detection. When the FROM-side table
3368        // is an objectname-typed reference whose bare name matches a
3369        // declared CTE in this UPDATE's outer WITH clause, emit a
3370        // SUBQUERY-kind RelationSource pointing at the CTE statement
3371        // (mirrors MERGE USING-as-CTE in slice 101). The slice-77
3372        // catalog-miss WARN walker filters to RelationKind.TABLE so
3373        // CTE-bound relations are naturally skipped, even when the
3374        // catalog also declares the same name (codex round-2 Q4
3375        // confirmed YES). The cross-stmt lineage edge from
3376        // STATEMENT_OUTPUT(updateIdx,col) → STATEMENT_OUTPUT(cteIdx,col)
3377        // is emitted by emitUpdateSubquerySourceEdges using the
3378        // combined alias→subIdx map.
3379        if (cteNameToStatementIndex != null
3380                && !cteNameToStatementIndex.isEmpty()) {
3381            String bareName = tName.toString();
3382            if (bareName != null && !bareName.isEmpty()) {
3383                String bareNameLower = bareName.toLowerCase(Locale.ROOT);
3384                if (cteNameToStatementIndex.containsKey(bareNameLower)) {
3385                    String cteAlias = effectiveAliasOf(t);
3386                    if (cteAlias == null || cteAlias.isEmpty()) {
3387                        cteAlias = bareName;
3388                    }
3389                    relations.add(new RelationSource(cteAlias,
3390                            new RelationBinding(RelationKind.SUBQUERY, cteAlias)));
3391                    return;
3392                }
3393            }
3394        }
3395        // effectiveAliasOf returns the SQL-written alias if present,
3396        // else the table name. RelationSource requires a non-empty
3397        // alias; this matches the slice-58/59 buildRelation contract.
3398        relations.add(new RelationSource(effectiveAliasOf(t),
3399                new RelationBinding(RelationKind.TABLE, tName.toString())));
3400    }
3401
3402    /**
3403     * Slice 82 (extended by slice 86) — process one {@link TJoinItem}
3404     * for joined {@link #buildUpdate}. Routes USING / NATURAL JoinItems
3405     * through the SELECT-side slice-64/65/66 shared helpers
3406     * ({@link #populateUsingJoinRefs} / {@link #emitMergedJoinRefs} /
3407     * {@link #naturalSharedKeys}) so the UPDATE join walker emits the
3408     * same {@code joinColumnRefs[]} shape as a SELECT body. ON / CROSS
3409     * JoinItems retain the slice-82 reject contract (subquery in ON,
3410     * window in ON) and ref-collection path.
3411     *
3412     * <p>Slice 86 signature extension: the join context
3413     * ({@code topJoin}, {@code items}, {@code itemIndex}) and the
3414     * per-top-level-TJoin {@link LeftOutputState} are required by the
3415     * shared helpers — the prior-relations chain for emit-refs and the
3416     * accumulated left row type for NATURAL inference.
3417     *
3418     * <p>USING / NATURAL shape conflicts (USING+ON, NATURAL+USING,
3419     * NATURAL+ON) reuse the existing slice-64/66 codes
3420     * ({@link DiagnosticCode#JOIN_WITH_BOTH_ON_AND_USING},
3421     * {@link DiagnosticCode#NATURAL_WITH_USING},
3422     * {@link DiagnosticCode#NATURAL_WITH_ON}) rather than introducing
3423     * UPDATE-specific codes — matching slice 86's "reuse SELECT-side
3424     * machinery verbatim" architecture.
3425     *
3426     * <p>Slice 82's lifted reject codes
3427     * ({@link DiagnosticCode#UPDATE_FROM_JOIN_USING_NOT_SUPPORTED} and
3428     * {@link DiagnosticCode#UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED})
3429     * stay declared-but-unreached for API stability — slice 71/72/82
3430     * retain-for-documentation precedent.
3431     */
3432    private static void buildUpdateJoinItem(TJoin topJoin,
3433                                            TJoinItemList items,
3434                                            int itemIndex,
3435                                            TTable targetTable,
3436                                            NameBindingProvider provider,
3437                                            List<RelationSource> relations,
3438                                            java.util.LinkedHashSet<ColumnRef> joinRefs,
3439                                            LeftOutputState leftState,
3440                                            TUpdateSqlStatement update,
3441                                            Map<String, Integer> cteNameToStatementIndex) {
3442        if (items == null) return;
3443        TJoinItem item = items.getJoinItem(itemIndex);
3444        if (item == null) return;
3445
3446        TObjectNameList usingCols = item.getUsingColumns();
3447        boolean hasUsing = usingCols != null && usingCols.size() > 0;
3448        boolean isNatural = isNaturalJoinType(item.getJoinType());
3449        boolean hasOn = item.getOnCondition() != null;
3450
3451        // Slice 86 — USING/NATURAL admit paths. Shape conflicts use the
3452        // slice-64/66 SELECT-side codes verbatim; the UPDATE-specific
3453        // lifted codes (UPDATE_FROM_JOIN_USING_NOT_SUPPORTED /
3454        // UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED) are no longer thrown
3455        // (declared-but-unreached for API stability).
3456        if (isNatural && hasUsing) {
3457            throw new SemanticIRBuildException(Diagnostic.error(
3458                    DiagnosticCode.NATURAL_WITH_USING,
3459                    "NATURAL JOIN must not carry a USING clause; choose "
3460                            + "either NATURAL or USING, not both", item));
3461        }
3462        if (isNatural && hasOn) {
3463            throw new SemanticIRBuildException(Diagnostic.error(
3464                    DiagnosticCode.NATURAL_WITH_ON,
3465                    "NATURAL JOIN must not carry an ON condition; rewrite "
3466                            + "as JOIN ... ON, or drop the NATURAL keyword", item));
3467        }
3468        if (hasUsing && hasOn) {
3469            throw new SemanticIRBuildException(Diagnostic.error(
3470                    DiagnosticCode.JOIN_WITH_BOTH_ON_AND_USING,
3471                    "JOIN cannot carry both ON and USING; choose one", item));
3472        }
3473
3474        if (hasUsing) {
3475            // Right-side table first: applies slice-82 source-shape
3476            // rejects + identity filter exactly as the ON path.
3477            buildUpdateRelation(item.getTable(), targetTable, relations, update,
3478                    cteNameToStatementIndex);
3479            // Slice 64 emit-refs: left-then-right per key, walking
3480            // priorRelations = topJoin.getTable() + items[0..itemIndex-1].
3481            List<ColumnRef> usingRefs = new ArrayList<>();
3482            populateUsingJoinRefs(topJoin, items, itemIndex, item.getTable(),
3483                    usingCols, provider, usingRefs);
3484            joinRefs.addAll(usingRefs);
3485            // Slice 66 LeftOutputState update: merge right's columns
3486            // into accumulated state so a subsequent NATURAL JoinItem
3487            // sees the row type (matches SELECT-side
3488            // {@code buildRelations}).
3489            List<String> usingKeyNames = new ArrayList<>(usingCols.size());
3490            for (int k = 0; k < usingCols.size(); k++) {
3491                TObjectName key = usingCols.getObjectName(k);
3492                if (key == null) continue;
3493                String keyName = key.getColumnNameOnly();
3494                if (keyName != null && !keyName.isEmpty()) {
3495                    usingKeyNames.add(keyName);
3496                }
3497            }
3498            mergeRightIntoLeftOutput(leftState, item.getTable(), provider,
3499                    usingKeyNames);
3500            return;
3501        }
3502
3503        if (isNatural) {
3504            // Right-side table first; identity filter excludes target.
3505            buildUpdateRelation(item.getTable(), targetTable, relations, update,
3506                    cteNameToStatementIndex);
3507            // Slice 66 catalog-required NATURAL inference. When either
3508            // side lacks resolvable column metadata the shared-column list
3509            // cannot be computed; DEGRADE (mirrors the SELECT-side
3510            // buildRelations path) — skip the merged join refs, record a
3511            // non-fatal NATURAL_CATALOG_REQUIRED warning, and append the
3512            // right's columns to the running state for consistency.
3513            NaturalKeyResult r = naturalSharedKeys(leftState, item.getTable(), provider);
3514            if (r.kind != NaturalKeyResult.Kind.SUCCESS) {
3515                recordNaturalDegradeWarning(r, item);
3516                appendRightToLeftOutput(leftState, item.getTable(), provider);
3517                return;
3518            }
3519            List<ColumnRef> naturalRefs = new ArrayList<>();
3520            emitMergedJoinRefs(JoinKind.NATURAL, r.keys, topJoin, items,
3521                    itemIndex, item.getTable(), provider, naturalRefs);
3522            joinRefs.addAll(naturalRefs);
3523            // Update LeftOutputState with the right's columns (merging
3524            // shared keys into existing slots, appending non-shared
3525            // columns as new entries).
3526            mergeRightIntoLeftOutput(leftState, item.getTable(), provider, r.keys);
3527            return;
3528        }
3529
3530        // ON / CROSS branch — slice-82 contract preserved.
3531        buildUpdateRelation(item.getTable(), targetTable, relations, update,
3532                    cteNameToStatementIndex);
3533        // Slice 86 — append right to LeftOutputState so subsequent
3534        // NATURAL JoinItems in the same top-level TJoin observe the
3535        // accumulated row type. CROSS / ON contribute non-merged
3536        // columns to state (matches SELECT-side appendRightToLeftOutput).
3537        appendRightToLeftOutput(leftState, item.getTable(), provider);
3538        TExpression onCond = item.getOnCondition();
3539        if (onCond == null) return; // CROSS JOIN: no ON.
3540        if (containsAnySubqueryExpression(onCond)) {
3541            throw new SemanticIRBuildException(Diagnostic.error(
3542                    DiagnosticCode.UPDATE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED,
3543                    "UPDATE FROM JOIN ON condition contains a subquery; "
3544                            + "slice 82 admits scalar predicates only",
3545                    item));
3546        }
3547        rejectWindowFunctionInScope(onCond, "UPDATE FROM JOIN ON");
3548        joinRefs.addAll(collectColumnRefs(onCond, provider));
3549    }
3550
3551    /**
3552     * Slice 83 — extract every FROM-side subquery in
3553     * {@code update.getJoins()} as its own {@link StatementGraph}
3554     * appended to {@code stmts} before the UPDATE itself. Walks both
3555     * the driver TTable of each TJoin AND each JoinItem's right table.
3556     * Returns an alias → stmts-index map so the consuming UPDATE can
3557     * (a) build its in-scope column map via
3558     * {@link #buildUpdateInScopeMap}, and (b) emit
3559     * STATEMENT_OUTPUT → STATEMENT_OUTPUT edges via
3560     * {@link #emitUpdateSubquerySourceEdges}.
3561     *
3562     * <p>Reuses the SELECT-side {@link #processDirectSubqueryTable}
3563     * verbatim, passing empty CTE maps because slice 80 already
3564     * rejects top-level WITH on UPDATE
3565     * ({@link DiagnosticCode#UPDATE_CTE_NOT_SUPPORTED}). The inner
3566     * SELECT's own FROM-subqueries are handled recursively by the
3567     * helper. Inner predicate subqueries in WHERE / JOIN ON /
3568     * GROUP BY are caught by the slice-17 leak guard
3569     * ({@link #rejectSubqueriesInFromSubqueryBodyClauses}). Inner
3570     * top-level WITH is rejected by
3571     * {@code buildSelectStatement(hasOuterCteListAlreadyProcessed=false)}.
3572     * Inner scalar projection subqueries are rejected by
3573     * {@code buildSelectStatement(allowScalarProjectionSubqueries=false)}.
3574     *
3575     * <p>No mutation-guard wrapper here: buildUpdate owns fresh local
3576     * lists and exceptions propagate to the caller (codex round-1 Q5
3577     * NICE).
3578     */
3579    private static Map<String, Integer> extractUpdateFromSubqueries(
3580            TUpdateSqlStatement update,
3581            NameBindingProvider provider,
3582            List<StatementGraph> stmts,
3583            List<LineageEdge> lineage,
3584            Map<String, Integer> cteNameToStatementIndex,
3585            Map<String, List<String>> ctePublishedColumns) {
3586        Map<String, Integer> aliasToIndex = new HashMap<>();
3587        TJoinList joins = update.getJoins();
3588        if (joins == null) return aliasToIndex;
3589        // Slice 105 — forward the outer-WITH CTE maps so a nested SELECT
3590        // inside an extracted FROM-subquery body can resolve outer-WITH
3591        // CTE references. Resolver2 wires CTEScope already; the maps are
3592        // forwarded for parity with the SELECT / MERGE call sites.
3593        Map<String, Integer> cteMap = cteNameToStatementIndex == null
3594                ? Collections.<String, Integer>emptyMap()
3595                : cteNameToStatementIndex;
3596        Map<String, List<String>> ctePublished = ctePublishedColumns == null
3597                ? Collections.<String, List<String>>emptyMap()
3598                : ctePublishedColumns;
3599        for (TJoin join : joins) {
3600            // Driver table — may be a subquery (PG / Snowflake / BQ /
3601            // Redshift `UPDATE t SET … FROM (SELECT …) sub` shape).
3602            processDirectSubqueryTable(join.getTable(), provider,
3603                    stmts, lineage, cteMap, ctePublished, aliasToIndex);
3604            TJoinItemList items = join.getJoinItems();
3605            if (items == null) continue;
3606            for (int i = 0; i < items.size(); i++) {
3607                TJoinItem item = items.getJoinItem(i);
3608                if (item == null) continue;
3609                // Right-side table of a JoinItem — may be a subquery
3610                // (MSSQL / PG `UPDATE t SET … FROM x JOIN (SELECT …)
3611                // sub ON …` shape).
3612                processDirectSubqueryTable(item.getTable(), provider,
3613                        stmts, lineage, cteMap, ctePublished, aliasToIndex);
3614            }
3615        }
3616        return aliasToIndex;
3617    }
3618
3619    /**
3620     * Slice 83 — build an effective-alias-keyed in-scope map publishing
3621     * each extracted FROM-subquery's output column names. The consuming
3622     * UPDATE wraps its provider via
3623     * {@code provider.withInScopeRelationColumns(map)} so {@code sub.x}
3624     * resolves to the subquery's published column rather than failing
3625     * resolution against the catalog.
3626     *
3627     * <p>Base-table FROM-side relations do not need an entry: their
3628     * column resolution stays on the Resolver2 catalog path. Slice 60's
3629     * SELECT-side {@link #buildEffectiveAliasInScopeMap} also skips
3630     * base-table relations.
3631     *
3632     * <p>Slice 105 — when an outer WITH clause declares a CTE and a
3633     * FROM-side relation references that CTE by its bare name, publish
3634     * the CTE's column names against the FROM-side effective alias so
3635     * SET RHS / WHERE / ON refs against the CTE alias bind correctly.
3636     */
3637    private static Map<String, List<String>> buildUpdateInScopeMap(
3638            TUpdateSqlStatement update,
3639            Map<String, Integer> subqueryAliasToIndex,
3640            List<StatementGraph> stmts,
3641            Map<String, Integer> cteNameToStatementIndex,
3642            Map<String, List<String>> ctePublishedColumns) {
3643        Map<String, List<String>> result = new HashMap<>();
3644        boolean haveSubq = subqueryAliasToIndex != null
3645                && !subqueryAliasToIndex.isEmpty();
3646        boolean haveCte = cteNameToStatementIndex != null
3647                && !cteNameToStatementIndex.isEmpty();
3648        if (!haveSubq && !haveCte) {
3649            return result;
3650        }
3651        TJoinList joins = update.getJoins();
3652        if (joins == null) return result;
3653        for (TJoin join : joins) {
3654            addUpdateRelationToInScopeMap(join.getTable(),
3655                    subqueryAliasToIndex, stmts, result,
3656                    cteNameToStatementIndex, ctePublishedColumns);
3657            TJoinItemList items = join.getJoinItems();
3658            if (items == null) continue;
3659            for (int i = 0; i < items.size(); i++) {
3660                TJoinItem item = items.getJoinItem(i);
3661                if (item == null) continue;
3662                addUpdateRelationToInScopeMap(item.getTable(),
3663                        subqueryAliasToIndex, stmts, result,
3664                        cteNameToStatementIndex, ctePublishedColumns);
3665            }
3666        }
3667        return result;
3668    }
3669
3670    private static void addUpdateRelationToInScopeMap(TTable t,
3671            Map<String, Integer> subqueryAliasToIndex,
3672            List<StatementGraph> stmts,
3673            Map<String, List<String>> result,
3674            Map<String, Integer> cteNameToStatementIndex,
3675            Map<String, List<String>> ctePublishedColumns) {
3676        if (t == null) return;
3677        // Slice 105 — CTE-as-FROM-relation in-scope publication. When
3678        // the FROM-side table is an objectname-typed reference whose
3679        // bare name matches a declared outer CTE, publish the CTE's
3680        // own column names against the FROM-side effective alias so
3681        // SET RHS / WHERE refs against the CTE alias bind correctly.
3682        if (cteNameToStatementIndex != null
3683                && !cteNameToStatementIndex.isEmpty()
3684                && ctePublishedColumns != null
3685                && t.getTableType()
3686                        == gudusoft.gsqlparser.ETableSource.objectname) {
3687            TObjectName tName = t.getTableName();
3688            if (tName != null) {
3689                String bare = tName.toString();
3690                if (bare != null && !bare.isEmpty()) {
3691                    String bareLower = bare.toLowerCase(Locale.ROOT);
3692                    if (cteNameToStatementIndex.containsKey(bareLower)) {
3693                        String aliasKey = effectiveAliasLowerCaseOrNull(t);
3694                        if (aliasKey == null) aliasKey = bareLower;
3695                        List<String> cols = ctePublishedColumns.get(bareLower);
3696                        if (cols != null) {
3697                            result.put(aliasKey, cols);
3698                        }
3699                        return;
3700                    }
3701                }
3702            }
3703        }
3704        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) {
3705            return;
3706        }
3707        if (subqueryAliasToIndex == null) {
3708            return;
3709        }
3710        String key = effectiveAliasLowerCaseOrNull(t);
3711        if (key == null) return;
3712        Integer idx = subqueryAliasToIndex.get(key);
3713        if (idx == null) return;
3714        result.put(key, outputColumnNames(stmts.get(idx)));
3715    }
3716
3717    /**
3718     * Slice 81 / slice 84 — admit single-target and joined
3719     * {@code DELETE} statements and produce a {@code "DELETE"}-kind
3720     * {@link StatementGraph} (§8.1.4 row D11 follow-up via slice 84's
3721     * joined-DELETE candidate (a)).
3722     *
3723     * <p>Structurally mirrors slice-80 + slice-82 + slice-83
3724     * {@link #buildUpdate} but with no SET clause and an empty
3725     * {@code outputColumns} list — DELETE has no projection of its
3726     * own (RETURNING / OUTPUT projections are deferred to a later
3727     * slice). The target relation is exposed via the slice-78
3728     * {@link TargetRelation} slot; its {@code columns} list is
3729     * intentionally empty because DELETE removes whole rows rather
3730     * than writing specific columns.
3731     *
3732     * <p>WHERE-side reads still surface on
3733     * {@link StatementGraph#getFilterColumnRefs()} so downstream
3734     * governance can audit "what predicates does this DELETE depend
3735     * on". Cross-statement {@link LineageEdge}s are NOT emitted (the
3736     * slice-78 / slice-80 {@code target.col_i ← STATEMENT_OUTPUT(…)}
3737     * contract has no DELETE analogue: there is no source
3738     * projection).
3739     *
3740     * <p>Slice 84 admit scope (lifts slice-81's blanket joined-DELETE
3741     * reject for the common PG / MSSQL FROM-side shapes; mirrors
3742     * slice 82 + slice 83 onto DELETE):
3743     * <ul>
3744     *   <li>PG / Snowflake / BQ / Redshift {@code DELETE FROM t USING
3745     *       source_list [WHERE]} — {@code source_list} = simple table,
3746     *       comma-separated tables, or chain of explicit JOIN ... ON
3747     *       (driver is taken from {@code referenceJoins}).</li>
3748     *   <li>MSSQL {@code DELETE FROM t FROM driver_table [JOIN other
3749     *       ON ...] [WHERE]} — the target may itself appear in the
3750     *       FROM-FROM clause as a different TTable instance.</li>
3751     *   <li>MSSQL {@code DELETE alias FROM t alias INNER JOIN ... ON …}
3752     *       — the alias-form DELETE where target is matched by alias.</li>
3753     *   <li>CROSS JOIN inside USING — no ON; semantically equivalent
3754     *       to comma-FROM.</li>
3755     *   <li>{@code DELETE FROM t USING (SELECT …) s [WHERE]} —
3756     *       FROM-subquery as a USING source; mirrors slice-83 UPDATE
3757     *       FROM-subquery extraction.</li>
3758     * </ul>
3759     *
3760     * <p>Slice 84 reject scope (preserves slice-81 reject coverage
3761     * for shapes that still need a refinement slice):
3762     * <ul>
3763     *   <li>{@link DiagnosticCode#DELETE_JOINED_NOT_SUPPORTED} — any
3764     *       shape with {@code delete.getJoins().size() &gt; 0}: MySQL
3765     *       multi-target {@code DELETE T1, T2 FROM …}, MySQL
3766     *       self-reference {@code DELETE T1 FROM T1}, MySQL
3767     *       multi-USING {@code DELETE FROM T1 USING T1, T2}.
3768     *       Candidates (c) and (d) in §8.1.4 lift these later.</li>
3769     *   <li>{@link DiagnosticCode#DELETE_FROM_JOIN_USING_NOT_SUPPORTED}
3770     *       — {@code USING(col1, col2)} on a FROM-side join item;
3771     *       mirror of slice-82 {@code UPDATE_FROM_JOIN_USING_*}.</li>
3772     *   <li>{@link DiagnosticCode#DELETE_FROM_JOIN_NATURAL_NOT_SUPPORTED}
3773     *       — {@code NATURAL JOIN} on a FROM-side join item.</li>
3774     *   <li>{@link DiagnosticCode#DELETE_FROM_NESTED_JOIN_NOT_SUPPORTED}
3775     *       — defensive: TTable wrapping a TJoin in the FROM source
3776     *       (not reached by any observed parser path on supported
3777     *       dialects, but kept distinct from the subquery code per
3778     *       slice-80 message-text-discrimination contract).</li>
3779     *   <li>{@link DiagnosticCode#DELETE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED}
3780     *       — subquery in a JOIN ON predicate.</li>
3781     * </ul>
3782     *
3783     * <p>Other rejected shapes (slice-81 baseline preserved):
3784     * {@link DiagnosticCode#DELETE_CTE_NOT_SUPPORTED},
3785     * {@link DiagnosticCode#DELETE_TARGET_MISSING},
3786     * {@link DiagnosticCode#DELETE_RETURNING_CLAUSE_NOT_SUPPORTED},
3787     * {@link DiagnosticCode#DELETE_OUTPUT_CLAUSE_NOT_SUPPORTED},
3788     * {@link DiagnosticCode#DELETE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED}.
3789     *
3790     * <p>WHERE-side subqueries reuse the existing
3791     * {@link DiagnosticCode#WHERE_HAS_SUBQUERY_NOT_SUPPORTED} (no
3792     * new DELETE-side code) — consistent with slice-80 UPDATE WHERE
3793     * handling. Window functions in WHERE / ON reuse
3794     * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK}.
3795     *
3796     * <p>IR shape (slice 84 changes from slice 81):
3797     * <ul>
3798     *   <li>{@code relations[]} — now carries TABLE-kind
3799     *       {@link RelationSource}s for joined-DELETE FROM-side
3800     *       sources, plus SUBQUERY-kind sources for {@code USING
3801     *       (SELECT …)} extractions. Slice 81 left it empty.
3802     *       Reference-identity filter excludes the target's own
3803     *       TTable instance; the slice-82 walker-order swap (target
3804     *       before relations[] in
3805     *       {@link gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer#collectCatalogMissWarnings})
3806     *       handles same-qualified-name target+driver collisions
3807     *       (e.g. MSSQL {@code DELETE FROM t FROM t spqh JOIN sp}).</li>
3808     *   <li>{@code joinColumnRefs[]} — now carries ON-clause refs
3809     *       collected from each JoinItem under a per-DELETE
3810     *       {@link java.util.LinkedHashSet} for cross-JoinItem dedup
3811     *       (slice-82 codex round-1 Q2 BLOCKING precedent).</li>
3812     *   <li>The DELETE itself emits NO new cross-stmt
3813     *       {@link LineageEdge}s — empty {@code outputColumns[]}
3814     *       means there is no STATEMENT_OUTPUT(deleteIdx, …) anchor
3815     *       for slice-83's SUBQUERY-kind emitter. Extracted
3816     *       FROM-subqueries DO emit their own internal lineage edges
3817     *       via {@code emitLineageForStatement} inside
3818     *       {@link #processDirectSubqueryTable}.</li>
3819     * </ul>
3820     */
3821    public static SemanticProgram buildDelete(TDeleteSqlStatement delete,
3822                                              NameBindingProvider provider) {
3823        if (delete == null) {
3824            throw new IllegalArgumentException("delete must not be null");
3825        }
3826        if (provider == null) {
3827            throw new IllegalArgumentException("provider must not be null");
3828        }
3829
3830        // 1) Slice 106 — admit top-level WITH on DELETE. Walks the CTE
3831        // list left-to-right, building each body as a preceding
3832        // StatementGraph and producing cteNameToStatementIndex +
3833        // ctePublishedColumns for the FROM-as-CTE branch in
3834        // buildDeleteRelation below. Mirrors the slice-105 UPDATE
3835        // walker. `stmts` / `lineage` allocated here (hoisted from the
3836        // prior slice-84 location) so the CTE walker can append.
3837        // DELETE_CTE_NOT_SUPPORTED stays declared-but-unreached
3838        // (slice 71/72/82/86/95/96/97/98/99/100/101/102/103/104/105
3839        // precedent).
3840        List<StatementGraph> stmts = new ArrayList<>();
3841        List<LineageEdge> lineage = new ArrayList<>();
3842        Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>();
3843        Map<String, Integer> cteNameToStatementIndex = buildDeleteCteList(
3844                delete, provider, stmts, lineage, ctePublishedColumns);
3845
3846        // 2) Target table — defensive (parser usually rejects first).
3847        TTable targetTable = delete.getTargetTable();
3848        if (targetTable == null || targetTable.getTableName() == null) {
3849            throw new SemanticIRBuildException(Diagnostic.error(
3850                    DiagnosticCode.DELETE_TARGET_MISSING,
3851                    "DELETE statement has no resolvable target table",
3852                    delete));
3853        }
3854        String targetQName = targetTable.getTableName().toString();
3855        if (targetQName == null || targetQName.isEmpty()) {
3856            throw new SemanticIRBuildException(Diagnostic.error(
3857                    DiagnosticCode.DELETE_TARGET_MISSING,
3858                    "DELETE target table name is empty",
3859                    delete));
3860        }
3861
3862        // 3) Slice 84 / Slice 92 — joined-DELETE discriminator.
3863        // Parser-probe-verified shapes:
3864        //   - Admit (slice 84): PG `DELETE FROM t USING j` / MSSQL
3865        //     `DELETE FROM t FROM t spqh JOIN sp` / MSSQL `DELETE spqh
3866        //     FROM t spqh JOIN sp` / Snowflake DELETE-USING — all have
3867        //     joins.size=0 and referenceJoins.size > 0.
3868        //   - Admit (slice 92): MySQL `DELETE T1 FROM T1 [WHERE pred]`
3869        //     self-reference — joins.size=1, refJoins.size=1, and all
3870        //     three names (joins[0].table, refJoins[0].table, target)
3871        //     agree case-insensitively. Semantically identical to
3872        //     `DELETE FROM T1 [WHERE pred]`; produces the same IR shape.
3873        //   - Reject (slice-81 code preserved for non-self-ref):
3874        //     MySQL `DELETE T1, T2 FROM …` (joins.size=2) and
3875        //     MySQL `DELETE FROM T1 USING T1, T2` (refJoins.size=2).
3876        //
3877        // Slice 84 drops the slice-81 `tables.size > 1` and
3878        // `fromSourceJoin != null` blanket rejects (both fire for
3879        // admit shapes; probe confirms no parser-reachable shape
3880        // needs them when joins.size == 0). Candidate (d) in §8.1.4
3881        // (Hive multi-insert) remains open for a future slice.
3882        boolean mysqlSelfRef = false;
3883        if (delete.joins != null && delete.joins.size() > 0) {
3884            // Slice 92 — admit MySQL self-reference form:
3885            //   DELETE T1 FROM T1 [WHERE …]
3886            // The check requires all three names to match (codex
3887            // plan-review rounds Q1+Q5 BLOCKING fix: checking only
3888            // joins[0] is insufficient — DELETE T1 FROM T2 would
3889            // incorrectly admit because joins[0]=T1=target but
3890            // refJoins[0]=T2≠target).
3891            mysqlSelfRef = isMysqlSelfReferenceDelete(delete, targetQName);
3892            if (!mysqlSelfRef) {
3893                throw new SemanticIRBuildException(Diagnostic.error(
3894                        DiagnosticCode.DELETE_JOINED_NOT_SUPPORTED,
3895                        "DELETE with multi-target / multi-USING clause is "
3896                                + "not supported by SemanticIRBuilder.buildDelete; "
3897                                + "slice 84 admits PG `DELETE FROM t USING j` and "
3898                                + "MSSQL `DELETE FROM t FROM t JOIN s` shapes; "
3899                                + "slice 92 admits MySQL "
3900                                + "`DELETE T1 FROM T1 [WHERE …]` self-reference",
3901                        delete));
3902            }
3903        }
3904
3905        // 4) Slice 85 lifts the RETURNING / OUTPUT rejects on DELETE.
3906        // The cheap statement-level OUTPUT_INTO reject fires here so a
3907        // multi-violation shape routes to the cheaper structural code
3908        // first. {@code DELETE_RETURNING_CLAUSE_NOT_SUPPORTED} and
3909        // {@code DELETE_OUTPUT_CLAUSE_NOT_SUPPORTED} stay declared but
3910        // unreached (slice 71/72 retain-for-documentation precedent).
3911        if (delete.getOutputClause() != null
3912                && delete.getOutputClause().getIntoTable() != null) {
3913            throw new SemanticIRBuildException(Diagnostic.error(
3914                    DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED,
3915                    "DELETE OUTPUT ... INTO <target> writes a second target; "
3916                            + "slice 85 admits projection-only OUTPUT",
3917                    delete));
3918        }
3919        if (delete.getOrderByClause() != null
3920                || delete.getLimitClause() != null) {
3921            throw new SemanticIRBuildException(Diagnostic.error(
3922                    DiagnosticCode.DELETE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED,
3923                    "DELETE with ORDER BY / LIMIT (MySQL) is not "
3924                            + "supported by SemanticIRBuilder.buildDelete; "
3925                            + "slice 81 admits no row-pruning on DELETE",
3926                    delete));
3927        }
3928
3929        // 4.7) Slice 84 — extract FROM-subqueries from referenceJoins
3930        // (after slice 106's CTE walker so the CTE bodies precede any
3931        // extracted FROM-subquery in the program). Mirrors slice-83
3932        // UPDATE FROM-subquery extraction (which uses
3933        // update.getJoins()); here we use delete.getReferenceJoins().
3934        // buildDelete owns fresh local stmts/lineage lists (allocated
3935        // in step 1 above) so exceptions propagate cleanly to the
3936        // caller — no snapshot/rollback wrapper.
3937        //
3938        // Slice 106 — forward cteNameToStatementIndex +
3939        // ctePublishedColumns so a nested SELECT inside an extracted
3940        // FROM-subquery body can resolve outer-WITH CTE references
3941        // (Resolver2 wires CTEScope; the maps are forwarded for parity
3942        // with the SELECT / MERGE / UPDATE call sites and so the
3943        // §N test for `USING (SELECT … FROM cte) sub` produces the
3944        // expected cross-stmt edge to the CTE body).
3945        //
3946        // Decorate the provider with the outer-WITH CTE name set so
3947        // the SELECT-side {@link #buildRelation} routes references to
3948        // those names through {@link RelationKind#CTE} (rather than
3949        // TABLE), which in turn makes
3950        // {@link #emitLineageForStatement} emit the cross-stmt
3951        // {@code STATEMENT_OUTPUT(subIdx,col) →
3952        // STATEMENT_OUTPUT(cteIdx,col)} edge required by §N. This
3953        // mirrors the SELECT-side outer-WITH walker
3954        // (see {@link #build}'s {@code outerProvider}).
3955        NameBindingProvider providerWithCte = cteNameToStatementIndex.isEmpty()
3956                ? provider
3957                : provider.withCteContext(cteNameToStatementIndex.keySet());
3958        Map<String, Integer> subqueryAliasToIndex =
3959                extractDeleteFromSubqueries(delete, providerWithCte, stmts, lineage,
3960                        cteNameToStatementIndex, ctePublishedColumns);
3961        Map<String, List<String>> deleteInScope = buildDeleteInScopeMap(
3962                delete, subqueryAliasToIndex, stmts,
3963                cteNameToStatementIndex, ctePublishedColumns);
3964        NameBindingProvider providerWithStar = deleteInScope.isEmpty()
3965                ? providerWithCte
3966                : providerWithCte.withInScopeRelationColumns(deleteInScope);
3967
3968        // 5) WHERE refs — slice 111 lifts the slice-81 blanket subquery
3969        // reject by routing uncorrelated predicate-subquery wrappers
3970        // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
3971        // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction pipeline
3972        // refactored by slice 110 to take a PredicateClauseContext. The
3973        // new DELETE_WHERE constant carries clause-specific
3974        // DiagnosticCode IDs (8 new DELETE_WHERE_* codes) and a
3975        // "DELETE WHERE clause" label. Each extracted wrapper lands as
3976        // its own <predicate_subquery_<i>> StatementGraph BEFORE the
3977        // DELETE (deleteIdx below = stmts.size() naturally accounts for
3978        // them — slice-83 dynamic-index pattern, slice 110 UPDATE
3979        // precedent). Remaining non-subquery refs flow into
3980        // filterColumnRefs via collectColumnRefsSkipping (or
3981        // collectColumnRefsTolerant on the slice-92 MySQL self-ref
3982        // path). Window functions in non-subquery subtrees still reject
3983        // via rejectWindowFunctionInScopeSkipping. Slice 84 —
3984        // providerWithStar so WHERE refs against extracted subquery
3985        // aliases bind correctly (slice-83 precedent).
3986        //
3987        // Slice 106 — providerWithCte (then providerWithStar on top of
3988        // it) already decorates the provider with the outer-WITH CTE
3989        // name set so the predicate body's inner SELECT routes
3990        // `FROM cte` refs through RelationKind.CTE and
3991        // emitLineageForStatement emits the
3992        // STATEMENT_OUTPUT(subIdx,col) → STATEMENT_OUTPUT(cteIdx,col)
3993        // cross-stmt edge (slice 110 UPDATE precedent).
3994        List<ColumnRef> filterRefs;
3995        TWhereClause where = delete.getWhereClause();
3996        if (where == null || where.getCondition() == null) {
3997            filterRefs = Collections.<ColumnRef>emptyList();
3998        } else {
3999            Set<TExpression> extractedWhereRoots =
4000                    Collections.<TExpression>emptySet();
4001            if (containsAnySubquery(where)) {
4002                extractedWhereRoots =
4003                        extractUncorrelatedPredicateSubqueriesFromClause(
4004                                where.getCondition(), providerWithStar,
4005                                stmts, lineage, cteNameToStatementIndex,
4006                                PredicateClauseContext.DELETE_WHERE);
4007                rejectAnyRemainingSubqueriesFromClause(
4008                        where.getCondition(), extractedWhereRoots,
4009                        PredicateClauseContext.DELETE_WHERE);
4010            }
4011            rejectWindowFunctionInScopeSkipping(where, "WHERE clause",
4012                    extractedWhereRoots);
4013            // Codex diff-review P1 fix: for MySQL self-reference DELETE the
4014            // MySQL parser puts 3 T1 instances in stmt.tables (target +
4015            // joins[0] + refJoins[0]), making Resolver2's inferredCandidates
4016            // see 3 candidates for any unqualified column → NOT_FOUND →
4017            // COLUMN_BINDING_NON_EXACT. Use a tolerant collector for the
4018            // self-ref path: EXACT_MATCH bindings (qualified refs) are
4019            // preserved verbatim; non-exact bindings emit the column ref with
4020            // the SQL-written qualifier (null for unqualified refs) instead of
4021            // throwing. Qualified refs like WHERE T1.id = 1 still get full
4022            // EXACT_MATCH treatment; only WHERE id = 1 (no qualifier) falls
4023            // back to the tolerant path. Slice 111 — both helpers now skip
4024            // extracted predicate-subquery subtrees so inner refs do not
4025            // leak into outer filterColumnRefs.
4026            filterRefs = mysqlSelfRef
4027                    ? collectColumnRefsTolerant(where, providerWithStar,
4028                            targetQName, extractedWhereRoots)
4029                    : collectColumnRefsSkipping(where, providerWithStar,
4030                            extractedWhereRoots);
4031        }
4032
4033        // 5.5) Slice 84 — walk delete.getReferenceJoins() to populate
4034        // relations[] (TABLE-kind FROM-side sources, target excluded
4035        // by reference identity; SUBQUERY-kind for USING (SELECT …))
4036        // and joinColumnRefs[] (ON-clause refs across all JoinItems).
4037        // Mirrors slice-82 buildUpdate's FROM walk, with the
4038        // `update.getJoins()` source replaced by
4039        // `delete.getReferenceJoins()`.
4040        //
4041        // Slice 92 — for MySQL self-reference DELETE T1 FROM T1,
4042        // refJoins[0] is the same table as the target; skip the loop
4043        // so relations[] stays empty (mirrors the slice-81 single-target
4044        // contract). Resolver2's ScopeBuilder has already registered
4045        // the FROM-clause table (including any alias) via the
4046        // `referenceJoins` walk in preVisit(TDeleteSqlStatement), so
4047        // WHERE refs resolve correctly even without a relations[] entry.
4048        List<RelationSource> relations = new ArrayList<>();
4049        // Slice-82 codex round-1 Q2 BLOCKING precedent — joinRefs
4050        // accumulates across multiple JoinItems in chained-JOIN
4051        // shapes. LinkedHashSet ensures cross-JoinItem dedup.
4052        java.util.LinkedHashSet<ColumnRef> joinRefsSet =
4053                new java.util.LinkedHashSet<>();
4054        TJoinList refJoins = delete.getReferenceJoins();
4055        if (!mysqlSelfRef && refJoins != null) {
4056            for (int ji = 0; ji < refJoins.size(); ji++) {
4057                TJoin join = refJoins.getJoin(ji);
4058                TTable leftTable = join.getTable();
4059                // Slice 106 — threads cteNameToStatementIndex so the
4060                // FROM-driver buildDeleteRelation call can route
4061                // objectname-typed CTE references to a SUBQUERY-kind
4062                // RelationSource pointing at the CTE statement.
4063                buildDeleteRelation(leftTable, targetTable, relations, delete,
4064                        cteNameToStatementIndex);
4065                TJoinItemList items = join.getJoinItems();
4066                if (items == null) continue;
4067                for (int i = 0; i < items.size(); i++) {
4068                    TJoinItem item = items.getJoinItem(i);
4069                    // Slice 106 — threads cteNameToStatementIndex through
4070                    // the JoinItem walker so JOIN-side CTE refs (MSSQL
4071                    // `FROM target t JOIN cte ON …`) get SUBQUERY-kind
4072                    // RelationSource emission.
4073                    buildDeleteJoinItem(item, targetTable, providerWithStar,
4074                            relations, joinRefsSet, delete,
4075                            cteNameToStatementIndex);
4076                }
4077            }
4078        }
4079        List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet);
4080
4081        // 6) Build the DELETE outer.
4082        // - relations[] may be non-empty for joined DELETE (slice 84);
4083        //   empty for single-target DELETE (slice 81 contract).
4084        // - target.columns empty by design — DELETE removes whole rows.
4085        RelationBinding targetBinding = new RelationBinding(
4086                RelationKind.TABLE, targetQName);
4087        TargetRelation target = new TargetRelation(
4088                targetBinding, Collections.<String>emptyList());
4089
4090        // Slice 85 — build RETURNING / OUTPUT projection columns BEFORE
4091        // the StatementGraph so the new returningColumns slot can be
4092        // populated. deleteIdx mirrors the slice-84 stmts.size() pattern.
4093        int deleteIdx = stmts.size();
4094        // DELETE target alias = effective alias from the target's
4095        // TTable (slice-84 convention).
4096        String deleteTargetAlias = effectiveAliasOf(targetTable);
4097        if (deleteTargetAlias == null || deleteTargetAlias.isEmpty()) {
4098            deleteTargetAlias = targetQName;
4099        }
4100        // Slice 123 — combine slice-84 FROM-subquery aliases with slice-106
4101        // CTE-as-relation aliases so a RETURNING/OUTPUT ref to a SUBQUERY-
4102        // kind FROM-side relation column emits the cross-stmt
4103        // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge into the producer.
4104        Map<String, Integer> returningAliasToSubIdx =
4105                buildDeleteCombinedAliasToSubIdx(delete,
4106                        subqueryAliasToIndex, cteNameToStatementIndex);
4107        List<OutputColumn> returningColumns = buildReturningColumns(
4108                delete.getReturningClause(),
4109                delete.getOutputClause(),
4110                "DELETE",
4111                targetQName,
4112                deleteTargetAlias,
4113                /*targetTable=*/ targetTable,
4114                relations,
4115                returningAliasToSubIdx,
4116                providerWithStar,
4117                deleteIdx,
4118                lineage,
4119                delete);
4120
4121        StatementGraph deleteStmt = new StatementGraph(
4122                /*name=*/ null,
4123                "DELETE",
4124                relations,
4125                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
4126                returningColumns,
4127                filterRefs,
4128                joinRefs,
4129                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4130                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4131                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4132                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4133                /*distinct=*/ false,
4134                /*setOperator=*/ null,
4135                /*rowLimit=*/ null,
4136                target);
4137
4138        stmts.add(deleteStmt);
4139        // Slice 85 — extracted FROM-subqueries have already emitted
4140        // their own internal lineage edges into `lineage` via
4141        // processDirectSubqueryTable; buildReturningColumns also
4142        // already appended STATEMENT_OUTPUT(deleteIdx, retName) →
4143        // TABLE_COLUMN(targetQName, baseCol) edges above. No further
4144        // edges are needed.
4145        return new SemanticProgram(stmts, lineage);
4146    }
4147
4148    /**
4149     * Slice 94 — admit the single-target MERGE skeleton:
4150     * <pre>
4151     *     MERGE INTO target [AS] tgt
4152     *     USING (source_table | (SELECT ...) ) [AS] src
4153     *     ON  &lt;join condition&gt;
4154     *     WHEN MATCHED      [AND &lt;cond&gt;] THEN UPDATE SET c1 = expr1 [, ...]
4155     *     WHEN NOT MATCHED  [AND &lt;cond&gt;] THEN INSERT [(c1, ...)] VALUES (expr1, ...)
4156     *     WHEN MATCHED      [AND &lt;cond&gt;] THEN DELETE
4157     * </pre>
4158     *
4159     * <p>Emits one {@code "MERGE"}-kind {@link StatementGraph} carrying:
4160     * <ul>
4161     *   <li>{@link TargetRelation} on {@code getTarget()} only — slice
4162     *       78/80 contract: target lives on the dedicated target slot,
4163     *       NOT in {@code relations[]}. The slice-77/79 catalog walker
4164     *       fires the kind-discriminated "MERGE target relation 'X'"
4165     *       message via {@code targetWarnMessage("MERGE")}.</li>
4166     *   <li>{@code relations[]} = one entry for the USING source
4167     *       (TABLE-kind base table or SUBQUERY-kind aliased subquery).
4168     *       The slice-77 FROM walker fires "FROM relation 'X'" for
4169     *       missing source.</li>
4170     *   <li>{@code outputColumns[]} = empty (MERGE has no projection).</li>
4171     *   <li>{@code joinColumnRefs[]} = ON condition refs + per-WHEN AND
4172     *       condition refs, LinkedHashSet-deduplicated (slice 82
4173     *       pattern).</li>
4174     *   <li>{@code filterColumnRefs[]} = per-WHEN action WHERE refs
4175     *       (UPDATE WHERE, UPDATE...DELETE WHERE, INSERT WHERE; slice
4176     *       95). Empty when no action WHERE is present.</li>
4177     * </ul>
4178     *
4179     * <p>Per-WHEN action lineage:
4180     * <ul>
4181     *   <li>{@code WHEN MATCHED THEN UPDATE SET col_i = expr_i}: emit
4182     *       one {@link LineageEdge} per (target col, RHS source ref)
4183     *       pair as {@code TABLE_COLUMN(target,col) ← <ref>} — direct,
4184     *       no STATEMENT_OUTPUT intermediate (MERGE has no SELECT
4185     *       projection). Codex round-2 Q4 confirmed YES.</li>
4186     *   <li>{@code WHEN NOT MATCHED THEN INSERT (c1, ...) VALUES (e1, ...)}:
4187     *       same pattern — one edge per (insert col, source ref).</li>
4188     *   <li>{@code WHEN MATCHED THEN DELETE}: no per-column lineage
4189     *       (slice 81 DELETE contract).</li>
4190     *   <li>{@code WHEN MATCHED [AND <cond>] THEN DO NOTHING} (PG 15+,
4191     *       slice 96): admitted as a no-op action. No per-column
4192     *       lineage (slice 81 DELETE precedent). Per-WHEN AND
4193     *       condition refs still feed {@code joinColumnRefs[]} via
4194     *       the pre-dispatch block.</li>
4195     *   <li>{@code WHEN NOT MATCHED BY SOURCE [AND <cond>] THEN
4196     *       UPDATE SET ... | DELETE} (SQL Server, slice 97):
4197     *       admitted with the SQL Server semantic invariant that
4198     *       SET RHS and per-WHEN AND cond may not reference USING
4199     *       source columns (no source row exists when the action
4200     *       fires). Source-side refs reject with
4201     *       {@link DiagnosticCode#MERGE_NOT_MATCHED_BY_SOURCE_REFERENCES_SOURCE}.
4202     *       INSERT on BY SOURCE is parser-admitted but semantically
4203     *       invalid; rejects with
4204     *       {@link DiagnosticCode#MERGE_NOT_MATCHED_BY_SOURCE_INSERT_NOT_VALID}.
4205     *       UPDATE target self-refs ({@code t.a = t.b}) emit no
4206     *       lineage edges (slice-94 alias-filter convention; codex
4207     *       round-1 Q2 confirmed). PG 17+ BY SOURCE syntax still
4208     *       parses as type 2 plain NOT MATCHED in parser 4.1.5.0
4209     *       — that parser gap is not addressed in slice 97.</li>
4210     * </ul>
4211     *
4212     * <p>For USING-subquery, the inner SELECT is built via {@link #build}
4213     * and appended as a preceding {@link StatementGraph}; its inner
4214     * lineage edges are rebased by the current statement-list offset so
4215     * STATEMENT_OUTPUT indices stay valid (slice 78 INSERT pattern).
4216     *
4217     * <p>Resolver2 already handles MERGE via {@link gudusoft.gsqlparser.resolver2.scope.MergeScope}
4218     * — both USING base tables and USING subqueries surface as
4219     * {@code sourceTable + EXACT_MATCH} bindings on RHS / VALUES /
4220     * ON / WHEN-AND refs. Codex round-2 Q5 BLOCKING fix: we install
4221     * an explicit slice-83-style published-column map only for
4222     * USING subqueries (deterministic; cheap; matches the SELECT-
4223     * side FROM-subquery pattern even when redundant).
4224     */
4225    public static SemanticProgram buildMerge(TMergeSqlStatement merge,
4226                                             NameBindingProvider provider) {
4227        if (merge == null) {
4228            throw new IllegalArgumentException("merge must not be null");
4229        }
4230        if (provider == null) {
4231            throw new IllegalArgumentException("provider must not be null");
4232        }
4233        // Slice 94 — defensive UsingScope reset at entry. MERGE does
4234        // not produce its own UsingScope but a parent context might
4235        // (e.g. nested-statement contexts); mirrors slice 80 / 86
4236        // buildUpdate hygiene.
4237        provider = provider.withUsingScope(UsingScope.EMPTY);
4238
4239        // Slice 101 — hoist allocations earlier so buildMergeCteList can
4240        // append CTE bodies as preceding statements. The slice-94 reject
4241        // at this location is replaced by the CTE walker below.
4242        List<StatementGraph> stmts = new ArrayList<>();
4243        List<LineageEdge> lineage = new ArrayList<>();
4244
4245        // 1) Slice 101 — admit top-level WITH on MERGE. Walks CTE list
4246        // left-to-right, building each body as a preceding statement.
4247        // Produces cteNameToStatementIndex + ctePublishedColumns for the
4248        // USING-as-CTE branch below. Mirrors SELECT-side build() at
4249        // lines 516-653. `MERGE_CTE_NOT_SUPPORTED` stays declared-but-
4250        // unreached for API stability (slice 71/72/82/86/95/96/97/98/99/100
4251        // precedent).
4252        Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>();
4253        Map<String, Integer> cteNameToStatementIndex = buildMergeCteList(
4254                merge, provider, stmts, lineage, ctePublishedColumns);
4255
4256        // 2) Target table — defensive.
4257        TTable targetTable = merge.getTargetTable();
4258        if (targetTable == null || targetTable.getTableName() == null) {
4259            throw new SemanticIRBuildException(Diagnostic.error(
4260                    DiagnosticCode.MERGE_TARGET_MISSING,
4261                    "MERGE statement has no resolvable target table",
4262                    merge));
4263        }
4264        String targetQName = targetTable.getTableName().toString();
4265        if (targetQName == null || targetQName.isEmpty()) {
4266            throw new SemanticIRBuildException(Diagnostic.error(
4267                    DiagnosticCode.MERGE_TARGET_MISSING,
4268                    "MERGE target table name is empty",
4269                    merge));
4270        }
4271
4272        // 3) USING source — defensive.
4273        TTable usingTable = merge.getUsingTable();
4274        if (usingTable == null) {
4275            throw new SemanticIRBuildException(Diagnostic.error(
4276                    DiagnosticCode.MERGE_USING_SOURCE_MISSING,
4277                    "MERGE statement has no USING source",
4278                    merge));
4279        }
4280
4281        // 4) ON condition — defensive (parser usually rejects first).
4282        TExpression onCondition = merge.getCondition();
4283        if (onCondition == null) {
4284            throw new SemanticIRBuildException(Diagnostic.error(
4285                    DiagnosticCode.MERGE_ON_CONDITION_MISSING,
4286                    "MERGE statement has no ON condition",
4287                    merge));
4288        }
4289
4290        // 5) OUTPUT INTO / RETURNING / LIMIT / error logging rejects.
4291        // Slice 98 lifts MSSQL MERGE OUTPUT projection (non-INTO) via
4292        // the slice-85 buildReturningColumns walker; the actual call
4293        // is deferred until after step 8 because the walker needs the
4294        // populated relations[] (USING source). OUTPUT INTO continues
4295        // to reject (writes a second target). The RETURNING-clause
4296        // branch stays declared-but-unreached: PG parser drops
4297        // MERGE RETURNING silently, Oracle PARSE_FAILED, Couchbase
4298        // has no test reach (slice 71/72/82/86/95/96/97 precedent).
4299        if (merge.getOutputClause() != null
4300                && merge.getOutputClause().getIntoTable() != null) {
4301            throw new SemanticIRBuildException(Diagnostic.error(
4302                    DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED,
4303                    "MERGE OUTPUT ... INTO <target> writes a second "
4304                            + "target; slice 98 admits OUTPUT projection only",
4305                    merge));
4306        }
4307        if (merge.getReturningClause() != null) {
4308            throw new SemanticIRBuildException(Diagnostic.error(
4309                    DiagnosticCode.MERGE_RETURNING_CLAUSE_NOT_SUPPORTED,
4310                    "MERGE RETURNING projection (Oracle / Couchbase) is "
4311                            + "not supported by SemanticIRBuilder.buildMerge",
4312                    merge));
4313        }
4314        if (merge.getLimitClause() != null) {
4315            throw new SemanticIRBuildException(Diagnostic.error(
4316                    DiagnosticCode.MERGE_LIMIT_NOT_SUPPORTED,
4317                    "MERGE with LIMIT (Couchbase) is not supported by "
4318                            + "SemanticIRBuilder.buildMerge",
4319                    merge));
4320        }
4321        if (merge.getErrorLoggingClause() != null) {
4322            throw new SemanticIRBuildException(Diagnostic.error(
4323                    DiagnosticCode.MERGE_ERROR_LOGGING_NOT_SUPPORTED,
4324                    "MERGE LOG ERRORS INTO (Oracle) is not supported by "
4325                            + "SemanticIRBuilder.buildMerge",
4326                    merge));
4327        }
4328
4329        // 6) Build USING source RelationSource. If USING is a subquery,
4330        // extract it as a preceding StatementGraph and emit a SUBQUERY-
4331        // kind RelationSource that points at it; slice-83 pattern.
4332        // Otherwise emit a TABLE-kind RelationSource.
4333        // Slice 101 — `stmts` / `lineage` were hoisted to the top of
4334        // buildMerge so the CTE walker can append its preceding CTE
4335        // body statements first. Do NOT re-declare them here.
4336        String usingAlias = effectiveAliasOf(usingTable);
4337        if (usingAlias == null || usingAlias.isEmpty()) {
4338            usingAlias = (usingTable.getName() == null
4339                    || usingTable.getName().toString().isEmpty())
4340                    ? "__merge_using__"
4341                    : usingTable.getName().toString();
4342        }
4343        boolean usingIsSubquery = usingTable.getTableType()
4344                == gudusoft.gsqlparser.ETableSource.subquery;
4345        List<RelationSource> relations = new ArrayList<>();
4346        Map<String, List<String>> mergeInScope = new LinkedHashMap<>();
4347        NameBindingProvider providerWithStar = provider;
4348        // Slice 94 — alias resolution maps for the per-WHEN action
4349        // lineage emitter. TABLE-kind sources map alias → qualifiedName;
4350        // SUBQUERY-kind sources map alias → statement index of the
4351        // extracted inner SELECT. A SEPARATE `targetAliases` set
4352        // identifies refs whose relationAlias is the target alias
4353        // (codex round-1 diff Q1 BLOCKING — without this separation,
4354        // a self-merge where USING happens to share the target's name
4355        // would mis-classify the source alias as the target alias).
4356        Map<String, String> aliasToTableQName = new HashMap<>();
4357        Map<String, Integer> aliasToSubIdx = new HashMap<>();
4358        Set<String> targetAliases = new HashSet<>();
4359        String targetAlias = effectiveAliasOf(targetTable);
4360        if (targetAlias != null && !targetAlias.isEmpty()) {
4361            targetAliases.add(targetAlias.toLowerCase(Locale.ROOT));
4362        }
4363        targetAliases.add(targetQName.toLowerCase(Locale.ROOT));
4364        if (usingIsSubquery) {
4365            TSelectSqlStatement usingSelect = usingTable.getSubquery();
4366            if (usingSelect == null) {
4367                throw new SemanticIRBuildException(Diagnostic.error(
4368                        DiagnosticCode.MERGE_SOURCE_NOT_SUPPORTED,
4369                        "MERGE USING declared as subquery but no inner "
4370                                + "SELECT statement was attached",
4371                        merge));
4372            }
4373            // Slice 122 — close the slice-110 documented parity gap so a
4374            // USING body that references an outer MERGE CTE
4375            // (`WITH cte AS (...) MERGE INTO t USING (SELECT ... FROM cte) s`)
4376            // binds CTE-kind and emits the cross-stmt edge into the CTE body.
4377            // Build the USING body directly into the SHARED stmts/lineage via
4378            // buildSelectProgramInto (no rebase — see its javadoc for the full
4379            // rationale), seeded with a COPY of the outer MERGE's CTE maps.
4380            // The COPY (not the live map) keeps a USING-body-own CTE from
4381            // leaking into the outer cteNameToStatementIndex consulted later
4382            // by the slice-116 action-WHERE extractor and the USING-as-CTE
4383            // `else` branch — matching pre-slice-122, where own-WITH CTEs were
4384            // never registered in the outer map.
4385            Map<String, Integer> usingCteMap =
4386                    new HashMap<>(cteNameToStatementIndex);
4387            Map<String, List<String>> usingCtePublished =
4388                    new LinkedHashMap<>(ctePublishedColumns);
4389            int subIdx = buildSelectProgramInto(usingSelect, provider,
4390                    stmts, lineage, usingCteMap, usingCtePublished);
4391            // Codex round-2 Q5 BLOCKING fix: install slice-83-style
4392            // in-scope map for USING subquery columns, scoped only to
4393            // the USING alias (codex round-3 Q2: ensure scoped to
4394            // USING alias only, no override of target / base-table).
4395            StatementGraph usingOuter = stmts.get(subIdx);
4396            List<String> publishedCols = new ArrayList<>();
4397            for (OutputColumn oc : usingOuter.getOutputColumns()) {
4398                if (oc.getName() != null && !oc.getName().isEmpty()) {
4399                    publishedCols.add(oc.getName());
4400                }
4401            }
4402            mergeInScope.put(
4403                    usingAlias.toLowerCase(Locale.ROOT), publishedCols);
4404            providerWithStar = provider.withInScopeRelationColumns(
4405                    mergeInScope);
4406            relations.add(new RelationSource(usingAlias,
4407                    new RelationBinding(RelationKind.SUBQUERY, usingAlias)));
4408            aliasToSubIdx.put(
4409                    usingAlias.toLowerCase(Locale.ROOT), subIdx);
4410        } else {
4411            // Slice 101 — USING-as-CTE detection. When MERGE has a WITH
4412            // clause and the USING bare name matches a CTE declared in
4413            // that WITH clause, route to a SUBQUERY-kind RelationSource
4414            // pointing at the CTE's already-built statement index. This
4415            // ensures:
4416            //   (a) lineage edges flow to STATEMENT_OUTPUT(cteIdx, col),
4417            //       not the fictitious TABLE_COLUMN(cteName, col);
4418            //   (b) the slice-77 catalog-miss WARN walker (which walks
4419            //       only TABLE-kind RelationSources) skips the CTE name;
4420            //   (c) Resolver2-bound CTE refs (probe 2026-05-17: status
4421            //       EXACT_MATCH with sourceTable=<cteName>) flow through
4422            //       the same emitMergeLineageEdge dispatch.
4423            // Case-insensitive lookup matches SQL identifier semantics.
4424            String usingBareName = (usingTable.getName() == null)
4425                    ? ""
4426                    : usingTable.getName().toString();
4427            String usingBareNameLower =
4428                    usingBareName.toLowerCase(Locale.ROOT);
4429            Integer cteIdx = usingBareNameLower.isEmpty()
4430                    ? null
4431                    : cteNameToStatementIndex.get(usingBareNameLower);
4432            if (cteIdx != null) {
4433                // USING references a declared CTE.
4434                List<String> publishedCols = ctePublishedColumns.get(
4435                        usingBareNameLower);
4436                if (publishedCols == null) {
4437                    publishedCols = new ArrayList<>();
4438                }
4439                mergeInScope.put(
4440                        usingAlias.toLowerCase(Locale.ROOT),
4441                        publishedCols);
4442                providerWithStar = provider.withInScopeRelationColumns(
4443                        mergeInScope);
4444                relations.add(new RelationSource(usingAlias,
4445                        new RelationBinding(
4446                                RelationKind.SUBQUERY, usingAlias)));
4447                aliasToSubIdx.put(
4448                        usingAlias.toLowerCase(Locale.ROOT), cteIdx);
4449                // Also register the bare CTE name in case the SQL
4450                // omits the alias (e.g. `USING src ON ...` with no
4451                // trailing alias). Mirrors the TABLE-kind branch
4452                // (line below) which also registers the bare name.
4453                aliasToSubIdx.put(usingBareNameLower, cteIdx);
4454            } else {
4455                // TABLE-kind USING — use the source table's qualified name
4456                // as the binding's qualifiedName so the slice-77 catalog
4457                // walker can find it.
4458                String usingQName = (usingTable.getTableName() == null)
4459                        ? usingAlias
4460                        : usingTable.getTableName().toString();
4461                relations.add(new RelationSource(usingAlias,
4462                        new RelationBinding(RelationKind.TABLE, usingQName)));
4463                aliasToTableQName.put(
4464                        usingAlias.toLowerCase(Locale.ROOT), usingQName);
4465                // Also register the bare name in case the SQL omits the
4466                // alias (e.g. `USING managers ON ...` without `s`).
4467                aliasToTableQName.put(
4468                        usingQName.toLowerCase(Locale.ROOT), usingQName);
4469            }
4470        }
4471
4472        // 7) Walk ON condition + per-WHEN AND conditions to build
4473        // joinColumnRefs[] with LinkedHashSet dedup (slice 82 pattern).
4474        // Reject ON-side subqueries: not supported in this slice; users
4475        // can still use a USING subquery for complex source logic.
4476        if (containsAnySubqueryExpression(onCondition)) {
4477            throw new SemanticIRBuildException(Diagnostic.error(
4478                    DiagnosticCode.MERGE_WHEN_CONDITION_HAS_SUBQUERY_NOT_SUPPORTED,
4479                    "MERGE ON condition contains a subquery; slice 94 "
4480                            + "admits scalar-only ON conditions",
4481                    merge));
4482        }
4483        rejectWindowFunctionInScope(onCondition, "MERGE ON condition");
4484        LinkedHashSet<ColumnRef> joinRefsSet = new LinkedHashSet<>();
4485        joinRefsSet.addAll(collectColumnRefs(onCondition, providerWithStar));
4486        // Slice 95 — per-WHEN action WHERE refs (UPDATE WHERE,
4487        // UPDATE...DELETE WHERE, INSERT WHERE) accumulate here.
4488        // Slice 94 left these refs silently dropped; slice 95 routes
4489        // them through filterColumnRefs[] (slice-80 UPDATE WHERE
4490        // precedent) — distinct from joinColumnRefs[] which holds
4491        // ON + WHEN-AND match conditions.
4492        LinkedHashSet<ColumnRef> filterRefsSet = new LinkedHashSet<>();
4493
4494        // 8) Per-WHEN clause loop. Validate type, dispatch to action
4495        // builder, accumulate joinColumnRefs and lineage edges.
4496        TargetRelation targetRel = null;
4497        List<String> targetColumnNames = new ArrayList<>();
4498        // Stable-order map: target col spelling (lower-cased) →
4499        // verbatim spelling encountered first. Iterating WHEN clauses
4500        // in order naturally produces SET-LHS first, then INSERT
4501        // column-list, matching the plan v3 column ordering rule.
4502        Map<String, String> seenTargetCols = new LinkedHashMap<>();
4503        // LineageEdge dedup spans the whole MERGE on
4504        // (target column lower-case, source ref lower-case key).
4505        Set<String> emittedEdgeKeys = new HashSet<>();
4506
4507        if (merge.getWhenClauses() == null
4508                || merge.getWhenClauses().size() == 0) {
4509            throw new SemanticIRBuildException(Diagnostic.error(
4510                    DiagnosticCode.MERGE_WHEN_NO_ACTION,
4511                    "MERGE statement has no WHEN clauses",
4512                    merge));
4513        }
4514        // Slice 116 — providerWithCteForActionWhere decorates
4515        // providerWithStar with withCteContext so the predicate body's
4516        // inner SELECT's `FROM cte` refs route through
4517        // RelationKind.CTE (slice 110 documented this is required for
4518        // emitLineageForStatement to emit STATEMENT_OUTPUT →
4519        // STATEMENT_OUTPUT edges into the CTE body). Hoisted here once
4520        // (cteNameToStatementIndex is finalized by line 3856 well
4521        // before the per-WHEN loop; recomputing per-WHEN would be
4522        // wasteful — codex diff-review Q1 advisory). The decoration
4523        // is scoped to collectMergeActionWhere only; providerWithStar
4524        // elsewhere stays unchanged so the WHEN AND condition (line
4525        // ~4153) and per-action SET/INSERT walkers see the original
4526        // provider — they already have their own slice-94 subquery
4527        // rejects so no asymmetric resolution surfaces.
4528        final NameBindingProvider providerWithCteForActionWhere =
4529                cteNameToStatementIndex.isEmpty()
4530                        ? providerWithStar
4531                        : providerWithStar.withCteContext(
4532                                cteNameToStatementIndex.keySet());
4533        // Slice 118 — build the MERGE correlation scope once (target +
4534        // USING source + outer CTEs) and thread through every per-WHEN
4535        // action WHERE call so correlated predicate subqueries promote
4536        // outer-aliased refs into OUTER_REFERENCE relations instead of
4537        // rejecting them. Mirrors the slice-117 pattern (UPDATE
4538        // SET-RHS correlated scalars). The scope is null-safe — every
4539        // value type inside flows from already-computed buildMerge
4540        // state (targetTable / usingTable / aliasToSubIdx /
4541        // cteNameToStatementIndex).
4542        final EnclosingScope mergeCorrelationScope =
4543                buildMergeEnclosingScope(merge, cteNameToStatementIndex,
4544                        aliasToSubIdx);
4545        for (int wi = 0; wi < merge.getWhenClauses().size(); wi++) {
4546            TMergeWhenClause when = merge.getWhenClauses().getElement(wi);
4547            // Slice 97 — BY SOURCE variants (SQL Server admits parser
4548            // types 7 / 8) are now admitted. PG 17+ syntax still parses
4549            // as type 2 (parser gap; slice 97 does not address). The
4550            // legacy MERGE_WHEN_NOT_MATCHED_BY_SOURCE_NOT_SUPPORTED
4551            // code stays declared-but-unreached (slice 71/72/82/86/95/96
4552            // precedent).
4553            boolean isNotMatchedBySource =
4554                    when.getType() == TMergeWhenClause.not_matched_by_source
4555                    || when.getType()
4556                        == TMergeWhenClause.not_matched_by_source_with_condition;
4557            // Per-WHEN AND condition (matched_with_condition,
4558            // not_matched_with_condition, not_matched_by_target_with_condition,
4559            // not_matched_by_source_with_condition).
4560            TExpression whenCond = when.getCondition();
4561            if (whenCond != null) {
4562                if (containsAnySubqueryExpression(whenCond)) {
4563                    throw new SemanticIRBuildException(Diagnostic.error(
4564                            DiagnosticCode.MERGE_WHEN_CONDITION_HAS_SUBQUERY_NOT_SUPPORTED,
4565                            "MERGE WHEN AND condition contains a subquery; "
4566                                    + "slice 94 admits scalar-only WHEN "
4567                                    + "conditions",
4568                            merge));
4569                }
4570                rejectWindowFunctionInScope(whenCond, "MERGE WHEN AND condition");
4571                List<ColumnRef> condRefs =
4572                        collectColumnRefs(whenCond, providerWithStar);
4573                // Slice 97 — BY SOURCE branches forbid source-side
4574                // refs in the AND condition (no source row exists).
4575                if (isNotMatchedBySource) {
4576                    rejectSourceRefsForBySource(condRefs, aliasToTableQName,
4577                            aliasToSubIdx, "MERGE WHEN NOT MATCHED BY SOURCE "
4578                                    + "AND condition", merge);
4579                }
4580                joinRefsSet.addAll(condRefs);
4581            }
4582            // Dispatch to action. Slice 96 — DO NOTHING is a no-op
4583            // action (PG 15+): no SET/INSERT VALUES, no per-column
4584            // lineage (slice-81 DELETE precedent). Per-WHEN AND
4585            // condition refs were already collected into joinRefsSet
4586            // above. MERGE_DO_NOTHING_NOT_SUPPORTED stays declared-
4587            // but-unreached for API stability (slice 71/72/82/86/95
4588            // precedent).
4589            boolean isDoNothingAction = when.getDoNothingClause() != null;
4590            TMergeUpdateClause upd = when.getUpdateClause();
4591            TMergeInsertClause ins = when.getInsertClause();
4592            boolean isDeleteAction = when.getDeleteClause() != null;
4593            if (upd == null && ins == null && !isDeleteAction
4594                    && !isDoNothingAction) {
4595                throw new SemanticIRBuildException(Diagnostic.error(
4596                        DiagnosticCode.MERGE_WHEN_NO_ACTION,
4597                        "MERGE WHEN clause #" + (wi + 1)
4598                                + " has no UPDATE / INSERT / DELETE / "
4599                                + "DO NOTHING action",
4600                        merge));
4601            }
4602            // Slice 95 — collect per-WHEN action WHERE refs into
4603            // filterRefsSet. Slice 116 — uncorrelated predicate-subquery
4604            // wrappers in those WHEREs now extract through the slice-23+
4605            // pipeline via PredicateClauseContext.MERGE_WHEN_WHERE
4606            // (mirrors slice 110-114 lifts on UPDATE / DELETE / SELECT /
4607            // set-op branch / CTE-body WHEREs). Window functions still
4608            // reject via rejectWindowFunctionInScopeSkipping (slice 95
4609            // contract preserved). MERGE_UPDATE_DELETE_WHERE_NOT_SUPPORTED
4610            // remains declared-but-unreached (slice 71/72/82/86
4611            // precedent). The providerWithCteForActionWhere decoration
4612            // is hoisted ABOVE the per-WHEN loop (codex diff-review Q1
4613            // advisory) since cteNameToStatementIndex is finalized
4614            // before the loop.
4615            if (upd != null) {
4616                collectMergeActionWhere(upd.getUpdateWhereClause(),
4617                        "MERGE WHEN action UPDATE WHERE",
4618                        providerWithCteForActionWhere, filterRefsSet,
4619                        stmts, lineage, cteNameToStatementIndex, merge,
4620                        mergeCorrelationScope);
4621                collectMergeActionWhere(upd.getDeleteWhereClause(),
4622                        "MERGE WHEN action DELETE WHERE",
4623                        providerWithCteForActionWhere, filterRefsSet,
4624                        stmts, lineage, cteNameToStatementIndex, merge,
4625                        mergeCorrelationScope);
4626            }
4627            if (ins != null) {
4628                collectMergeActionWhere(ins.getInsertWhereClause(),
4629                        "MERGE WHEN action INSERT WHERE",
4630                        providerWithCteForActionWhere, filterRefsSet,
4631                        stmts, lineage, cteNameToStatementIndex, merge,
4632                        mergeCorrelationScope);
4633            }
4634            // Slice 97 — BY SOURCE forbids INSERT semantically. MSSQL
4635            // parser admits the shape, so Semantic IR rejects.
4636            if (isNotMatchedBySource && ins != null) {
4637                throw new SemanticIRBuildException(Diagnostic.error(
4638                        DiagnosticCode.MERGE_NOT_MATCHED_BY_SOURCE_INSERT_NOT_VALID,
4639                        "MERGE WHEN NOT MATCHED BY SOURCE THEN INSERT is "
4640                                + "not a valid SQL Server action; INSERT only "
4641                                + "applies when the source row has no target "
4642                                + "match. Slice 97 admits UPDATE / DELETE on "
4643                                + "BY SOURCE branches.",
4644                        merge));
4645            }
4646            if (upd != null) {
4647                // Slice 97 — pre-walk SET RHS refs for BY SOURCE
4648                // branches to reject source-side refs before lineage
4649                // emission (Q1 in plan-review: keep helper branch-
4650                // agnostic; double-collect cost is bounded since
4651                // BY SOURCE UPDATEs are small).
4652                if (isNotMatchedBySource) {
4653                    rejectBySourceSetRhsRefs(upd, providerWithStar,
4654                            aliasToTableQName, aliasToSubIdx, merge);
4655                }
4656                buildMergeUpdateAction(upd, targetQName, targetTable,
4657                        providerWithStar, seenTargetCols, lineage,
4658                        emittedEdgeKeys, aliasToTableQName,
4659                        aliasToSubIdx, targetAliases, merge);
4660            }
4661            if (ins != null) {
4662                buildMergeInsertAction(ins, targetQName, targetTable,
4663                        providerWithStar, seenTargetCols, lineage,
4664                        emittedEdgeKeys, aliasToTableQName,
4665                        aliasToSubIdx, targetAliases, merge);
4666            }
4667            // DELETE action: no per-column lineage (slice 81 contract).
4668        }
4669
4670        // 9) Build TargetRelation from accumulated target column spellings.
4671        for (String spelling : seenTargetCols.values()) {
4672            targetColumnNames.add(spelling);
4673        }
4674        targetRel = new TargetRelation(
4675                new RelationBinding(RelationKind.TABLE, targetQName),
4676                targetColumnNames);
4677
4678        // 9.5) Slice 98 — MSSQL MERGE OUTPUT projection. Reuses the
4679        // slice-85 buildReturningColumns walker with dmlKind="MERGE":
4680        // Pass 1.5's INSERT/DELETE pseudo-table mismatch check naturally
4681        // skips (MERGE is action-polymorphic — INSERTED and DELETED
4682        // may both legitimately appear). The walker handles $action via
4683        // a slice-98-specific short-circuit (derived OutputColumn, no
4684        // sources, no edges). mergeIdx = stmts.size() because the MERGE
4685        // StatementGraph is appended below; for USING-subquery shapes,
4686        // step 6 has already appended the extracted SELECT so the index
4687        // points at the upcoming MERGE position (slice-83 dynamic-index
4688        // pattern). Pass relations[] as fromSideRelations so unique
4689        // USING-alias matches resolve to the source qname (codex Q4).
4690        int mergeIdx = stmts.size();
4691        String mergeTargetAlias = effectiveAliasOf(targetTable);
4692        if (mergeTargetAlias == null || mergeTargetAlias.isEmpty()) {
4693            mergeTargetAlias = targetQName;
4694        }
4695        List<OutputColumn> returningCols = buildReturningColumns(
4696                /*ret=*/ null,
4697                /*out=*/ merge.getOutputClause(),
4698                "MERGE",
4699                targetQName,
4700                mergeTargetAlias,
4701                targetTable,
4702                /*fromSideRelations=*/ relations,
4703                // Slice 124 — MERGE OUTPUT USING-source lineage unification
4704                // (symmetric follow-up to slice 123, which lifted DELETE +
4705                // UPDATE). aliasToSubIdx is already the MERGE combined
4706                // alias→stmt-index map: it holds the USING alias for both the
4707                // USING-(SELECT) subquery branch (alias→subIdx) and the
4708                // USING-as-CTE branch (alias→cteIdx); both add a SUBQUERY-kind
4709                // relation to relations[], so returningSubquerySourceIndex
4710                // promotes a MERGE OUTPUT ref bound to the USING source to the
4711                // canonical STATEMENT_OUTPUT(mergeIdx, ret) →
4712                // STATEMENT_OUTPUT(subOrCteIdx, col) edge instead of the
4713                // fictitious TABLE_COLUMN(<usingAlias>, col). TABLE-kind USING
4714                // adds nothing to aliasToSubIdx, and INSERTED/DELETED/target
4715                // refs are excluded by the gate, so those paths keep their
4716                // slice-85/98 TABLE_COLUMN edge.
4717                /*fromSideAliasToStmtIndex=*/ aliasToSubIdx,
4718                providerWithStar,
4719                mergeIdx,
4720                lineage,
4721                merge);
4722
4723        // 10) Emit StatementGraph. joinColumnRefs[] = ON + WHEN-AND refs.
4724        // Slice 95: filterColumnRefs[] = per-WHEN action WHERE refs
4725        // (UPDATE WHERE, UPDATE...DELETE WHERE, INSERT WHERE).
4726        // Slice 98: returningColumns[] = MERGE OUTPUT projection.
4727        List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet);
4728        List<ColumnRef> filterRefs = new ArrayList<>(filterRefsSet);
4729        StatementGraph mergeStmt = new StatementGraph(
4730                /*name=*/ null,
4731                "MERGE",
4732                relations,
4733                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
4734                returningCols,
4735                filterRefs,
4736                joinRefs,
4737                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4738                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4739                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4740                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4741                /*distinct=*/ false,
4742                /*setOperator=*/ null,
4743                /*rowLimit=*/ null,
4744                targetRel);
4745        stmts.add(mergeStmt);
4746
4747        return new SemanticProgram(stmts, lineage);
4748    }
4749
4750    /**
4751     * Slice 95 — collect column refs from a per-WHEN action WHERE
4752     * predicate ({@code TMergeUpdateClause.updateWhereClause},
4753     * {@code TMergeUpdateClause.deleteWhereClause}, or
4754     * {@code TMergeInsertClause.insertWhereClause}) into the supplied
4755     * {@code filterRefsSet}.
4756     *
4757     * <p>Slice 116 — lifts the slice-95 blanket subquery reject by
4758     * routing uncorrelated predicate-subquery wrappers (IN-SELECT /
4759     * EXISTS / NOT EXISTS / scalar comparison / ANY-ALL-SOME) through
4760     * the slice-23+ JOIN-ON extraction pipeline refactored by slice
4761     * 110 to take a {@link PredicateClauseContext}. The
4762     * {@link PredicateClauseContext#MERGE_WHEN_WHERE} constant reuses
4763     * the {@code SELECT_WHERE_*} DiagnosticCode family (slice 113/114
4764     * precedent — a MERGE-action WHERE IS a SELECT WHERE in shape) so
4765     * the enum count stays at 279; only the {@code clauseLabel}
4766     * differs so diagnostic messages can identify the MERGE-action
4767     * host context.
4768     *
4769     * <p>Each extracted wrapper lands as its own
4770     * {@code <predicate_subquery_<i>>} StatementGraph BEFORE the
4771     * MERGE statement (so {@code mergeIdx = stmts.size()} below in
4772     * {@code buildMerge} already accounts for them — slice-83
4773     * dynamic-index pattern, slice 110/111 precedent). Remaining
4774     * non-subquery refs flow into {@code filterRefsSet} via
4775     * {@link #collectColumnRefsSkipping}. Window functions in
4776     * non-subquery subtrees still reject via
4777     * {@link #rejectWindowFunctionInScopeSkipping} (slice 95
4778     * window-function contract preserved).
4779     *
4780     * <p>The supplied {@code provider} must carry
4781     * {@code withCteContext(cteMap.keySet())} so the predicate body's
4782     * inner SELECT's {@code FROM cte} refs route through
4783     * {@link RelationKind#CTE} — without that decoration,
4784     * {@code emitLineageForStatement} would lose the
4785     * STATEMENT_OUTPUT &rarr; STATEMENT_OUTPUT edge to the CTE body
4786     * (slice 110 documented gap for UPDATE WHERE — same applies here).
4787     * {@code buildMerge} composes the decoration once before the
4788     * per-WHEN loop.
4789     *
4790     * <p>Null-safe: returns immediately when the WHERE expression is
4791     * absent (slice-94 default; most WHEN clauses have no action
4792     * WHERE).
4793     */
4794    private static void collectMergeActionWhere(TExpression expr,
4795                                                String label,
4796                                                NameBindingProvider provider,
4797                                                LinkedHashSet<ColumnRef> filterRefsSet,
4798                                                List<StatementGraph> stmts,
4799                                                List<LineageEdge> lineage,
4800                                                Map<String, Integer> cteMap,
4801                                                TMergeSqlStatement merge,
4802                                                EnclosingScope correlationScope) {
4803        if (expr == null) {
4804            return;
4805        }
4806        Set<TExpression> extractedRoots = Collections.<TExpression>emptySet();
4807        if (containsAnySubqueryExpression(expr)) {
4808            extractedRoots =
4809                    extractUncorrelatedPredicateSubqueriesFromClause(
4810                            expr, provider, stmts, lineage, cteMap,
4811                            PredicateClauseContext.MERGE_WHEN_WHERE,
4812                            correlationScope);
4813            rejectAnyRemainingSubqueriesFromClause(expr, extractedRoots,
4814                    PredicateClauseContext.MERGE_WHEN_WHERE);
4815        }
4816        rejectWindowFunctionInScopeSkipping(expr, label, extractedRoots);
4817        filterRefsSet.addAll(collectColumnRefsSkipping(expr, provider,
4818                extractedRoots));
4819    }
4820
4821    /**
4822     * Slice 97 — reject any source-aliased column ref in a
4823     * WHEN NOT MATCHED BY SOURCE branch. SQL Server forbids
4824     * source-side references in this branch because there is no
4825     * matching source row when the action fires.
4826     *
4827     * <p>A ref is "source-aliased" iff its
4828     * {@code relationAlias.toLowerCase(Locale.ROOT)} appears in
4829     * either alias map (TABLE-kind or SUBQUERY-kind USING source).
4830     * Refs whose alias is unknown to both maps are assumed to be
4831     * target-bound (slice-94 alias-filter convention) and are
4832     * left alone.
4833     *
4834     * <p>Walks all refs so a source ref nested inside an arbitrary
4835     * function call (e.g. {@code COALESCE(s.code, t.code)}) is
4836     * caught — {@code collectColumnRefs} descends through arbitrary
4837     * scalar expressions (codex round-1 Q3 confirmed YES).
4838     */
4839    private static void rejectSourceRefsForBySource(List<ColumnRef> refs,
4840                                                    Map<String, String> aliasToTableQName,
4841                                                    Map<String, Integer> aliasToSubIdx,
4842                                                    String label,
4843                                                    TMergeSqlStatement merge) {
4844        if (refs == null || refs.isEmpty()) {
4845            return;
4846        }
4847        for (ColumnRef r : refs) {
4848            String alias = r.getRelationAlias();
4849            if (alias == null || alias.isEmpty()) {
4850                continue;
4851            }
4852            String key = alias.toLowerCase(Locale.ROOT);
4853            if (aliasToTableQName.containsKey(key)
4854                    || aliasToSubIdx.containsKey(key)) {
4855                throw new SemanticIRBuildException(Diagnostic.error(
4856                        DiagnosticCode.MERGE_NOT_MATCHED_BY_SOURCE_REFERENCES_SOURCE,
4857                        label + " references USING source column '"
4858                                + r + "'; WHEN NOT MATCHED BY SOURCE "
4859                                + "branches must only reference target "
4860                                + "columns or constants.",
4861                        merge));
4862            }
4863        }
4864    }
4865
4866    /**
4867     * Slice 97 — pre-walk the SET RHS of every assignment in a BY SOURCE
4868     * UPDATE action and reject any source-side ref. Called before
4869     * {@link #buildMergeUpdateAction} so the existing slice-94 helper
4870     * remains BY-SOURCE-agnostic.
4871     *
4872     * <p>Skips assignments that aren't shaped as a simple
4873     * {@code assignment_t} expression; those defects are caught by
4874     * {@link #buildMergeUpdateAction} with
4875     * {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}.
4876     */
4877    private static void rejectBySourceSetRhsRefs(TMergeUpdateClause upd,
4878                                                 NameBindingProvider provider,
4879                                                 Map<String, String> aliasToTableQName,
4880                                                 Map<String, Integer> aliasToSubIdx,
4881                                                 TMergeSqlStatement merge) {
4882        TResultColumnList sets = upd.getUpdateColumnList();
4883        if (sets == null || sets.size() == 0) {
4884            return;
4885        }
4886        for (int i = 0; i < sets.size(); i++) {
4887            TResultColumn rc = sets.getResultColumn(i);
4888            TExpression assignment = (rc == null) ? null : rc.getExpr();
4889            if (assignment == null
4890                    || assignment.getExpressionType() != EExpressionType.assignment_t) {
4891                continue;
4892            }
4893            TExpression rhs = assignment.getRightOperand();
4894            if (rhs == null) {
4895                continue;
4896            }
4897            // Subquery RHS would short-circuit later with
4898            // UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED; ignore here.
4899            if (containsAnySubqueryExpression(rhs)) {
4900                continue;
4901            }
4902            List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider);
4903            rejectSourceRefsForBySource(rhsRefs, aliasToTableQName,
4904                    aliasToSubIdx,
4905                    "MERGE WHEN NOT MATCHED BY SOURCE UPDATE SET "
4906                            + "assignment #" + (i + 1) + " RHS",
4907                    merge);
4908        }
4909    }
4910
4911    /**
4912     * Slice 94 — process one WHEN MATCHED THEN UPDATE SET action.
4913     * Each {@code TResultColumn} carries an assignment_t TExpression
4914     * whose leftOperand is the SET LHS (target column reference) and
4915     * whose rightOperand is the value expression. We emit one
4916     * {@link LineageEdge} per (target col, RHS source ref) pair.
4917     */
4918    private static void buildMergeUpdateAction(TMergeUpdateClause upd,
4919                                               String targetQName,
4920                                               TTable targetTable,
4921                                               NameBindingProvider provider,
4922                                               Map<String, String> seenTargetCols,
4923                                               List<LineageEdge> lineage,
4924                                               Set<String> emittedEdgeKeys,
4925                                               Map<String, String> aliasToTableQName,
4926                                               Map<String, Integer> aliasToSubIdx,
4927                                               Set<String> targetAliases,
4928                                               TMergeSqlStatement merge) {
4929        TResultColumnList sets = upd.getUpdateColumnList();
4930        if (sets == null || sets.size() == 0) {
4931            return;
4932        }
4933        for (int i = 0; i < sets.size(); i++) {
4934            TResultColumn rc = sets.getResultColumn(i);
4935            TExpression assignment = (rc == null) ? null : rc.getExpr();
4936            if (assignment == null
4937                    || assignment.getExpressionType() != EExpressionType.assignment_t) {
4938                throw new SemanticIRBuildException(Diagnostic.error(
4939                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
4940                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
4941                                + " is not a simple column-value assignment_t",
4942                        merge));
4943            }
4944            TExpression lhs = assignment.getLeftOperand();
4945            TExpression rhs = assignment.getRightOperand();
4946            if (lhs == null || rhs == null) {
4947                throw new SemanticIRBuildException(Diagnostic.error(
4948                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
4949                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
4950                                + " is missing an operand",
4951                        merge));
4952            }
4953            if (lhs.getExpressionType() == EExpressionType.list_t) {
4954                throw new SemanticIRBuildException(Diagnostic.error(
4955                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
4956                        "MERGE WHEN MATCHED UPDATE SET tuple assignment "
4957                                + "'(a, b) = ...' is not supported",
4958                        merge));
4959            }
4960            if (lhs.getExpressionType() != EExpressionType.simple_object_name_t) {
4961                throw new SemanticIRBuildException(Diagnostic.error(
4962                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
4963                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
4964                                + " LHS is expressionType=" + lhs.getExpressionType()
4965                                + "; slice 94 admits simple column references only",
4966                        merge));
4967            }
4968            TObjectName targetCol = lhs.getObjectOperand();
4969            if (targetCol == null) {
4970                throw new SemanticIRBuildException(Diagnostic.error(
4971                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
4972                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
4973                                + " LHS has no TObjectName operand",
4974                        merge));
4975            }
4976            // Codex round-1 diff Q3 NO fix: SET LHS qualifier must be
4977            // either the target alias or the target qualified name.
4978            // A foreign qualifier (e.g. `s.name` on a SET LHS pointing
4979            // at source `s`) silently treated as a target column would
4980            // produce a wrong target column spelling.
4981            String rawSpelling = targetCol.toString();
4982            String colSpelling = validateAndStripSetLhsQualifier(
4983                    rawSpelling, targetTable, targetQName, merge);
4984            // Subquery / window on RHS — reuse existing codes per
4985            // plan v3 §B (codex round-1 Q2 NO fix).
4986            if (containsAnySubqueryExpression(rhs)) {
4987                throw new SemanticIRBuildException(Diagnostic.error(
4988                        DiagnosticCode.UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED,
4989                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
4990                                + " right-hand side contains a subquery; "
4991                                + "slice 94 admits scalar-only RHS expressions",
4992                        merge));
4993            }
4994            rejectWindowFunctionInScope(rhs, "MERGE WHEN MATCHED UPDATE SET RHS");
4995
4996            String lowerKey = colSpelling.toLowerCase(Locale.ROOT);
4997            if (!seenTargetCols.containsKey(lowerKey)) {
4998                seenTargetCols.put(lowerKey, colSpelling);
4999            }
5000            // Per-WHEN action lineage: TABLE_COLUMN(target,col) ← <RHS ref>
5001            List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider);
5002            for (ColumnRef src : rhsRefs) {
5003                emitMergeLineageEdge(targetQName, colSpelling, src,
5004                        lineage, emittedEdgeKeys, aliasToTableQName,
5005                        aliasToSubIdx, targetAliases);
5006            }
5007        }
5008    }
5009
5010    /**
5011     * Slice 94 — process one WHEN NOT MATCHED THEN INSERT (cols) VALUES (exprs)
5012     * action. Emits one {@link LineageEdge} per (insert col, source ref)
5013     * pair, plus arity validation between the explicit column list and
5014     * VALUES list. If the column list is omitted, we cannot derive
5015     * target column names — the slice rejects defensively.
5016     */
5017    private static void buildMergeInsertAction(TMergeInsertClause ins,
5018                                               String targetQName,
5019                                               TTable targetTable,
5020                                               NameBindingProvider provider,
5021                                               Map<String, String> seenTargetCols,
5022                                               List<LineageEdge> lineage,
5023                                               Set<String> emittedEdgeKeys,
5024                                               Map<String, String> aliasToTableQName,
5025                                               Map<String, Integer> aliasToSubIdx,
5026                                               Set<String> targetAliases,
5027                                               TMergeSqlStatement merge) {
5028        TResultColumnList values = ins.getValuelist();
5029        gudusoft.gsqlparser.nodes.TObjectNameList colList = ins.getColumnList();
5030        if (values == null || values.size() == 0) {
5031            throw new SemanticIRBuildException(Diagnostic.error(
5032                    DiagnosticCode.MERGE_INSERT_DEFAULT_VALUES_NOT_SUPPORTED,
5033                    "MERGE WHEN NOT MATCHED INSERT has no VALUES list "
5034                            + "(DEFAULT VALUES / row-type forms not supported)",
5035                    merge));
5036        }
5037        // If an explicit column list is present, validate arity.
5038        if (colList != null && colList.size() > 0) {
5039            if (colList.size() != values.size()) {
5040                throw new SemanticIRBuildException(Diagnostic.error(
5041                        DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH,
5042                        "MERGE WHEN NOT MATCHED INSERT column list has "
5043                                + colList.size() + " column(s) but VALUES "
5044                                + "list has " + values.size(),
5045                        merge));
5046            }
5047        }
5048        for (int i = 0; i < values.size(); i++) {
5049            TResultColumn rc = values.getResultColumn(i);
5050            TExpression rhs = (rc == null) ? null : rc.getExpr();
5051            if (rhs == null) {
5052                throw new SemanticIRBuildException(Diagnostic.error(
5053                        DiagnosticCode.MERGE_INSERT_DEFAULT_VALUES_NOT_SUPPORTED,
5054                        "MERGE WHEN NOT MATCHED INSERT VALUES item #"
5055                                + (i + 1) + " has no expression",
5056                        merge));
5057            }
5058            if (containsAnySubqueryExpression(rhs)) {
5059                throw new SemanticIRBuildException(Diagnostic.error(
5060                        DiagnosticCode.MERGE_INSERT_VALUES_HAS_SUBQUERY_NOT_SUPPORTED,
5061                        "MERGE WHEN NOT MATCHED INSERT VALUES item #"
5062                                + (i + 1) + " contains a subquery; slice 94 "
5063                                + "admits scalar-only VALUES expressions",
5064                        merge));
5065            }
5066            rejectWindowFunctionInScope(rhs, "MERGE INSERT VALUES");
5067
5068            // Target column name. If the explicit column list is
5069            // omitted, slice 94 does not synthesize positional target
5070            // column names (the catalog is required to map by
5071            // position; the current builder does not consume catalog
5072            // ordering). We still emit lineage edges from a synth
5073            // "__merge_insert_pos_<i>__" target column so source refs
5074            // are observable; users with an explicit column list get
5075            // the verbatim spelling.
5076            String colSpelling;
5077            if (colList != null && colList.size() > 0) {
5078                TObjectName col = colList.getObjectName(i);
5079                // INSERT column list is conventionally bare (no
5080                // qualifier), but Oracle / SQL Server allow
5081                // target-qualified spellings; strip them and reject
5082                // foreign qualifiers (codex round-1 diff Q3 NO fix).
5083                colSpelling = (col == null) ? ("__merge_insert_pos_" + i + "__")
5084                        : validateAndStripSetLhsQualifier(col.toString(),
5085                                targetTable, targetQName, merge);
5086            } else {
5087                colSpelling = "__merge_insert_pos_" + i + "__";
5088            }
5089            String lowerKey = colSpelling.toLowerCase(Locale.ROOT);
5090            if (!seenTargetCols.containsKey(lowerKey)) {
5091                seenTargetCols.put(lowerKey, colSpelling);
5092            }
5093            List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider);
5094            for (ColumnRef src : rhsRefs) {
5095                emitMergeLineageEdge(targetQName, colSpelling, src,
5096                        lineage, emittedEdgeKeys, aliasToTableQName,
5097                        aliasToSubIdx, targetAliases);
5098            }
5099        }
5100    }
5101
5102    /**
5103     * Slice 94 — emit a single MERGE per-WHEN action lineage edge:
5104     * {@code TABLE_COLUMN(targetQName, colSpelling) ← <src ref>}.
5105     * Deduplicates on a lower-case key so the same (target column,
5106     * source ref) pair appearing in multiple WHEN clauses produces
5107     * one edge (codex round-2 Q4 confirmed YES on this dedup
5108     * strategy).
5109     */
5110    private static void emitMergeLineageEdge(String targetQName,
5111                                             String colSpelling,
5112                                             ColumnRef src,
5113                                             List<LineageEdge> lineage,
5114                                             Set<String> emittedEdgeKeys,
5115                                             Map<String, String> aliasToTableQName,
5116                                             Map<String, Integer> aliasToSubIdx,
5117                                             Set<String> targetAliases) {
5118        if (src == null || colSpelling == null || colSpelling.isEmpty()) {
5119            return;
5120        }
5121        String srcAlias = src.getRelationAlias();
5122        String srcCol = src.getColumnName();
5123        if (srcCol == null || srcCol.isEmpty()) {
5124            return;
5125        }
5126        if (srcAlias == null) srcAlias = "";
5127        String aliasKey = srcAlias.toLowerCase(Locale.ROOT);
5128        // Codex round-1 diff Q1 BLOCKING fix: skip only if the alias
5129        // is a target alias, NOT if the alias resolves to a same-named
5130        // table. Self-merge (USING with same name as target) must
5131        // distinguish target from USING by alias identity, not by
5132        // resolved table name.
5133        if (targetAliases.contains(aliasKey)) {
5134            return;
5135        }
5136        // Map alias → LineageRef (TABLE_COLUMN or STATEMENT_OUTPUT).
5137        LineageRef toRef;
5138        if (aliasToSubIdx.containsKey(aliasKey)) {
5139            toRef = LineageRef.statementOutput(
5140                    aliasToSubIdx.get(aliasKey), srcCol);
5141        } else if (aliasToTableQName.containsKey(aliasKey)) {
5142            toRef = LineageRef.tableColumn(
5143                    aliasToTableQName.get(aliasKey), srcCol);
5144        } else {
5145            // Unknown alias — skip to avoid emitting a bogus edge.
5146            // The ref still surfaces on joinColumnRefs[] (ON / WHEN-AND).
5147            return;
5148        }
5149        // Codex round-1 diff Q2 NO fix: dedup on resolved LineageRef
5150        // identity (not raw alias), so `s.name` (alias) and
5151        // `managers.name` (qualified name) coming from the SAME
5152        // resolved source produce ONE edge. The key embeds the toRef's
5153        // canonical form: STATEMENT_OUTPUT(idx,col) or
5154        // TABLE_COLUMN(qname,col) — both are lower-cased.
5155        String resolvedKey;
5156        if (toRef.getKind() == LineageRef.Kind.STATEMENT_OUTPUT) {
5157            resolvedKey = "STMT_OUT::" + toRef.getStatementIndex() + "::"
5158                    + (toRef.getOutputName() == null ? "" : toRef.getOutputName());
5159        } else {
5160            resolvedKey = "TBL_COL::"
5161                    + (toRef.getQualifiedName() == null ? "" : toRef.getQualifiedName())
5162                    + "::"
5163                    + (toRef.getColumnName() == null ? "" : toRef.getColumnName());
5164        }
5165        String key = (targetQName + "::" + colSpelling + "::"
5166                + resolvedKey).toLowerCase(Locale.ROOT);
5167        if (emittedEdgeKeys.add(key)) {
5168            lineage.add(new LineageEdge(
5169                    LineageRef.tableColumn(targetQName, colSpelling),
5170                    toRef));
5171        }
5172    }
5173
5174    /**
5175     * Slice 94 — validate the SET LHS / INSERT column-list spelling
5176     * and strip a leading target qualifier. Codex round-1 diff Q3 NO
5177     * fix: previously the helper returned a foreign-qualified spelling
5178     * unchanged, which silently produced a wrong target column (e.g.
5179     * {@code "s.name"} would land in the target columns list verbatim
5180     * instead of being rejected).
5181     *
5182     * <p>Admit rules:
5183     * <ul>
5184     *   <li>Unqualified bare name: return unchanged.</li>
5185     *   <li>Qualified by target alias or qualified name: strip.</li>
5186     *   <li>Qualified by anything else: reject as
5187     *       {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}
5188     *       (the same code used for slice-80 UPDATE-LHS shape rejects,
5189     *       message text discriminates by mentioning the foreign
5190     *       qualifier).</li>
5191     * </ul>
5192     */
5193    private static String validateAndStripSetLhsQualifier(String spelling,
5194                                                          TTable targetTable,
5195                                                          String targetQName,
5196                                                          TMergeSqlStatement merge) {
5197        if (spelling == null) return spelling;
5198        int dot = spelling.indexOf('.');
5199        if (dot <= 0) return spelling;
5200        String qualifier = spelling.substring(0, dot);
5201        String bare = spelling.substring(dot + 1);
5202        String targetAlias = effectiveAliasOf(targetTable);
5203        if (targetAlias != null
5204                && qualifier.equalsIgnoreCase(targetAlias)) {
5205            return bare;
5206        }
5207        if (qualifier.equalsIgnoreCase(targetQName)) {
5208            return bare;
5209        }
5210        throw new SemanticIRBuildException(Diagnostic.error(
5211                DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
5212                "MERGE SET LHS / INSERT column qualifier '" + qualifier
5213                        + "' does not match the target table; slice 94 "
5214                        + "admits target-qualified or unqualified target "
5215                        + "column references only",
5216                merge));
5217    }
5218
5219    /**
5220     * Slice 84 — process one FROM-side source table for joined
5221     * {@link #buildDelete}. Mirrors slice-82 {@link #buildUpdateRelation}.
5222     * Applies the slice-84 reject contract for nested-join wrappers,
5223     * then appends a TABLE-kind {@link RelationSource} unless the
5224     * table is the target (reference-identity filter — clean IR
5225     * semantics: relations[] models read-side sources only). For
5226     * subquery sources (already extracted in step 4.7), publishes a
5227     * SUBQUERY-kind {@link RelationSource} so the inScope-enhanced
5228     * provider can route {@code sub.col} references.
5229     *
5230     * <p>Null-driver guard: probed PG
5231     * {@code DELETE FROM e USING (t1 JOIN t2 ON …)} returns
5232     * {@code refJoin[0].getTable() == null}. Silent skip mirrors
5233     * slice-82's null guard for the analogous UPDATE case;
5234     * documented as a known limitation (parenthesized JOIN-in-USING
5235     * is opaque to {@code relations[]} though WHERE refs still bind
5236     * via Resolver2).
5237     */
5238    private static void buildDeleteRelation(TTable t, TTable targetTable,
5239                                            List<RelationSource> relations,
5240                                            TDeleteSqlStatement delete,
5241                                            Map<String, Integer> cteNameToStatementIndex) {
5242        if (t == null) {
5243            return; // defensive — parenthesized JOIN-in-USING surfaces null
5244        }
5245        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
5246            // Slice 84 — admit FROM-side subqueries. The inner SELECT
5247            // has already been extracted as its own StatementGraph by
5248            // extractDeleteFromSubqueries. Publish a SUBQUERY-kind
5249            // RelationSource so the inScope-enhanced provider routes
5250            // `sub.col` references correctly. Mirrors slice-83
5251            // buildUpdateRelation's subquery branch.
5252            String subAlias = effectiveAliasOf(t);
5253            if (subAlias != null && !subAlias.isEmpty()) {
5254                relations.add(new RelationSource(subAlias,
5255                        new RelationBinding(RelationKind.SUBQUERY, subAlias)));
5256            }
5257            return;
5258        }
5259        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
5260            // Defensive: TTable wrapping a TJoin. Not reached by any
5261            // observed parser path on supported dialects (slice-82
5262            // precedent — parenthesized JOIN-in-USING surfaces a null
5263            // driver, not a join-typed TTable). Distinct DiagnosticCode
5264            // per slice-80's message-text-discrimination contract.
5265            throw new SemanticIRBuildException(Diagnostic.error(
5266                    DiagnosticCode.DELETE_FROM_NESTED_JOIN_NOT_SUPPORTED,
5267                    "DELETE FROM source is a nested join wrapper; "
5268                            + "slice 84 admits simple table / subquery "
5269                            + "FROM sources only",
5270                    delete));
5271        }
5272        // Reference-identity filter: target's own TTable instance is
5273        // excluded from relations[]. Different TTable instances with
5274        // the same qualified name (e.g. MSSQL `DELETE FROM t FROM t
5275        // spqh JOIN sp` where target identity A and FROM-driver
5276        // identity B share name "t") both stay — the catalog-miss
5277        // WARN walker's pass-1-target-then-pass-2-relations ordering
5278        // (slice 83) deduplicates by qualified name.
5279        if (t == targetTable) {
5280            return;
5281        }
5282        TObjectName tName = t.getTableName();
5283        if (tName == null) {
5284            return; // defensive
5285        }
5286        // Slice 106 — FROM-side CTE detection. When the FROM-side table
5287        // is an objectname-typed reference whose bare name matches a
5288        // declared CTE in this DELETE's outer WITH clause, emit a
5289        // SUBQUERY-kind RelationSource pointing at the CTE statement
5290        // (mirrors slice-105 buildUpdateRelation). The slice-77 catalog-
5291        // miss WARN walker filters to RelationKind.TABLE so CTE-bound
5292        // relations are naturally skipped, even when the catalog also
5293        // declares the same name (slice-105 §G / §X precedent).
5294        //
5295        // Explicit objectname guard (codex round-1 NICE Q3): subquery /
5296        // join table types are handled by the early returns above; this
5297        // guard documents the contract and makes the branch resilient
5298        // if a future TTable type is added.
5299        if (cteNameToStatementIndex != null
5300                && !cteNameToStatementIndex.isEmpty()
5301                && t.getTableType()
5302                        == gudusoft.gsqlparser.ETableSource.objectname) {
5303            String bareName = tName.toString();
5304            if (bareName != null && !bareName.isEmpty()) {
5305                String bareNameLower = bareName.toLowerCase(Locale.ROOT);
5306                if (cteNameToStatementIndex.containsKey(bareNameLower)) {
5307                    String cteAlias = effectiveAliasOf(t);
5308                    if (cteAlias == null || cteAlias.isEmpty()) {
5309                        cteAlias = bareName;
5310                    }
5311                    relations.add(new RelationSource(cteAlias,
5312                            new RelationBinding(RelationKind.SUBQUERY, cteAlias)));
5313                    return;
5314                }
5315            }
5316        }
5317        relations.add(new RelationSource(effectiveAliasOf(t),
5318                new RelationBinding(RelationKind.TABLE, tName.toString())));
5319    }
5320
5321    /**
5322     * Slice 84 — process one {@link TJoinItem} for joined
5323     * {@link #buildDelete}. Mirrors slice-82 {@link #buildUpdateJoinItem}.
5324     * Applies the slice-84 reject contract for USING / NATURAL /
5325     * subquery-in-ON, processes the right-side table via
5326     * {@link #buildDeleteRelation}, and collects ON-clause column
5327     * refs into {@code joinRefs} via the shared
5328     * {@link #collectColumnRefs} helper.
5329     */
5330    private static void buildDeleteJoinItem(TJoinItem item, TTable targetTable,
5331                                            NameBindingProvider provider,
5332                                            List<RelationSource> relations,
5333                                            java.util.LinkedHashSet<ColumnRef> joinRefs,
5334                                            TDeleteSqlStatement delete,
5335                                            Map<String, Integer> cteNameToStatementIndex) {
5336        if (item == null) return;
5337        if (item.getUsingColumns() != null && item.getUsingColumns().size() > 0) {
5338            throw new SemanticIRBuildException(Diagnostic.error(
5339                    DiagnosticCode.DELETE_FROM_JOIN_USING_NOT_SUPPORTED,
5340                    "DELETE FROM join uses USING(...); slice 84 admits "
5341                            + "JOIN ON / CROSS JOIN / comma-FROM only",
5342                    item));
5343        }
5344        if (isNaturalJoinType(item.getJoinType())) {
5345            throw new SemanticIRBuildException(Diagnostic.error(
5346                    DiagnosticCode.DELETE_FROM_JOIN_NATURAL_NOT_SUPPORTED,
5347                    "DELETE FROM uses NATURAL JOIN; slice 84 admits "
5348                            + "JOIN ON / CROSS JOIN / comma-FROM only",
5349                    item));
5350        }
5351        // Right-side table: apply the same source-shape rejects +
5352        // identity filter as the driver table. Slice 106 — threads
5353        // cteNameToStatementIndex so right-side CTE refs (MSSQL
5354        // `FROM target t JOIN cte ON …`) get SUBQUERY-kind emission.
5355        buildDeleteRelation(item.getTable(), targetTable, relations, delete,
5356                cteNameToStatementIndex);
5357        // ON-clause refs: subquery rejects with slice-84 code;
5358        // window function reuses CLAUSE_WINDOW_FUNCTION_LEAK via the
5359        // shared helper. CROSS JOIN has no ON; skip the walk entirely.
5360        TExpression onCond = item.getOnCondition();
5361        if (onCond == null) return;
5362        if (containsAnySubqueryExpression(onCond)) {
5363            throw new SemanticIRBuildException(Diagnostic.error(
5364                    DiagnosticCode.DELETE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED,
5365                    "DELETE FROM JOIN ON condition contains a subquery; "
5366                            + "slice 84 admits scalar predicates only",
5367                    item));
5368        }
5369        rejectWindowFunctionInScope(onCond, "DELETE FROM JOIN ON");
5370        joinRefs.addAll(collectColumnRefs(onCond, provider));
5371    }
5372
5373    /**
5374     * Slice 84 — extract every FROM-side subquery in
5375     * {@code delete.getReferenceJoins()} as its own
5376     * {@link StatementGraph} appended to {@code stmts} before the
5377     * DELETE itself. Walks both the driver TTable of each TJoin AND
5378     * each JoinItem's right table. Returns an alias → stmts-index
5379     * map so the consuming DELETE can (a) build its in-scope column
5380     * map via {@link #buildDeleteInScopeMap}, and (b) bind
5381     * {@code sub.col} references in WHERE / ON via the
5382     * inScope-enhanced provider.
5383     *
5384     * <p>Mirrors slice-83 {@link #extractUpdateFromSubqueries} but
5385     * walks {@code delete.getReferenceJoins()} instead of
5386     * {@code update.getJoins()}. Reuses the SELECT-side
5387     * {@link #processDirectSubqueryTable} verbatim, forwarding the
5388     * outer-WITH {@code cteNameToStatementIndex} +
5389     * {@code ctePublishedColumns} (slice 106) so a nested SELECT in
5390     * an extracted FROM-subquery body can resolve outer-CTE refs.
5391     * Pre-slice-106 the maps were always empty because slice 81
5392     * rejected top-level WITH on DELETE
5393     * ({@link DiagnosticCode#DELETE_CTE_NOT_SUPPORTED}).
5394     *
5395     * <p>No mutation-guard wrapper: buildDelete owns fresh local
5396     * lists and exceptions propagate cleanly to the caller.
5397     */
5398    private static Map<String, Integer> extractDeleteFromSubqueries(
5399            TDeleteSqlStatement delete,
5400            NameBindingProvider provider,
5401            List<StatementGraph> stmts,
5402            List<LineageEdge> lineage,
5403            Map<String, Integer> cteNameToStatementIndex,
5404            Map<String, List<String>> ctePublishedColumns) {
5405        Map<String, Integer> aliasToIndex = new HashMap<>();
5406        TJoinList refJoins = delete.getReferenceJoins();
5407        if (refJoins == null) return aliasToIndex;
5408        // Slice 106 — forward the outer-WITH CTE maps so a nested SELECT
5409        // inside an extracted FROM-subquery body can resolve outer-WITH
5410        // CTE references. Resolver2 wires CTEScope; the maps are
5411        // forwarded for parity with the SELECT / MERGE / UPDATE call
5412        // sites and so the §N test for
5413        // `USING (SELECT … FROM cte) sub` produces the expected
5414        // cross-stmt lineage edge to the CTE body.
5415        Map<String, Integer> cteMap = cteNameToStatementIndex == null
5416                ? Collections.<String, Integer>emptyMap()
5417                : cteNameToStatementIndex;
5418        Map<String, List<String>> ctePublished = ctePublishedColumns == null
5419                ? Collections.<String, List<String>>emptyMap()
5420                : ctePublishedColumns;
5421        for (int ji = 0; ji < refJoins.size(); ji++) {
5422            TJoin join = refJoins.getJoin(ji);
5423            // Driver table — may be a subquery (PG / SF / BQ / RS
5424            // `DELETE FROM t USING (SELECT …) sub` shape).
5425            processDirectSubqueryTable(join.getTable(), provider,
5426                    stmts, lineage, cteMap, ctePublished, aliasToIndex);
5427            TJoinItemList items = join.getJoinItems();
5428            if (items == null) continue;
5429            for (int i = 0; i < items.size(); i++) {
5430                TJoinItem item = items.getJoinItem(i);
5431                if (item == null) continue;
5432                // Right-side table of a JoinItem — may be a subquery
5433                // (MSSQL / PG `DELETE FROM t FROM x JOIN (SELECT …)
5434                // sub ON …` shape).
5435                processDirectSubqueryTable(item.getTable(), provider,
5436                        stmts, lineage, cteMap, ctePublished, aliasToIndex);
5437            }
5438        }
5439        return aliasToIndex;
5440    }
5441
5442    /**
5443     * Slice 84 — build an effective-alias-keyed in-scope map publishing
5444     * each extracted DELETE FROM-subquery's output column names.
5445     * Mirrors slice-83 {@link #buildUpdateInScopeMap} but walks
5446     * {@code delete.getReferenceJoins()}.
5447     *
5448     * <p>Base-table FROM-side relations do not need an entry: their
5449     * column resolution stays on the Resolver2 catalog path
5450     * (probed correct for PG / MSSQL DELETE — see slice-84 plan
5451     * §Codex Q4 + Q11).
5452     */
5453    private static Map<String, List<String>> buildDeleteInScopeMap(
5454            TDeleteSqlStatement delete,
5455            Map<String, Integer> subqueryAliasToIndex,
5456            List<StatementGraph> stmts,
5457            Map<String, Integer> cteNameToStatementIndex,
5458            Map<String, List<String>> ctePublishedColumns) {
5459        Map<String, List<String>> result = new HashMap<>();
5460        boolean haveSubq = subqueryAliasToIndex != null
5461                && !subqueryAliasToIndex.isEmpty();
5462        boolean haveCte = cteNameToStatementIndex != null
5463                && !cteNameToStatementIndex.isEmpty();
5464        if (!haveSubq && !haveCte) {
5465            return result;
5466        }
5467        TJoinList refJoins = delete.getReferenceJoins();
5468        if (refJoins == null) return result;
5469        for (int ji = 0; ji < refJoins.size(); ji++) {
5470            TJoin join = refJoins.getJoin(ji);
5471            addDeleteRelationToInScopeMap(join.getTable(),
5472                    subqueryAliasToIndex, stmts, result,
5473                    cteNameToStatementIndex, ctePublishedColumns);
5474            TJoinItemList items = join.getJoinItems();
5475            if (items == null) continue;
5476            for (int i = 0; i < items.size(); i++) {
5477                TJoinItem item = items.getJoinItem(i);
5478                if (item == null) continue;
5479                addDeleteRelationToInScopeMap(item.getTable(),
5480                        subqueryAliasToIndex, stmts, result,
5481                        cteNameToStatementIndex, ctePublishedColumns);
5482            }
5483        }
5484        return result;
5485    }
5486
5487    private static void addDeleteRelationToInScopeMap(TTable t,
5488            Map<String, Integer> subqueryAliasToIndex,
5489            List<StatementGraph> stmts,
5490            Map<String, List<String>> result,
5491            Map<String, Integer> cteNameToStatementIndex,
5492            Map<String, List<String>> ctePublishedColumns) {
5493        if (t == null) return;
5494        // Slice 106 — CTE-as-FROM-relation in-scope publication. When
5495        // the FROM-side table is an objectname-typed reference whose
5496        // bare name matches a declared outer CTE, publish the CTE's
5497        // own column names against the FROM-side effective alias so
5498        // WHERE / ON / RETURNING refs against the CTE alias bind
5499        // correctly. Mirrors slice-105 addUpdateRelationToInScopeMap.
5500        if (cteNameToStatementIndex != null
5501                && !cteNameToStatementIndex.isEmpty()
5502                && ctePublishedColumns != null
5503                && t.getTableType()
5504                        == gudusoft.gsqlparser.ETableSource.objectname) {
5505            TObjectName tName = t.getTableName();
5506            if (tName != null) {
5507                String bare = tName.toString();
5508                if (bare != null && !bare.isEmpty()) {
5509                    String bareLower = bare.toLowerCase(Locale.ROOT);
5510                    if (cteNameToStatementIndex.containsKey(bareLower)) {
5511                        String aliasKey = effectiveAliasLowerCaseOrNull(t);
5512                        if (aliasKey == null) aliasKey = bareLower;
5513                        List<String> cols = ctePublishedColumns.get(bareLower);
5514                        if (cols != null) {
5515                            result.put(aliasKey, cols);
5516                        }
5517                        return;
5518                    }
5519                }
5520            }
5521        }
5522        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) {
5523            return;
5524        }
5525        if (subqueryAliasToIndex == null) {
5526            return;
5527        }
5528        String key = effectiveAliasLowerCaseOrNull(t);
5529        if (key == null) return;
5530        Integer idx = subqueryAliasToIndex.get(key);
5531        if (idx == null) return;
5532        result.put(key, outputColumnNames(stmts.get(idx)));
5533    }
5534
5535    /**
5536     * Slice 92 — returns {@code true} when the MySQL DELETE statement is a
5537     * self-reference single-target form ({@code DELETE T1 FROM T1 [WHERE …]})
5538     * that is semantically equivalent to {@code DELETE FROM T1 [WHERE …]}.
5539     *
5540     * <p>The check requires ALL of the following (codex plan-review Q1+Q5
5541     * BLOCKING fix — checking only {@code joins[0]} is insufficient because
5542     * {@code DELETE T1 FROM T2} also has {@code joins.size==1} and
5543     * {@code joins[0].table=="T1"==targetQName}, yet the FROM clause points
5544     * to a different table):
5545     * <ol>
5546     *   <li>{@code joins.size == 1} — exactly one MySQL target list entry.</li>
5547     *   <li>{@code getReferenceJoins().size() == 1} — exactly one FROM clause
5548     *       table.</li>
5549     *   <li>{@code joins[0]} has no JoinItems — must be a plain table, not a
5550     *       JOIN chain.</li>
5551     *   <li>{@code refJoins[0]} has no JoinItems — same constraint.</li>
5552     *   <li>{@code joins[0].table.name.toLowerCase() == targetQName.toLowerCase()}.
5553     *   </li>
5554     *   <li>{@code refJoins[0].table.name.toLowerCase() == targetQName.toLowerCase()}.
5555     *   </li>
5556     * </ol>
5557     */
5558    private static boolean isMysqlSelfReferenceDelete(
5559            TDeleteSqlStatement delete, String targetQName) {
5560        if (delete.joins == null || delete.joins.size() != 1) return false;
5561        TJoinList ref = delete.getReferenceJoins();
5562        if (ref == null || ref.size() != 1) return false;
5563        TJoin join0 = delete.joins.getJoin(0);
5564        if (join0.getJoinItems() != null && join0.getJoinItems().size() > 0) {
5565            return false;
5566        }
5567        TJoin ref0 = ref.getJoin(0);
5568        if (ref0.getJoinItems() != null && ref0.getJoinItems().size() > 0) {
5569            return false;
5570        }
5571        TTable joinTable = join0.getTable();
5572        if (joinTable == null || joinTable.getTableName() == null) return false;
5573        TTable refTable = ref0.getTable();
5574        if (refTable == null || refTable.getTableName() == null) return false;
5575        String lowerTarget = targetQName.toLowerCase(java.util.Locale.ROOT);
5576        String joinName = joinTable.getTableName().toString()
5577                .toLowerCase(java.util.Locale.ROOT);
5578        String refName = refTable.getTableName().toString()
5579                .toLowerCase(java.util.Locale.ROOT);
5580
5581        // Codex diff-review P1 fix: MySQL allows the alias in the DELETE
5582        // target list instead of the table name:
5583        //   DELETE t FROM T1 AS t WHERE t.id = 1
5584        // In this form joins[0].table.name = "t" (the alias used in the
5585        // delete-list) while targetQName = "T1" (from getTargetTable()
5586        // which the parser resolves to the real table). Accept the target
5587        // table's alias as a valid joins[0] match alongside the table name.
5588        String targetAlias = null;
5589        if (delete.getTargetTable() != null
5590                && delete.getTargetTable().getAliasClause() != null
5591                && delete.getTargetTable().getAliasClause().getAliasName() != null) {
5592            String a = delete.getTargetTable().getAliasClause()
5593                    .getAliasName().toString();
5594            if (a != null && !a.isEmpty()) {
5595                targetAlias = a.toLowerCase(java.util.Locale.ROOT);
5596            }
5597        }
5598        boolean joinMatchesTarget = joinName.equals(lowerTarget)
5599                || (targetAlias != null && joinName.equals(targetAlias));
5600        // refJoins[0].table must always be the real table name (= targetQName).
5601        return joinMatchesTarget && refName.equals(lowerTarget);
5602    }
5603
5604    /**
5605     * Per-result-column metadata about an extracted scalar-subquery
5606     * projection (slice 11). {@link #statementIndex} points to the
5607     * inner body statement; {@link #innerOutputName} is the inner
5608     * SELECT's single projected output name (used to wire the
5609     * STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edge).
5610     */
5611    private static final class ScalarInfo {
5612        final int statementIndex;
5613        final String innerOutputName;
5614        ScalarInfo(int statementIndex, String innerOutputName) {
5615            this.statementIndex = statementIndex;
5616            this.innerOutputName = innerOutputName;
5617        }
5618    }
5619
5620    /**
5621     * Walk the consuming SELECT's FROM list. For every {@link TTable} of
5622     * type {@link gudusoft.gsqlparser.ETableSource#subquery}, recursively
5623     * build the inner statement, append it to {@code stmts}, emit its own
5624     * lineage edges, and record alias→statementIndex. The returned map is
5625     * scoped to this single consuming statement so duplicate aliases
5626     * across different scopes do not collide.
5627     *
5628     * <p>Slice 17: extraction now walks BOTH sides of every JOIN
5629     * ({@code TJoin.getTable()} for the left, {@code joinItems[i].getTable()}
5630     * for each right) and recurses into nested FROM-subquery bodies. Each
5631     * recursive level pre-extracts its own children before calling
5632     * {@code buildSelectStatement}, preserving the
5633     * {@code BodyIndexes}-required ordering (innermost body before
5634     * its consumer). FROM-subquery bodies still recurse with
5635     * {@code allowScalarProjectionSubqueries=false} (slice-15 invariant
5636     * pinned by {@code Slice15Test.scalarProjectionInsideFromSubqueryBodyStillRejected}).
5637     *
5638     * <p>Slice 18 lifts CTE bodies (the non-set-op CTE-body branch in
5639     * {@link #build} now invokes this extractor with
5640     * {@code allowFromSubqueries=true}). Still rejected: subqueries with
5641     * no alias, FROM-subqueries inside a scalar body / set-op branch /
5642     * set-op CTE body (each enforced by the caller's
5643     * {@code allowFromSubqueries=false}), and predicate subqueries inside
5644     * the FROM-subquery body's WHERE / JOIN ON / GROUP BY (slice-17
5645     * helper {@link #rejectSubqueriesInFromSubqueryBodyClauses}).
5646     */
5647    private static Map<String, Integer> extractFromSubqueriesAsStatements(
5648            TSelectSqlStatement consumer,
5649            NameBindingProvider consumerProvider,
5650            List<StatementGraph> stmts,
5651            List<LineageEdge> lineage,
5652            Map<String, Integer> cteNameToStatementIndex,
5653            Map<String, List<String>> ctePublishedColumns) {
5654        Map<String, Integer> aliasToIndex = new HashMap<>();
5655        if (consumer.joins == null) return aliasToIndex;
5656        // Slice 17 mutation-free preflight: walk the entire direct
5657        // FROM/JOIN list once and reject before any mutation of
5658        // stmts/lineage. Catches comma-FROM, anonymous subqueries,
5659        // unsupported join shapes, and ALL same-level alias collisions
5660        // (base AND subquery, since rejectDuplicateAliases inside
5661        // buildRelations only catches them later, after this level's
5662        // subquery body has already landed in stmts).
5663        preflightDirectFromList(consumer);
5664
5665        // Slice 17: walk both sides of every join and process each
5666        // direct subquery via the same helper so left/right can't drift.
5667        for (TJoin join : consumer.joins) {
5668            processDirectSubqueryTable(join.getTable(),
5669                    consumerProvider, stmts, lineage,
5670                    cteNameToStatementIndex, ctePublishedColumns, aliasToIndex);
5671            TJoinItemList items = join.getJoinItems();
5672            if (items == null) continue;
5673            for (int i = 0; i < items.size(); i++) {
5674                TJoinItem item = items.getJoinItem(i);
5675                if (item == null) continue;
5676                processDirectSubqueryTable(item.getTable(),
5677                        consumerProvider, stmts, lineage,
5678                        cteNameToStatementIndex, ctePublishedColumns, aliasToIndex);
5679            }
5680        }
5681        return aliasToIndex;
5682    }
5683
5684    /**
5685     * Slice 17 mutation-free preflight for the consumer's direct
5686     * FROM/JOIN list. Validates structural invariants BEFORE any
5687     * subquery body is appended to {@code stmts} so a deferred
5688     * failure (e.g. on the second of two siblings) doesn't strand
5689     * earlier-sibling output in the program.
5690     *
5691     * <p>Slice 62 (codex plan-review round 1): the comma-FROM
5692     * reject was removed here. The preflight runs only for
5693     * {@code allowFromSubqueries=true} paths (outer SELECT, CTE
5694     * body, FROM-subquery body recursion) — exactly the paths
5695     * that admit comma-FROM under slice 62. Synthetic body
5696     * contexts (scalar / set-op-branch / set-op-CTE / predicate)
5697     * do not run this preflight; they reach the gated reject in
5698     * {@link #buildRelations} (and predicate bodies hit the
5699     * earlier slice-62 reject inside
5700     * {@link #preflightExistsInnerShape}).
5701     */
5702    private static void preflightDirectFromList(TSelectSqlStatement consumer) {
5703        if (consumer.joins == null) return;
5704        Set<String> seenSubqueryAliases = new HashSet<>();
5705        Set<String> seenAllAliases = new HashSet<>();
5706        for (TJoin join : consumer.joins) {
5707            preflightOneTable(join.getTable(), seenSubqueryAliases, seenAllAliases);
5708            TJoinItemList items = join.getJoinItems();
5709            if (items == null) continue;
5710            for (int i = 0; i < items.size(); i++) {
5711                TJoinItem item = items.getJoinItem(i);
5712                if (item == null) continue;
5713                rejectUnsupportedJoinShape(item);
5714                preflightOneTable(item.getTable(), seenSubqueryAliases, seenAllAliases);
5715            }
5716        }
5717    }
5718
5719    /**
5720     * Slice 17: validate one direct FROM/JOIN-list TTable in the
5721     * mutation-free preflight. Effective alias is the SQL-written alias
5722     * if present, else the slice-74 synthetic alias for unaliased
5723     * FROM-subqueries (position-keyed), else the table name (matches
5724     * {@link #buildRelation}).
5725     *
5726     * <p>Slice 74: removed the {@code FROM_SUBQUERY_ALIAS_REQUIRED} reject
5727     * for anonymous subqueries; the slot is now filled by
5728     * {@link FromSubqueryNaming#synthAliasFor}. Two unaliased subqueries
5729     * at the same source location are theoretically impossible (the
5730     * parser would have to emit the same start token for both), but if
5731     * it ever happens the {@code DUPLICATE_FROM_SUBQUERY_ALIAS} branch
5732     * below catches it the same way as a literal user-written duplicate.
5733     *
5734     * <p>Still rejects: duplicate subquery aliases (whether user-written
5735     * or synthetic by collision), and any cross-kind alias collision
5736     * (base alias colliding with a subquery alias).
5737     */
5738    private static void preflightOneTable(TTable t,
5739                                          Set<String> seenSubqueryAliases,
5740                                          Set<String> seenAllAliases) {
5741        if (t == null) return;
5742        boolean isSub = t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery;
5743        String effective = effectiveAliasOf(t);
5744        if (effective == null || effective.isEmpty()) return;
5745        String lower = effective.toLowerCase(Locale.ROOT);
5746        if (isSub && !seenSubqueryAliases.add(lower)) {
5747            throw new SemanticIRBuildException(
5748                    Diagnostic.error(DiagnosticCode.DUPLICATE_FROM_SUBQUERY_ALIAS,
5749                    "duplicate FROM-clause subquery alias '" + effective + "'", (TParseTreeNode) null));
5750        }
5751        if (!seenAllAliases.add(lower)) {
5752            throw new SemanticIRBuildException(
5753                    Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS,
5754                    "duplicate relation alias '" + effective
5755                            + "' is not supported (would make ColumnRef ambiguous)", (TParseTreeNode) null));
5756        }
5757    }
5758
5759    /**
5760     * Slice 17: extract one direct subquery TTable as its own
5761     * StatementGraph. Recurses into the inner SELECT first
5762     * (innermost body lands in {@code stmts} BEFORE its consumer, as
5763     * {@code BodyIndexes} requires). Skips non-subquery tables (base
5764     * relations are bound later by {@code buildRelations}).
5765     */
5766    private static void processDirectSubqueryTable(
5767            TTable t,
5768            NameBindingProvider consumerProvider,
5769            List<StatementGraph> stmts,
5770            List<LineageEdge> lineage,
5771            Map<String, Integer> cteNameToStatementIndex,
5772            Map<String, List<String>> ctePublishedColumns,
5773            Map<String, Integer> aliasToIndex) {
5774        if (t == null) return;
5775        // Slice 136: a PIVOT / UNPIVOT source can itself be a FROM-subquery
5776        // (`FROM (SELECT ...) [alias] PIVOT(...)`). Unwrap the pivoted_table to
5777        // its underlying source relation so the inner SELECT is extracted and
5778        // registered HERE — that lands the source-subquery alias in the
5779        // pre-pass's aliasToIndex (== build()'s outerSubqueryAliasToIndex)
5780        // BEFORE emitLineageForStatement runs, so the pivot→subquery cross-stmt
5781        // edges resolve. A base-table pivot source falls through the
5782        // non-subquery early-return below unchanged.
5783        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
5784                && t.getPivotedTable() != null
5785                && !t.getPivotedTable().getRelations().isEmpty()) {
5786            t = t.getPivotedTable().getRelations().get(0);
5787            if (t == null) return;
5788        }
5789        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) return;
5790        // Alias presence/uniqueness already validated by the preflight.
5791        // Slice 74: anonymous (unaliased) subqueries get a synth name
5792        // from FromSubqueryNaming via effectiveAliasOf so the alias used
5793        // for the aliasToIndex map and inner-stmt name is non-null.
5794        String alias = effectiveAliasOf(t);
5795        String aliasLower = alias.toLowerCase(Locale.ROOT);
5796        TSelectSqlStatement inner = t.getSubquery();
5797        if (inner == null) {
5798            throw new SemanticIRBuildException(
5799                    Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_NO_INNER_SELECT,
5800                    "FROM-clause subquery '" + alias + "' has no inner SELECT", (TParseTreeNode) null));
5801        }
5802        // Slice 17 leak guard: predicate subqueries inside the
5803        // FROM-subquery body's WHERE / JOIN ON / GROUP BY would
5804        // otherwise slip past `allowScalarProjectionSubqueries=false`
5805        // (which only guards buildOutputColumns) and leak inner refs
5806        // into the body's filter/join/group ref lists.
5807        rejectSubqueriesInFromSubqueryBodyClauses(inner, alias);
5808        // Recurse into the inner's own FROM-subqueries first so each
5809        // deeper body lands in stmts BEFORE the body that consumes it.
5810        // The recursive call uses `consumerProvider` because the inner
5811        // sees the same CTE-name set as the outer (CTEs are visible
5812        // through FROM-subquery bodies — pinned by
5813        // Slice5Test.cteVisibleInsideFromSubquery).
5814        // Slice 60: thread ctePublishedColumns down unchanged. The
5815        // inner's siblings get registered into innerSubAliasToIndex
5816        // here; below we build the per-level innerInScope BEFORE
5817        // calling buildSelectStatement.
5818        Map<String, Integer> innerSubAliasToIndex =
5819                extractFromSubqueriesAsStatements(inner, consumerProvider,
5820                        stmts, lineage, cteNameToStatementIndex,
5821                        ctePublishedColumns);
5822        // Slice 60 (codex diff-review): build the inner FROM-subquery
5823        // body's effective-alias-keyed in-scope map by walking the
5824        // inner SELECT's FROM list. Sibling isolation is preserved
5825        // because `innerSubAliasToIndex` contains ONLY this body's
5826        // own children — ancestor siblings are never visited by the
5827        // walk because they're not in the inner's FROM list.
5828        Map<String, List<String>> innerInScope = buildEffectiveAliasInScopeMap(
5829                inner, consumerProvider, ctePublishedColumns,
5830                innerSubAliasToIndex, stmts);
5831        NameBindingProvider innerProviderWithStar = consumerProvider
5832                .withInScopeRelationColumns(innerInScope);
5833        // Slice 120 — switch from the 7-arg buildSelectStatement to the
5834        // 14-arg buildSelectStatementImpl so the FROM-subquery body's
5835        // WHERE clause can extract uncorrelated predicate subqueries
5836        // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
5837        // ANY-ALL-SOME) as their own statements (mirrors the slice-114
5838        // CTE-body lift). JOIN-ON predicate subqueries stay rejected (the
5839        // two flags are independent per the slice-113 split) — the
5840        // slice-17 leak guard rejectSubqueriesInFromSubqueryBodyClauses
5841        // above still fires for the body's JOIN-ON / GROUP-BY clauses.
5842        // allowFromSubqueries=true so its buildRelations accepts
5843        // already-extracted subquery aliases; allowScalarProjectionSubqueries
5844        // =false (slice-15 invariant). The snapshot/rollback wrapper
5845        // mirrors the slice-114 CTE-body call site: if the build appends
5846        // predicate bodies and then a later reject fires, stmts/lineage
5847        // truncate back to the pre-call boundary so a partial extraction
5848        // does not leak into the program. processDirectSubqueryTable is
5849        // shared by the SELECT / UPDATE (slice 83) / DELETE (slice 84)
5850        // FROM-subquery extractors, so this single site lifts all three.
5851        int fromBodyStmtsSnapshot = stmts.size();
5852        int fromBodyLineageSnapshot = lineage.size();
5853        StatementGraph innerStmt;
5854        try {
5855            if (isPivotSelect(inner)) {
5856                // Slice 138: a nested PIVOT / UNPIVOT in a FROM-subquery body
5857                // (`FROM (SELECT ... FROM base PIVOT(...)) t`). The outer-SELECT
5858                // pivot router in buildSelectStatementImpl is gated to
5859                // `name == null`, so a pivot body extracted here (name == alias)
5860                // would otherwise fall to the normal path and reject with
5861                // TABLE_BINDING_UNRESOLVED ("null(piviot_table)"). Route it to
5862                // buildPivotSelect with the subquery alias as the statement name
5863                // so the extracted statement becomes a proper pivot StatementGraph;
5864                // the outer query then references it as a SUBQUERY-kind relation
5865                // and the slice-136 emitLineageForStatement call below wires the
5866                // pivot output → source edges (base-table TABLE_COLUMN, or — when
5867                // the pivot's own source is a subquery — STATEMENT_OUTPUT via the
5868                // innerSubAliasToIndex already populated by the recursion above).
5869                // PIVOT extra-clause / chained / set-op-branch nesting stay
5870                // deferred via buildPivotSelect's existing rejects.
5871                innerStmt = buildPivotSelect(inner, innerProviderWithStar, alias);
5872            } else {
5873                innerStmt = buildSelectStatementImpl(inner, innerProviderWithStar, alias,
5874                    /*hasOuterCteListAlreadyProcessed=*/ false,
5875                    /*allowFromSubqueries=*/ true,
5876                    /*allowScalarProjectionSubqueries=*/ false,
5877                    /*allowWindowProjection=*/ true,
5878                    /*allowJoinOnPredicateSubqueries=*/ false,
5879                    /*stmtsForExtraction=*/ stmts,
5880                    /*lineageForExtraction=*/ lineage,
5881                    /*cteMapForExtraction=*/ cteNameToStatementIndex,
5882                    /*isPredicateBody=*/ false,
5883                    /*whereClauseContext=*/ PredicateClauseContext.FROM_SUBQUERY_BODY_WHERE,
5884                    /*allowWherePredicateSubqueries=*/ true);
5885            }
5886        } catch (RuntimeException ex) {
5887            while (stmts.size() > fromBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
5888            while (lineage.size() > fromBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
5889            throw ex;
5890        }
5891        int idx = stmts.size();
5892        stmts.add(innerStmt);
5893        aliasToIndex.put(aliasLower, idx);
5894        // Emit lineage with the inner's own subquery alias map (so
5895        // STATEMENT_OUTPUT → STATEMENT_OUTPUT edges target the inner's
5896        // children, not the outer's). Scalar map stays empty because
5897        // FROM-subquery bodies still reject scalar projections
5898        // (slice-15 invariant).
5899        emitLineageForStatement(innerStmt, idx, lineage,
5900                cteNameToStatementIndex,
5901                innerSubAliasToIndex,
5902                Collections.<Integer, ScalarInfo>emptyMap());
5903    }
5904
5905    /**
5906     * Slice 17 leak guard: reject subqueries inside a FROM-subquery
5907     * body's JOIN ON / GROUP BY clauses. Mirrors
5908     * {@link #rejectSubqueriesInScalarBodyClauses} (slice 11) — the
5909     * {@code allowScalarProjectionSubqueries=false} flag only guards
5910     * {@code buildOutputColumns}, so without this helper a SQL like
5911     * {@code SELECT id FROM (SELECT id FROM e JOIN d ON e.x = d.x
5912     * AND EXISTS (...)) sub} would leak the EXISTS subquery's refs into
5913     * the body's join column refs via {@code collectColumnRefs}.
5914     * HAVING / ORDER BY subqueries are caught by the slice-9 / 10
5915     * deep-scan rejecters inside {@code buildSelectStatementImpl}.
5916     *
5917     * <p>Slice 120 — the WHERE branch was removed: uncorrelated WHERE-side
5918     * predicate subqueries in a FROM-subquery body are now extracted as
5919     * their own statements by {@code buildSelectStatementImpl} via
5920     * {@link PredicateClauseContext#FROM_SUBQUERY_BODY_WHERE} (see
5921     * {@link #processDirectSubqueryTable}). {@code FROM_SUBQUERY_INNER_SUBQUERY_IN_WHERE}
5922     * stays declared-but-unreached for public-API stability (slice
5923     * 71/72/82/86/95/101/.../114 retain-for-documentation precedent).
5924     */
5925    private static void rejectSubqueriesInFromSubqueryBodyClauses(
5926            TSelectSqlStatement inner, String fromAlias) {
5927        if (inner.joins != null) {
5928            for (TJoin join : inner.joins) {
5929                TJoinItemList items = join.getJoinItems();
5930                if (items == null) continue;
5931                for (int i = 0; i < items.size(); i++) {
5932                    TJoinItem item = items.getJoinItem(i);
5933                    TExpression onCond = item == null ? null : item.getOnCondition();
5934                    if (onCond != null && containsAnySubqueryExpression(onCond)) {
5935                        throw new SemanticIRBuildException(
5936                                Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_INNER_SUBQUERY_IN_JOIN_ON,
5937                                "FROM-clause subquery '" + fromAlias
5938                                        + "' has a subquery in a JOIN ON clause; not supported yet "
5939                                        + "(would leak inner refs)", (TParseTreeNode) null));
5940                    }
5941                }
5942            }
5943        }
5944        TGroupBy groupBy = inner.getGroupByClause();
5945        if (groupBy != null) {
5946            TGroupByItemList items = groupBy.getItems();
5947            if (items != null && containsAnySubquery(items)) {
5948                throw new SemanticIRBuildException(
5949                        Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_INNER_SUBQUERY_IN_GROUP_BY,
5950                        "FROM-clause subquery '" + fromAlias
5951                                + "' has a subquery in a GROUP BY clause; not supported yet "
5952                                + "(would leak inner refs)", (TParseTreeNode) null));
5953            }
5954        }
5955    }
5956
5957    /**
5958     * Walk the consuming SELECT's result-column list. For every
5959     * top-level {@link EExpressionType#subquery_t} projection, build
5960     * the inner SELECT as its own {@link StatementGraph} (mirroring
5961     * slice 5 FROM-subquery extraction), append it to {@code stmts},
5962     * emit its own lineage edges, and record
5963     * {@code resultColumnOrdinal → ScalarInfo} so the consumer's
5964     * {@code emitLineageForStatement} can wire the
5965     * STATEMENT_OUTPUT → STATEMENT_OUTPUT edge.
5966     *
5967     * <p>Slice 11 disallows: scalar subqueries with no outer alias,
5968     * inner SELECTs that project more than one column, inner columns
5969     * with no alias and no direct column name, scalar subqueries
5970     * whose inner WHERE/JOIN/GROUP BY contains a subquery (predicate
5971     * leak guard), correlated scalar subqueries (inner refs that
5972     * resolve to outer aliases), and nested scalar subqueries.
5973     *
5974     * <p>Slice 14 lifted correlated TABLE-bound; slice 15 lifted CTE-
5975     * and SUBQUERY-bound. Slice 20 lifts <i>nested</i> scalar
5976     * projections inside a scalar body when the
5977     * {@code allowRecursiveScalarSubqueryExtraction} flag is true (passed
5978     * by the outer-build and CTE-body call sites). Set-op-branch call
5979     * sites pass false to keep the slice-12 / slice-16 boundary that
5980     * branch scalar bodies must not host another scalar projection.
5981     *
5982     * <p>Slice 20 wraps the body in a snapshot/rollback so a deeper
5983     * level's failure does not leak appended scalar-body statements at
5984     * shallower levels into {@code stmts}/{@code lineage}. The wrapper
5985     * mirrors slice-16's {@code buildSetOpProgram} and slice-17/18's
5986     * extract wrappers (§14.18 process lesson #21).
5987     */
5988    private static Map<Integer, ScalarInfo> extractScalarSubqueriesAsStatements(
5989            TSelectSqlStatement consumer,
5990            NameBindingProvider consumerProvider,
5991            List<StatementGraph> stmts,
5992            List<LineageEdge> lineage,
5993            Map<String, Integer> cteNameToStatementIndex,
5994            EnclosingScope enclosingScope,
5995            boolean allowRecursiveScalarSubqueryExtraction) {
5996        // Slice 20: SET-OP-WIDE-style transactional rollback. A failure
5997        // anywhere inside the loop (or inside a recursive call) truncates
5998        // both lists back to the pre-extraction size. The wrapper closes
5999        // the class of "mutation-free check fires after partial mutation"
6000        // (codex round-3..5 finding on slice 16); the recursive scalar
6001        // extraction surfaced it here.
6002        int stmtsSnapshot = stmts.size();
6003        int lineageSnapshot = lineage.size();
6004        try {
6005            return extractScalarSubqueriesAsStatementsInternal(consumer,
6006                    consumerProvider, stmts, lineage,
6007                    cteNameToStatementIndex, enclosingScope,
6008                    allowRecursiveScalarSubqueryExtraction);
6009        } catch (RuntimeException ex) {
6010            while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1);
6011            while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1);
6012            throw ex;
6013        }
6014    }
6015
6016    /**
6017     * Internal body of {@link #extractScalarSubqueriesAsStatements}.
6018     * Wrapped with snapshot/rollback by the public entry point; do not
6019     * call directly from non-wrapper sites.
6020     */
6021    private static Map<Integer, ScalarInfo> extractScalarSubqueriesAsStatementsInternal(
6022            TSelectSqlStatement consumer,
6023            NameBindingProvider consumerProvider,
6024            List<StatementGraph> stmts,
6025            List<LineageEdge> lineage,
6026            Map<String, Integer> cteNameToStatementIndex,
6027            EnclosingScope enclosingScope,
6028            boolean allowRecursiveScalarSubqueryExtraction) {
6029        Map<Integer, ScalarInfo> ordinalToInfo = new HashMap<>();
6030        TResultColumnList rcl = consumer.getResultColumnList();
6031        if (rcl == null || rcl.size() == 0) return ordinalToInfo;
6032
6033        // Reject duplicate output aliases when a scalar projection is
6034        // present (codex impl-review round-1 SHOULD 1). Lineage refs are
6035        // keyed by (statementIndex, outputName); two outputs sharing
6036        // the same name would collapse their lineage chains and
6037        // silently merge the scalar dependency with another column's
6038        // dependency. The slice-11 boundary is the cleanest place to
6039        // enforce this since the issue is most acute when a scalar
6040        // body's STATEMENT_OUTPUT → STATEMENT_OUTPUT edge is in play.
6041        boolean hasScalar = false;
6042        for (int i = 0; i < rcl.size(); i++) {
6043            TResultColumn rc = rcl.getResultColumn(i);
6044            if (rc != null && rc.getExpr() != null
6045                    && rc.getExpr().getExpressionType() == EExpressionType.subquery_t) {
6046                hasScalar = true;
6047                break;
6048            }
6049        }
6050        if (hasScalar) {
6051            Set<String> seenOutputNames = new HashSet<>();
6052            for (int i = 0; i < rcl.size(); i++) {
6053                TResultColumn rc = rcl.getResultColumn(i);
6054                if (rc == null) continue;
6055                String alias = rc.getColumnAlias();
6056                String colName = rc.getColumnNameOnly();
6057                String name = (alias != null && !alias.isEmpty())
6058                        ? alias
6059                        : colName;
6060                if (name == null || name.isEmpty()) continue;
6061                String lower = name.toLowerCase(Locale.ROOT);
6062                if (!seenOutputNames.add(lower)) {
6063                    throw new SemanticIRBuildException(
6064                            Diagnostic.error(DiagnosticCode.DUPLICATE_OUTPUT_NAME,
6065                            "duplicate output name '" + name
6066                                    + "' in a SELECT containing a scalar subquery projection; "
6067                                    + "lineage refs are keyed by output name and would collide", rc));
6068                }
6069            }
6070        }
6071
6072        for (int i = 0; i < rcl.size(); i++) {
6073            TResultColumn rc = rcl.getResultColumn(i);
6074            if (rc == null || rc.getExpr() == null) continue;
6075            if (rc.getExpr().getExpressionType() != EExpressionType.subquery_t) {
6076                continue;
6077            }
6078            String outerAlias = rc.getColumnAlias();
6079            if (outerAlias == null || outerAlias.isEmpty()) {
6080                throw new SemanticIRBuildException(
6081                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_ALIAS_REQUIRED,
6082                        "scalar subquery projection must have an alias", rc));
6083            }
6084            TSelectSqlStatement inner = rc.getExpr().getSubQuery();
6085            if (inner == null) {
6086                throw new SemanticIRBuildException(
6087                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_NO_INNER_SELECT,
6088                        "scalar subquery projection '" + outerAlias
6089                                + "' has no inner SELECT", rc));
6090            }
6091            // Pre-recursion validation (codex round-2 MUST 2): inspect
6092            // the inner SELECT's projected column count and naming
6093            // BEFORE recursive build so the rejection message is
6094            // scalar-specific instead of bubbling up from
6095            // effectiveOutputName.
6096            TResultColumnList innerRcl = inner.getResultColumnList();
6097            if (innerRcl == null || innerRcl.size() == 0) {
6098                throw new SemanticIRBuildException(
6099                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT,
6100                        "scalar subquery '" + outerAlias
6101                                + "' must project exactly one column, got 0", rc));
6102            }
6103            if (innerRcl.size() != 1) {
6104                throw new SemanticIRBuildException(
6105                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT,
6106                        "scalar subquery '" + outerAlias
6107                                + "' must project exactly one column, got "
6108                                + innerRcl.size(), rc));
6109            }
6110            TResultColumn innerCol = innerRcl.getResultColumn(0);
6111            String innerAlias = innerCol.getColumnAlias();
6112            String innerColName = innerCol.getColumnNameOnly();
6113            boolean innerHasName =
6114                    (innerAlias != null && !innerAlias.isEmpty())
6115                            || (innerColName != null && !innerColName.isEmpty());
6116            if (!innerHasName && !isConstantExpression(innerCol.getExpr())) {
6117                throw new SemanticIRBuildException(
6118                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED,
6119                        "scalar subquery '" + outerAlias
6120                                + "' inner projection has no alias and no column name; "
6121                                + "add an explicit alias inside the subquery", rc));
6122            }
6123            // Pre-recursion deep-scan (codex round-3 MUST 5, round-4
6124            // MUST 1): reject nested predicate subqueries in the
6125            // scalar body's JOIN ON / GROUP BY before collectColumnRefs
6126            // can descend. Slice 121: the WHERE branch is no longer
6127            // rejected here (allowWherePredicateSubqueries=true) — the
6128            // scalar body's WHERE-side uncorrelated predicate subqueries
6129            // are extracted as their own statements by the
6130            // buildSelectStatementImpl call below via
6131            // PredicateClauseContext.SCALAR_BODY_WHERE.
6132            rejectSubqueriesInScalarBodyClauses(inner, outerAlias,
6133                    /*allowWherePredicateSubqueries=*/ true);
6134
6135            // Slice 20: branch on allowRecursiveScalarSubqueryExtraction.
6136            // - true (outer / CTE-body call sites): build the inner's
6137            //   own enclosing scope chained to this caller's; recursively
6138            //   extract the inner's scalar projections; then build the
6139            //   inner with allowScalarProjectionSubqueries=true;
6140            //   compute scalarName AFTER the recursive extraction so
6141            //   the digit suffix matches the post-extraction stmts.size()
6142            //   (slice-16 codex round-1 MUST 2 lesson).
6143            // - false (set-op-branch call site): keep the slice-12 /
6144            //   slice-16 boundary — no recursive extraction; the inner
6145            //   scalar map stays empty; the inner builds with
6146            //   allowScalarProjectionSubqueries=false; promotion still
6147            //   uses the caller's enclosing scope so OUTER_REFERENCE-of-*
6148            //   correlation works at the branch level.
6149            EnclosingScope innerEnclosing;
6150            Map<Integer, ScalarInfo> innerScalarMap;
6151            String scalarName;
6152            if (allowRecursiveScalarSubqueryExtraction) {
6153                innerEnclosing = buildEnclosingScope(inner,
6154                        cteNameToStatementIndex,
6155                        Collections.<String, Integer>emptyMap(),
6156                        enclosingScope);
6157                innerScalarMap = extractScalarSubqueriesAsStatements(inner,
6158                        consumerProvider, stmts, lineage,
6159                        cteNameToStatementIndex, innerEnclosing,
6160                        /*allowRecursiveScalarSubqueryExtraction=*/ true);
6161                scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">";
6162            } else {
6163                innerEnclosing = enclosingScope;
6164                innerScalarMap = Collections.<Integer, ScalarInfo>emptyMap();
6165                scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">";
6166            }
6167            // Slice 121 — switch from the 7-arg buildSelectStatement to
6168            // the 14-arg buildSelectStatementImpl so the scalar body's
6169            // WHERE clause can extract uncorrelated predicate subqueries
6170            // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
6171            // ANY-ALL-SOME) as their own <predicate_subquery_<i>>
6172            // statements (mirrors the slice-113 set-op branch / slice-120
6173            // FROM-subquery body lift). allowJoinOnPredicateSubqueries=false
6174            // keeps JOIN-ON predicate subqueries rejected (the two flags
6175            // are independent per the slice-113 split; the slice-11 leak
6176            // guard above still fires for the body's JOIN-ON / GROUP-BY).
6177            // allowScalarProjectionSubqueries stays gated on
6178            // allowRecursiveScalarSubqueryExtraction (slice-15/16 invariant).
6179            // stmts/lineage/cteMap are threaded for extraction; the
6180            // enclosing extractScalarSubqueriesAsStatements wrapper is the
6181            // snapshot/rollback boundary (no per-site try/catch needed —
6182            // slice-113 enclosed-by-buildSetOpProgram precedent).
6183            // preBuildScalarSize == the digit baked into scalarName above
6184            // (nothing mutates stmts between the scalarName assignment and
6185            // here), so `idx != preBuildScalarSize` below is exactly "the
6186            // build appended predicate bodies" without re-parsing the name.
6187            int preBuildScalarSize = stmts.size();
6188            StatementGraph innerStmt = buildSelectStatementImpl(inner, consumerProvider,
6189                    scalarName,
6190                    /*hasOuterCteListAlreadyProcessed=*/ false,
6191                    /*allowFromSubqueries=*/ false,
6192                    /*allowScalarProjectionSubqueries=*/ allowRecursiveScalarSubqueryExtraction,
6193                    /*allowWindowProjection=*/ false,
6194                    /*allowJoinOnPredicateSubqueries=*/ false,
6195                    /*stmtsForExtraction=*/ stmts,
6196                    /*lineageForExtraction=*/ lineage,
6197                    /*cteMapForExtraction=*/ cteNameToStatementIndex,
6198                    /*isPredicateBody=*/ false,
6199                    /*whereClauseContext=*/ PredicateClauseContext.SCALAR_BODY_WHERE,
6200                    /*allowWherePredicateSubqueries=*/ true);
6201            // Slice 14: instead of rejecting correlated scalar
6202            // subqueries, promote outer-scope refs into synthesised
6203            // OUTER_REFERENCE relations on the inner statement.
6204            // Non-TABLE-bound outer refs (CTE / SUBQUERY) and
6205            // unknown aliases still throw. (This projection path uses the
6206            // innerEnclosing scope for promotion; the UPDATE SET-RHS scalar
6207            // path in extractScalarSubqueriesFromUpdateSetRhs instead uses
6208            // withTolerantOuterBinding because UPDATE has no enclosing-scope
6209            // promotion — slices 117/119. The two are equivalent for
6210            // uncorrelated bodies; do not cross-port one onto the other.)
6211            // promoteCorrelatedRefsToOuterReference returns a new
6212            // StatementGraph and does NOT append to stmts, so reading
6213            // idx after it is equivalent to reading it right after the
6214            // build (it only reflects predicate bodies the build appended).
6215            innerStmt = promoteCorrelatedRefsToOuterReference(
6216                    innerStmt, outerAlias, innerEnclosing);
6217            int idx = stmts.size();
6218            // Slice 121 — if the WHERE predicate-body extraction appended
6219            // statements during the build, the scalar body's final index
6220            // exceeds the value baked into scalarName (computed before the
6221            // build). Rebuild with the corrected <scalar_subquery_<idx>>
6222            // name so the slice-12/16 name↔index invariant survives
6223            // (slice-113 set-op-branch withRenamedTo precedent). LineageRefs
6224            // are idx-based, not name-based, so the rename is cosmetic.
6225            if (idx != preBuildScalarSize) {
6226                innerStmt = withRenamedTo(innerStmt, SCALAR_BODY_PREFIX + idx + ">");
6227            }
6228            stmts.add(innerStmt);
6229            String innerOutName = effectiveOutputName(innerCol);
6230            ordinalToInfo.put(i, new ScalarInfo(idx, innerOutName));
6231            // Slice 20: pass the chained subquery alias map so
6232            // OUTER_REFERENCE-of-SUBQUERY refs in deeply nested scalar
6233            // bodies resolve through ancestor FROM-subquery aliases.
6234            // Pass innerScalarMap (non-empty when recursive extraction
6235            // is allowed) so the inner's own STATEMENT_OUTPUT →
6236            // STATEMENT_OUTPUT edges land.
6237            emitLineageForStatement(innerStmt, idx, lineage,
6238                    cteNameToStatementIndex,
6239                    innerEnclosing.flattenSubqueryAliasToIndex(),
6240                    innerScalarMap);
6241        }
6242        return ordinalToInfo;
6243    }
6244
6245    /**
6246     * Reject nested predicate subqueries inside a scalar body's
6247     * WHERE / JOIN ON / GROUP BY clauses. Scalar bodies are slice-11
6248     * scope; the pre-existing builder leaks predicate-subquery refs
6249     * into {@code filterColumnRefs}/etc. elsewhere, but the scalar-body
6250     * recursion path is guarded so slice-11 outputs stay clean.
6251     */
6252    private static void rejectSubqueriesInScalarBodyClauses(
6253            TSelectSqlStatement inner, String outerAlias,
6254            boolean allowWherePredicateSubqueries) {
6255        // Slice 121 — the WHERE branch is gated. The projection scalar
6256        // path passes {@code allowWherePredicateSubqueries=true} so its
6257        // WHERE-side uncorrelated predicate subqueries are instead
6258        // extracted as their own {@code <predicate_subquery_<i>>}
6259        // statements by {@code buildSelectStatementImpl} via
6260        // {@link PredicateClauseContext#SCALAR_BODY_WHERE} (see
6261        // {@link #extractScalarSubqueriesAsStatementsInternal}). The
6262        // UPDATE SET-RHS scalar path
6263        // ({@link #extractScalarSubqueriesFromUpdateSetRhs}) keeps passing
6264        // {@code false} so its WHERE subqueries still reject with
6265        // {@code SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE} (the code stays
6266        // reached). JOIN-ON and GROUP-BY always reject (slice 11/23/26).
6267        TWhereClause where = inner.getWhereClause();
6268        if (!allowWherePredicateSubqueries
6269                && where != null && containsAnySubquery(where)) {
6270            throw new SemanticIRBuildException(
6271                    Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE,
6272                    "scalar subquery '" + outerAlias
6273                            + "' has a subquery in its WHERE clause; not supported yet "
6274                            + "(would leak inner refs)", (TParseTreeNode) null));
6275        }
6276        if (inner.joins != null) {
6277            for (TJoin join : inner.joins) {
6278                TJoinItemList items = join.getJoinItems();
6279                if (items == null) continue;
6280                for (int i = 0; i < items.size(); i++) {
6281                    TJoinItem item = items.getJoinItem(i);
6282                    TExpression onCond = item == null ? null : item.getOnCondition();
6283                    if (onCond != null && containsAnySubqueryExpression(onCond)) {
6284                        throw new SemanticIRBuildException(
6285                                Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_JOIN_ON,
6286                                "scalar subquery '" + outerAlias
6287                                        + "' has a subquery in a JOIN ON clause; not supported yet "
6288                                        + "(would leak inner refs)", (TParseTreeNode) null));
6289                    }
6290                }
6291            }
6292        }
6293        TGroupBy groupBy = inner.getGroupByClause();
6294        if (groupBy != null) {
6295            TGroupByItemList items = groupBy.getItems();
6296            if (items != null && containsAnySubquery(items)) {
6297                throw new SemanticIRBuildException(
6298                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_GROUP_BY,
6299                        "scalar subquery '" + outerAlias
6300                                + "' has a subquery in a GROUP BY clause; not supported yet "
6301                                + "(would leak inner refs)", (TParseTreeNode) null));
6302            }
6303        }
6304        // HAVING / ORDER BY subqueries are caught by the slice-9 / 10
6305        // deep-scan rejecters that fire during buildSelectStatement.
6306    }
6307
6308    /**
6309     * True iff any descendant of {@code root} is a {@link TExpression}
6310     * with {@code subquery_t} type or a non-null
6311     * {@link TExpression#getSubQuery()}. Mirrors the slice 9/10 pattern.
6312     */
6313    private static boolean containsAnySubquery(gudusoft.gsqlparser.nodes.TParseTreeNode root) {
6314        final boolean[] found = {false};
6315        root.acceptChildren(new TParseTreeVisitor() {
6316            @Override
6317            public void preVisit(TExpression e) {
6318                if (found[0]) return;
6319                if (e.getExpressionType() == EExpressionType.subquery_t
6320                        || e.getSubQuery() != null) {
6321                    found[0] = true;
6322                }
6323            }
6324        });
6325        return found[0];
6326    }
6327
6328    /** Same as {@link #containsAnySubquery} but also checks the root expression itself. */
6329    private static boolean containsAnySubqueryExpression(TExpression root) {
6330        if (root.getExpressionType() == EExpressionType.subquery_t
6331                || root.getSubQuery() != null) {
6332            return true;
6333        }
6334        return containsAnySubquery(root);
6335    }
6336
6337    /**
6338     * Slice 119 — collect every {@code subquery_t} TExpression node that
6339     * is a direct (top-level) subquery in the compound expression {@code
6340     * root}, without descending into any found subquery's own body.
6341     *
6342     * <p>Uses {@link TExpression#acceptChildren} with a depth counter to
6343     * handle all expression subtypes (arithmetic, CASE, function args)
6344     * without manual branch enumeration. The depth counter increments on
6345     * every {@code subquery_t} preVisit and decrements on every postVisit,
6346     * so nested subqueries inside a found body (e.g. an EXISTS inside a
6347     * scalar's WHERE) are tracked but NOT added to the result set.
6348     *
6349     * <p>Result list is in traversal (preVisit) order for deterministic
6350     * statement-graph numbering across identical SQL texts.
6351     */
6352    private static List<TExpression> collectNestedSubqueryExpressions(TExpression root) {
6353        if (root == null) return Collections.<TExpression>emptyList();
6354        // Defensive: root itself is a subquery_t — callers of the
6355        // mixed-expression path should have already taken the slice-115
6356        // top-level branch, but guard anyway.
6357        if (root.getExpressionType() == EExpressionType.subquery_t) {
6358            return Collections.singletonList(root);
6359        }
6360        final List<TExpression> ordered = new ArrayList<>();
6361        final int[] depth = {0};
6362        root.acceptChildren(new TParseTreeVisitor() {
6363            @Override
6364            public void preVisit(TExpression e) {
6365                if (e.getExpressionType() == EExpressionType.subquery_t) {
6366                    if (depth[0] == 0) ordered.add(e); // top-level only
6367                    depth[0]++;
6368                }
6369            }
6370            @Override
6371            public void postVisit(TExpression e) {
6372                if (e.getExpressionType() == EExpressionType.subquery_t) {
6373                    depth[0]--;
6374                }
6375            }
6376        });
6377        return ordered;
6378    }
6379
6380    /**
6381     * Slice-14 enclosing-scope map for correlation promotion. Holds the
6382     * full outer {@link RelationSource} per alias visible from the
6383     * enclosing scope so {@link #promoteCorrelatedRefsToOuterReference}
6384     * can read the outer's binding kind and qualifiedName.
6385     *
6386     * <p>The map is keyed by lower-cased alias for case-insensitive
6387     * lookup. The synthesised OUTER_REFERENCE RelationSource's alias is
6388     * NOT taken from this map's value — it is taken from the inner
6389     * ref's spelling (see §4.3 of the slice-14 plan), because
6390     * {@link gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider}
6391     * records the inner ref's alias verbatim and case-sensitive
6392     * alias-equality elsewhere relies on that spelling.
6393     *
6394     * <p>Slice-15 adds {@link #subqueryAliasToIndex}: when a SUBQUERY-bound
6395     * outer alias is referenced from an inner correlated scalar, the
6396     * inner statement's lineage emission needs to look up the outer's
6397     * FROM-subquery body by alias to wire a STATEMENT_OUTPUT →
6398     * STATEMENT_OUTPUT edge. The map mirrors the outer's alias→index
6399     * map but is filtered to alias keys also present in
6400     * {@link #aliasLowerToOuter} as SUBQUERY entries so the two maps
6401     * cannot drift apart.
6402     *
6403     * <p>Slice-20 generalises slice-14/15's flat map into a chain of
6404     * ancestor scopes (the {@link #parent} field). Lookups walk innermost
6405     * → outermost via {@link #lookupAlias(String)}. The
6406     * {@link #flattenSubqueryAliasToIndex()} helper produces an innermost-
6407     * wins flattened map for {@code emitLineageForStatement} consumers
6408     * that resolve OUTER_REFERENCE-of-SUBQUERY through ancestor
6409     * FROM-subquery aliases. Worst-case asymptotic per build for a
6410     * scalar chain of depth D with K siblings per level is
6411     * {@code O(K · D²)}; in practice D ≤ 4-5 for human-written SQL so the
6412     * constant factor is negligible. If a real-world benchmark surfaces
6413     * the flatten as a hot path, the obvious fix is a memo keyed on
6414     * {@code EnclosingScope} identity — deferred until measured.
6415     */
6416    private static final class EnclosingScope {
6417        final Map<String, RelationSource> aliasLowerToOuter;
6418        final Map<String, Integer> subqueryAliasToIndex;
6419        /** Slice-20 chain: parent enclosing scope; null at the root. */
6420        final EnclosingScope parent;
6421
6422        EnclosingScope(Map<String, RelationSource> aliasLowerToOuter,
6423                       Map<String, Integer> subqueryAliasToIndex,
6424                       EnclosingScope parent) {
6425            this.aliasLowerToOuter = aliasLowerToOuter;
6426            this.subqueryAliasToIndex = subqueryAliasToIndex;
6427            this.parent = parent;
6428        }
6429
6430        static EnclosingScope empty() {
6431            return new EnclosingScope(
6432                    Collections.<String, RelationSource>emptyMap(),
6433                    Collections.<String, Integer>emptyMap(),
6434                    /*parent=*/ null);
6435        }
6436
6437        /**
6438         * Walk the chain innermost → outermost; first match wins
6439         * (shadowing). Defensive cycle guard via identity-keyed visited
6440         * set: the chain is a DAG by construction (parent links never
6441         * loop back), but the guard makes the invariant explicit.
6442         */
6443        RelationSource lookupAlias(String aliasLower) {
6444            EnclosingScope cur = this;
6445            Set<EnclosingScope> visited = Collections.newSetFromMap(
6446                    new IdentityHashMap<EnclosingScope, Boolean>());
6447            while (cur != null && visited.add(cur)) {
6448                RelationSource r = cur.aliasLowerToOuter.get(aliasLower);
6449                if (r != null) return r;
6450                cur = cur.parent;
6451            }
6452            return null;
6453        }
6454
6455        /**
6456         * Innermost-wins flatten of the SUBQUERY alias → body-index chain.
6457         * Ancestors contribute their own FROM-subquery alias maps; if both
6458         * a parent and a child define the same alias, the child wins
6459         * (innermost shadows outermost). Cycle guard mirrors
6460         * {@link #lookupAlias(String)}.
6461         */
6462        Map<String, Integer> flattenSubqueryAliasToIndex() {
6463            if (parent == null) return subqueryAliasToIndex;
6464            Deque<EnclosingScope> stack = new ArrayDeque<>();
6465            Set<EnclosingScope> visited = Collections.newSetFromMap(
6466                    new IdentityHashMap<EnclosingScope, Boolean>());
6467            EnclosingScope cur = this;
6468            while (cur != null && visited.add(cur)) {
6469                stack.push(cur);
6470                cur = cur.parent;
6471            }
6472            // Stack top = outermost. Pop outermost first, emit, then
6473            // innermost overwrites via put(). Result: innermost wins.
6474            Map<String, Integer> out = new LinkedHashMap<>();
6475            while (!stack.isEmpty()) {
6476                EnclosingScope s = stack.pop();
6477                out.putAll(s.subqueryAliasToIndex);
6478            }
6479            return out;
6480        }
6481    }
6482
6483    /**
6484     * Build an {@link EnclosingScope} for the consuming SELECT by walking
6485     * its {@link TSelectSqlStatement#tables} list (FROM relations) and
6486     * classifying each entry. Mirrors how
6487     * {@link Resolver2NameBindingProvider#bindRelation} would classify
6488     * the same TTable but produces a full RelationSource per alias so
6489     * the slice-14 promotion can read both kind and qualifiedName.
6490     *
6491     * <p>Slice 20: the {@code parent} parameter chains the new scope to an
6492     * enclosing one so a doubly-nested scalar body can resolve grandparent
6493     * aliases. Top-level callers pass {@code null}; recursive scalar
6494     * extraction passes the caller's enclosing scope.
6495     *
6496     * <p>Classification (precedence on collision: CTE name beats
6497     * base-table name):
6498     * <ul>
6499     *   <li>{@code TTable.getTableType() == subquery} AND alias matches
6500     *       a key in {@code subqueryAliasToIndex} → SUBQUERY-bound.</li>
6501     *   <li>{@code TTable.getName().toLowerCase()} matches a key in
6502     *       {@code cteNameToStatementIndex} → CTE-bound.</li>
6503     *   <li>Otherwise → TABLE-bound.</li>
6504     * </ul>
6505     */
6506    private static EnclosingScope buildEnclosingScope(TSelectSqlStatement consumer,
6507                                                      Map<String, Integer> cteNameToStatementIndex,
6508                                                      Map<String, Integer> subqueryAliasToIndex,
6509                                                      EnclosingScope parent) {
6510        if (consumer == null || consumer.tables == null || consumer.tables.size() == 0) {
6511            // Even an empty FROM contributes an empty scope so the chain's
6512            // shape reflects nesting depth uniformly.
6513            return new EnclosingScope(
6514                    Collections.<String, RelationSource>emptyMap(),
6515                    Collections.<String, Integer>emptyMap(),
6516                    parent);
6517        }
6518        Map<String, RelationSource> map = new LinkedHashMap<>();
6519        Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>();
6520        for (int i = 0; i < consumer.tables.size(); i++) {
6521            TTable t = consumer.tables.getTable(i);
6522            if (t == null) continue;
6523            // Slice 74: route anonymous FROM-subqueries through
6524            // effectiveAliasOf so the synth name (instead of the
6525            // literal "subquery" returned by t.getName()) flows into the
6526            // OUTER_REFERENCE scope chain.
6527            String aliasOrName = effectiveAliasOf(t);
6528            if (aliasOrName == null || aliasOrName.isEmpty()) continue;
6529            String lower = aliasOrName.toLowerCase(Locale.ROOT);
6530            if (map.containsKey(lower)) continue; // first-occurrence wins (defensive)
6531
6532            if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
6533                if (subqueryAliasToIndex != null && subqueryAliasToIndex.containsKey(lower)) {
6534                    map.put(lower, new RelationSource(aliasOrName,
6535                            new RelationBinding(RelationKind.SUBQUERY, aliasOrName)));
6536                    // Slice 15: keep a parallel filtered map of alias→index
6537                    // so OUTER_REFERENCE-of-SUBQUERY emit-side dispatch can
6538                    // resolve the outer body's statement index.
6539                    filteredSubqueryAliasToIndex.put(lower, subqueryAliasToIndex.get(lower));
6540                }
6541                continue;
6542            }
6543            if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) {
6544                continue; // function / rowList / etc. — not modelled
6545            }
6546            String name = t.getName();
6547            if (name == null || name.isEmpty()) continue;
6548            String nameLower = name.toLowerCase(Locale.ROOT);
6549            if (cteNameToStatementIndex != null
6550                    && cteNameToStatementIndex.containsKey(nameLower)) {
6551                map.put(lower, new RelationSource(aliasOrName,
6552                        new RelationBinding(RelationKind.CTE, name)));
6553            } else {
6554                map.put(lower, new RelationSource(aliasOrName,
6555                        new RelationBinding(RelationKind.TABLE, name)));
6556            }
6557        }
6558        return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent);
6559    }
6560
6561    /**
6562     * Slice 117 — sibling to {@link #buildEnclosingScope} for UPDATE
6563     * SET-RHS scalar-subquery bodies. The UPDATE outer scope is the
6564     * target table (TABLE-bound) plus any FROM-side relations walked
6565     * via {@code update.getJoins()} (TABLE / CTE / SUBQUERY-classified).
6566     *
6567     * <p>The target is added FIRST so on alias collisions with a
6568     * FROM-side relation (e.g. {@code UPDATE t ... FROM other t}) the
6569     * target wins. The {@code first-occurrence wins} rule mirrors
6570     * {@link #buildEnclosingScope}.
6571     *
6572     * <p>Used only by
6573     * {@link #extractScalarSubqueriesFromUpdateSetRhsInternal}.
6574     */
6575    private static EnclosingScope buildUpdateEnclosingScope(
6576            TUpdateSqlStatement update,
6577            Map<String, Integer> cteNameToStatementIndex,
6578            Map<String, Integer> subqueryAliasToIndex,
6579            EnclosingScope parent) {
6580        Map<String, RelationSource> map = new LinkedHashMap<>();
6581        Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>();
6582        if (update == null) {
6583            return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent);
6584        }
6585        // 1) Target — always TABLE-bound. effectiveAliasOf falls back to
6586        //    the table's own name when no alias is present.
6587        TTable target = update.getTargetTable();
6588        if (target != null
6589                && target.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
6590            String targetAlias = effectiveAliasOf(target);
6591            String targetName = target.getName();
6592            if (targetAlias != null && !targetAlias.isEmpty()
6593                    && targetName != null && !targetName.isEmpty()) {
6594                String aliasLower = targetAlias.toLowerCase(Locale.ROOT);
6595                map.put(aliasLower, new RelationSource(targetAlias,
6596                        new RelationBinding(RelationKind.TABLE, targetName)));
6597            }
6598        }
6599        // 2) FROM-side joins.
6600        TJoinList joins = update.getJoins();
6601        if (joins != null) {
6602            for (TJoin join : joins) {
6603                addUpdateRelationToEnclosingScope(join.getTable(), map,
6604                        filteredSubqueryAliasToIndex,
6605                        cteNameToStatementIndex, subqueryAliasToIndex);
6606                TJoinItemList items = join.getJoinItems();
6607                if (items == null) continue;
6608                for (int i = 0; i < items.size(); i++) {
6609                    TJoinItem item = items.getJoinItem(i);
6610                    if (item == null) continue;
6611                    addUpdateRelationToEnclosingScope(item.getTable(), map,
6612                            filteredSubqueryAliasToIndex,
6613                            cteNameToStatementIndex, subqueryAliasToIndex);
6614                }
6615            }
6616        }
6617        return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent);
6618    }
6619
6620    /**
6621     * Slice 117 — classify one FROM-side TTable for
6622     * {@link #buildUpdateEnclosingScope}. SUBQUERY-typed tables go to
6623     * {@link RelationKind#SUBQUERY} if their alias was registered in
6624     * {@code subqueryAliasToIndex} (slice-83 extraction map); objectname
6625     * tables go to {@link RelationKind#CTE} if their bare name is a
6626     * declared CTE, otherwise {@link RelationKind#TABLE}. First-
6627     * occurrence wins. Function / rowList sources are silently skipped
6628     * (not modelled).
6629     */
6630    private static void addUpdateRelationToEnclosingScope(TTable t,
6631            Map<String, RelationSource> map,
6632            Map<String, Integer> filteredSubqueryAliasToIndex,
6633            Map<String, Integer> cteNameToStatementIndex,
6634            Map<String, Integer> subqueryAliasToIndex) {
6635        if (t == null) return;
6636        String aliasOrName = effectiveAliasOf(t);
6637        if (aliasOrName == null || aliasOrName.isEmpty()) return;
6638        String lower = aliasOrName.toLowerCase(Locale.ROOT);
6639        if (map.containsKey(lower)) return; // first-occurrence wins
6640        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
6641            if (subqueryAliasToIndex != null
6642                    && subqueryAliasToIndex.containsKey(lower)) {
6643                map.put(lower, new RelationSource(aliasOrName,
6644                        new RelationBinding(RelationKind.SUBQUERY, aliasOrName)));
6645                filteredSubqueryAliasToIndex.put(lower,
6646                        subqueryAliasToIndex.get(lower));
6647            }
6648            return;
6649        }
6650        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) {
6651            return; // function / rowList / etc. — not modelled
6652        }
6653        String name = t.getName();
6654        if (name == null || name.isEmpty()) return;
6655        String nameLower = name.toLowerCase(Locale.ROOT);
6656        if (cteNameToStatementIndex != null
6657                && cteNameToStatementIndex.containsKey(nameLower)) {
6658            map.put(lower, new RelationSource(aliasOrName,
6659                    new RelationBinding(RelationKind.CTE, name)));
6660        } else {
6661            map.put(lower, new RelationSource(aliasOrName,
6662                    new RelationBinding(RelationKind.TABLE, name)));
6663        }
6664    }
6665
6666    /**
6667     * Slice 118 — sibling to {@link #buildUpdateEnclosingScope} for MERGE
6668     * per-WHEN action WHERE correlated predicate subqueries. Builds the
6669     * enclosing scope's relation map covering MERGE's target table, USING
6670     * source, and any outer CTEs declared on the MERGE itself. Used only
6671     * by {@link #collectMergeActionWhere}; produced once per MERGE in
6672     * {@link #buildMerge} and threaded through the predicate-subquery
6673     * extractor.
6674     *
6675     * <p>Classification mirrors {@code buildMerge} step 6:
6676     * <ul>
6677     *   <li>{@code merge.getTargetTable()} — TABLE-bound (qualifiedName =
6678     *       target's table name). First-occurrence wins.</li>
6679     *   <li>{@code merge.getUsingTable()}:
6680     *       <ul>
6681     *         <li>SUBQUERY-typed → SUBQUERY-bound with index pulled from
6682     *             {@code aliasToSubIdx}.</li>
6683     *         <li>objectname-typed AND name in
6684     *             {@code cteNameToStatementIndex} → CTE-bound (slice-101
6685     *             USING-as-CTE).</li>
6686     *         <li>Else objectname → TABLE-bound.</li>
6687     *       </ul></li>
6688     * </ul>
6689     */
6690    private static EnclosingScope buildMergeEnclosingScope(
6691            TMergeSqlStatement merge,
6692            Map<String, Integer> cteNameToStatementIndex,
6693            Map<String, Integer> aliasToSubIdx) {
6694        Map<String, RelationSource> map = new LinkedHashMap<>();
6695        Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>();
6696        if (merge == null) {
6697            return new EnclosingScope(map, filteredSubqueryAliasToIndex,
6698                    /*parent=*/ null);
6699        }
6700        // 1) Target — always TABLE-bound. effectiveAliasOf falls back to
6701        //    the table's own name when no alias is present.
6702        TTable target = merge.getTargetTable();
6703        if (target != null
6704                && target.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
6705            String targetAlias = effectiveAliasOf(target);
6706            String targetName = target.getName();
6707            if (targetAlias != null && !targetAlias.isEmpty()
6708                    && targetName != null && !targetName.isEmpty()) {
6709                String aliasLower = targetAlias.toLowerCase(Locale.ROOT);
6710                map.put(aliasLower, new RelationSource(targetAlias,
6711                        new RelationBinding(RelationKind.TABLE, targetName)));
6712            }
6713        }
6714        // 2) USING source.
6715        TTable using = merge.getUsingTable();
6716        if (using != null) {
6717            String usingAlias = effectiveAliasOf(using);
6718            // Fall back to the USING source's bare name when no alias is
6719            // present — matches buildMerge's `usingAlias` initialisation.
6720            if (usingAlias == null || usingAlias.isEmpty()) {
6721                usingAlias = (using.getName() == null
6722                        || using.getName().toString().isEmpty())
6723                        ? "__merge_using__"
6724                        : using.getName().toString();
6725            }
6726            String usingAliasLower = usingAlias.toLowerCase(Locale.ROOT);
6727            // First-occurrence wins (defensive — target's alias could
6728            // theoretically shadow if user named USING source identically;
6729            // matches buildUpdateEnclosingScope behaviour).
6730            if (!map.containsKey(usingAliasLower)) {
6731                if (using.getTableType()
6732                        == gudusoft.gsqlparser.ETableSource.subquery) {
6733                    if (aliasToSubIdx != null
6734                            && aliasToSubIdx.containsKey(usingAliasLower)) {
6735                        map.put(usingAliasLower, new RelationSource(usingAlias,
6736                                new RelationBinding(RelationKind.SUBQUERY,
6737                                        usingAlias)));
6738                        filteredSubqueryAliasToIndex.put(usingAliasLower,
6739                                aliasToSubIdx.get(usingAliasLower));
6740                    }
6741                } else if (using.getTableType()
6742                        == gudusoft.gsqlparser.ETableSource.objectname) {
6743                    String usingName = using.getName();
6744                    if (usingName != null && !usingName.isEmpty()) {
6745                        String usingNameLower =
6746                                usingName.toLowerCase(Locale.ROOT);
6747                        Integer cteIdx = (cteNameToStatementIndex == null)
6748                                ? null
6749                                : cteNameToStatementIndex.get(usingNameLower);
6750                        if (cteIdx != null) {
6751                            // Slice 101 USING-as-CTE branch — model as
6752                            // SUBQUERY-bound so the promoter classifies
6753                            // outerKind=SUBQUERY (mirrors the slice-117
6754                            // SUBQUERY classifier for UPDATE FROM-CTE
6755                            // sources, also via aliasToSubIdx).
6756                            map.put(usingAliasLower, new RelationSource(usingAlias,
6757                                    new RelationBinding(RelationKind.SUBQUERY,
6758                                            usingAlias)));
6759                            // The MERGE caller pre-populates aliasToSubIdx
6760                            // with the CTE's statement index for both
6761                            // usingAlias AND bare CTE name. Use whichever
6762                            // exists (aliasToSubIdx is keyed on lower-cased
6763                            // aliases — matches buildMerge step 6).
6764                            if (aliasToSubIdx != null
6765                                    && aliasToSubIdx.containsKey(usingAliasLower)) {
6766                                filteredSubqueryAliasToIndex.put(usingAliasLower,
6767                                        aliasToSubIdx.get(usingAliasLower));
6768                            }
6769                        } else {
6770                            map.put(usingAliasLower, new RelationSource(usingAlias,
6771                                    new RelationBinding(RelationKind.TABLE, usingName)));
6772                        }
6773                    }
6774                }
6775                // function / rowList sources are silently skipped (not
6776                // modelled — same convention as buildUpdateEnclosingScope).
6777            }
6778        }
6779        return new EnclosingScope(map, filteredSubqueryAliasToIndex,
6780                /*parent=*/ null);
6781    }
6782
6783    /**
6784     * Slice 117 — precompute the lowercased set of inner local FROM
6785     * aliases for a SELECT statement, used by the tolerant-outer-
6786     * binding fallback in
6787     * {@link gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider#bindColumn}.
6788     * Walks {@code select.tables} (which includes both the FROM driver
6789     * and any JOIN sides) and collects each table's effective alias.
6790     *
6791     * <p>Computed BEFORE the inner build's {@code buildRelations} JOIN-
6792     * ON collector runs so the tolerant provider is already scope-aware
6793     * when the collector first calls {@code bindColumn} (codex round-5
6794     * ordering fix).
6795     */
6796    private static Set<String> precomputeInnerLocalAliases(
6797            TSelectSqlStatement select) {
6798        Set<String> aliases = new HashSet<>();
6799        if (select == null || select.tables == null) return aliases;
6800        for (int i = 0; i < select.tables.size(); i++) {
6801            TTable t = select.tables.getTable(i);
6802            if (t == null) continue;
6803            String alias = effectiveAliasOf(t);
6804            if (alias != null && !alias.isEmpty()) {
6805                aliases.add(alias.toLowerCase(Locale.ROOT));
6806            }
6807        }
6808        return aliases;
6809    }
6810
6811    /**
6812     * Slice-14 correlation promotion (slice-15 extended). Walk every
6813     * column ref in the already-built inner statement; any ref whose
6814     * alias is NOT in the inner's local relations is "correlated."
6815     * Look it up in the enclosing scope:
6816     *
6817     * <ul>
6818     *   <li>Found AND TABLE / CTE / SUBQUERY-bound → synthesise an
6819     *       OUTER_REFERENCE RelationSource with {@code outerKind}
6820     *       set to the resolved outer kind, and add to
6821     *       {@code inner.relations}.</li>
6822     *   <li>Found AND UNION / UNKNOWN-bound → throw defensively
6823     *       (no current builder path produces these from a FROM
6824     *       relation).</li>
6825     *   <li>Not found anywhere → throw (Resolver2 should not give us
6826     *       such a ref; defensive).</li>
6827     * </ul>
6828     *
6829     * <p>The synthesised RelationSource's alias is the inner ref's
6830     * spelling (case-sensitive equality is preserved). The
6831     * qualifiedName comes from the outer's existing binding:
6832     * - TABLE: outer's table name.
6833     * - CTE: outer's CTE name (NOT alias — see
6834     *   {@code correlatedToCteBoundOuterWithCteAlias}).
6835     * - SUBQUERY: outer's alias (matching slice-14 SUBQUERY
6836     *   convention, where {@code buildEnclosingScope} sets
6837     *   {@code qualifiedName=aliasOrName}).
6838     * No lower-casing happens here — that happens at projector
6839     * emit-time per slice-1 convention. Multiple inner refs with
6840     * case-variant spellings inherit the same pre-existing slice-1
6841     * limitation.
6842     */
6843    private static StatementGraph promoteCorrelatedRefsToOuterReference(
6844            StatementGraph innerStmt, String outerAlias,
6845            EnclosingScope enclosingScope) {
6846        Set<String> innerAliasesLower = new HashSet<>();
6847        for (RelationSource r : innerStmt.getRelations()) {
6848            innerAliasesLower.add(r.getAlias().toLowerCase(Locale.ROOT));
6849        }
6850        // Per alias-lower: outer's RelationSource (for binding) and the
6851        // inner ref's exact alias spelling (for the synthesised alias).
6852        LinkedHashMap<String, RelationSource> outerByLower = new LinkedHashMap<>();
6853        LinkedHashMap<String, String> firstRefSpellingByLower = new LinkedHashMap<>();
6854
6855        for (ColumnRef ref : collectAllInnerRefs(innerStmt)) {
6856            String aliasLower = ref.getRelationAlias().toLowerCase(Locale.ROOT);
6857            if (innerAliasesLower.contains(aliasLower)) continue; // local — not correlated
6858            if (outerByLower.containsKey(aliasLower)) continue;   // already promoted
6859
6860            // Slice 20: chain-walking lookup. Walks the EnclosingScope
6861            // chain innermost → outermost; first match wins (shadowing
6862            // semantics). slice 14/15 used a single-level get(); slice
6863            // 20 generalises so inner-inner scalars can resolve
6864            // grandparent (and deeper) aliases.
6865            RelationSource outerRel = enclosingScope.lookupAlias(aliasLower);
6866            if (outerRel == null) {
6867                throw new SemanticIRBuildException(
6868                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS,
6869                        "scalar subquery '" + outerAlias
6870                                + "' references unknown alias '" + ref.getRelationAlias()
6871                                + "' (column '" + ref.getRelationAlias() + "."
6872                                + ref.getColumnName()
6873                                + "'); not in inner relations or any enclosing scope", (TParseTreeNode) null));
6874            }
6875            RelationKind outerKind = outerRel.getBinding().getKind();
6876            if (outerKind != RelationKind.TABLE
6877                    && outerKind != RelationKind.CTE
6878                    && outerKind != RelationKind.SUBQUERY) {
6879                throw new SemanticIRBuildException(
6880                        Diagnostic.error(DiagnosticCode.CORRELATED_SCALAR_SUBQUERY_UNKNOWN_OUTER_BINDING,
6881                        "correlated scalar subquery '" + outerAlias
6882                                + "' references outer alias '" + ref.getRelationAlias()
6883                                + "' bound to a " + outerKind
6884                                + "; only TABLE / CTE / SUBQUERY-bound outer correlations supported", (TParseTreeNode) null));
6885            }
6886            outerByLower.put(aliasLower, outerRel);
6887            firstRefSpellingByLower.put(aliasLower, ref.getRelationAlias());
6888        }
6889
6890        if (outerByLower.isEmpty()) return innerStmt;
6891
6892        List<RelationSource> augmented = new ArrayList<>(innerStmt.getRelations());
6893        for (String key : outerByLower.keySet()) {
6894            RelationSource outerRel = outerByLower.get(key);
6895            augmented.add(new RelationSource(
6896                    firstRefSpellingByLower.get(key),
6897                    new RelationBinding(RelationKind.OUTER_REFERENCE,
6898                            outerRel.getBinding().getQualifiedName(),
6899                            outerRel.getBinding().getKind())));
6900        }
6901        return rebuildStatementGraphWithRelations(innerStmt, augmented);
6902    }
6903
6904    /**
6905     * Collect every {@link ColumnRef} reachable from {@code innerStmt}'s
6906     * seven clause-bearing fields. Used by
6907     * {@link #promoteCorrelatedRefsToOuterReference} (slice-11 scalar
6908     * bodies) and the JOIN-ON EXISTS predicate-body correlation walker
6909     * (slice 23+); mirrors the old rejecter's clause coverage exactly
6910     * (output sources, filter, join, groupBy, having, orderBy, and
6911     * slice-73 distinctOn).
6912     */
6913    private static List<ColumnRef> collectAllInnerRefs(StatementGraph innerStmt) {
6914        List<ColumnRef> all = new ArrayList<>();
6915        for (OutputColumn out : innerStmt.getOutputColumns()) {
6916            all.addAll(out.getSources());
6917        }
6918        all.addAll(innerStmt.getFilterColumnRefs());
6919        all.addAll(innerStmt.getJoinColumnRefs());
6920        all.addAll(innerStmt.getGroupByColumnRefs());
6921        all.addAll(innerStmt.getHavingColumnRefs());
6922        all.addAll(innerStmt.getOrderByColumnRefs());
6923        all.addAll(innerStmt.getDistinctOnColumnRefs());
6924        return all;
6925    }
6926
6927    /**
6928     * Copy a {@link StatementGraph} replacing only its relations list.
6929     * StatementGraph is otherwise immutable.
6930     */
6931    private static StatementGraph rebuildStatementGraphWithRelations(
6932            StatementGraph stmt, List<RelationSource> relations) {
6933        return new StatementGraph(
6934                stmt.getName(),
6935                stmt.getKind(),
6936                relations,
6937                stmt.getOutputColumns(),
6938                stmt.getFilterColumnRefs(),
6939                stmt.getJoinColumnRefs(),
6940                stmt.getGroupByColumnRefs(),
6941                stmt.getHavingColumnRefs(),
6942                stmt.getOrderByColumnRefs(),
6943                stmt.getDistinctOnColumnRefs(),
6944                stmt.isDistinct(),
6945                stmt.getSetOperator(),
6946                stmt.getRowLimit())
6947                // Slice 180 (R5): preserve the block span the legacy ctor
6948                // would otherwise drop (this copy only swaps relations).
6949                .withSourceSpan(stmt.getSourceSpan());
6950    }
6951
6952    /**
6953     * Slice 16 preflight: reject any FROM-clause subquery directly on a
6954     * set-op branch's FROM/JOIN list, BEFORE
6955     * {@link #extractScalarSubqueriesAsStatements} can append scalar-body
6956     * statements to {@code stmts}/{@code lineage}. Without this preflight,
6957     * a branch with both a FROM-subquery and a scalar projection would
6958     * extract the scalar (mutating shared state) and then fail later in
6959     * {@code buildSelectStatement}, producing a confusing scalar-correlation
6960     * error message instead of the slice-12 FROM-subquery boundary.
6961     *
6962     * <p>Inspects only the branch's DIRECT FROM/JOIN entries — does NOT
6963     * recurse into result-column expressions or scalar-body inner SELECTs
6964     * (those are handled by the recursive scalar-body build's own
6965     * {@code allowFromSubqueries=false} guard). The error message contains
6966     * both {@code set-op branch} and {@code FROM-clause subquery} keywords
6967     * so the slice-12 boundary is surfaced explicitly.
6968     */
6969    private static void rejectFromSubqueriesInSetOpBranch(TSelectSqlStatement br) {
6970        if (br.joins == null) return;
6971        for (TJoin join : br.joins) {
6972            TTable left = join.getTable();
6973            if (left != null
6974                    && left.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
6975                throw new SemanticIRBuildException(
6976                        Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM,
6977                        "FROM-clause subquery directly in a set-op branch FROM is not "
6978                                + "supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null));
6979            }
6980            // Slice 140: a PIVOT/UNPIVOT branch whose SOURCE is a FROM-subquery
6981            // (`SELECT ... FROM (SELECT ...) PIVOT(...) UNION ALL ...`) is
6982            // deferred. The slice-138 `processDirectSubqueryTable` pre-extraction
6983            // is NOT called from the set-op branch path (set-op branches set
6984            // `allowFromSubqueries=false`), so routing such a shape to
6985            // `buildPivotSelect` would land in `buildRelation` on the inner
6986            // subquery TTable with empty `subqueryAliasToIndex` and emit a
6987            // less-informative second-step `TABLE_BINDING_UNRESOLVED` against
6988            // the inner subquery. Reuse the existing
6989            // `FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM` code with a discriminated
6990            // message; no new DiagnosticCode.
6991            if (left != null
6992                    && left.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
6993                    && left.getPivotedTable() != null
6994                    && !left.getPivotedTable().getRelations().isEmpty()
6995                    && left.getPivotedTable().getRelations().get(0) != null
6996                    && left.getPivotedTable().getRelations().get(0).getTableType()
6997                            == gudusoft.gsqlparser.ETableSource.subquery) {
6998                throw new SemanticIRBuildException(
6999                        Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM,
7000                        "FROM-clause subquery as a PIVOT source in a set-op branch is "
7001                                + "not supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null));
7002            }
7003            TJoinItemList items = join.getJoinItems();
7004            if (items == null) continue;
7005            for (int i = 0; i < items.size(); i++) {
7006                TTable r = items.getJoinItem(i).getTable();
7007                if (r != null
7008                        && r.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
7009                    throw new SemanticIRBuildException(
7010                            Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_ON_JOIN_SIDE_IN_SET_OP_BRANCH,
7011                            "FROM-clause subquery on a JOIN side in a set-op branch is "
7012                                    + "not supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null));
7013                }
7014            }
7015        }
7016    }
7017
7018    // ====================================================================
7019    // Slice 12: Set operations (UNION / INTERSECT / MINUS / EXCEPT).
7020    //
7021    // Algorithm: each branch of the set-op tree is built as its own
7022    // synthetically-named StatementGraph; the outer set-op statement has
7023    // empty relations and per-output `STATEMENT_OUTPUT → STATEMENT_OUTPUT`
7024    // lineage edges to each branch's corresponding output. The flatten
7025    // walks the left-leaning AST iteratively (CLAUDE.md mandates no
7026    // recursion on leftStmt/rightStmt; would StackOverflow on 2000+ UNIONs).
7027    //
7028    // Internal-node modifiers (ORDER BY / row-limits) are rejected on every
7029    // set-op node visited (root + internal), not just the root, because
7030    // parenthesized inner combined nodes can carry those modifiers (per
7031    // /tmp/SetOpInnerModifierProbe: `(A UNION B ORDER BY id) UNION C`
7032    // attaches ORDER BY to the inner node in Oracle / PostgreSQL / MSSQL;
7033    // PostgreSQL maps inner FETCH FIRST → LIMIT).
7034    // ====================================================================
7035
7036    /**
7037     * Build a set-op program: flatten branches, build each as its own
7038     * StatementGraph, construct the outer set-op statement, emit lineage.
7039     * Returns the outer set-op statement's index in {@code stmts}.
7040     *
7041     * @param setOp the {@link TSelectSqlStatement} carrying
7042     *     {@code setOperatorType != none}.
7043     * @param setOpName non-null when the set-op is a CTE body (the outer
7044     *     statement is named with the CTE name); null when the set-op is
7045     *     the program's top-level outer.
7046     * @param hasOuterCteListAlreadyProcessed true when the caller already
7047     *     processed the set-op root's CTE list (top-level dispatch);
7048     *     false when this is a recursive context (set-op CTE body) where
7049     *     a non-empty CTE list on the set-op root is rejected as a
7050     *     nested WITH.
7051     */
7052    private static int buildSetOpProgram(TSelectSqlStatement setOp,
7053                                         NameBindingProvider provider,
7054                                         List<StatementGraph> stmts,
7055                                         List<LineageEdge> lineage,
7056                                         Map<String, Integer> cteNameToStatementIndex,
7057                                         String setOpName,
7058                                         boolean hasOuterCteListAlreadyProcessed) {
7059        // Nested-WITH guard (rd-5 MUST 1): when called from a CTE body
7060        // context, the set-op root must not carry its own CTE list.
7061        if (!hasOuterCteListAlreadyProcessed
7062                && setOp.getCteList() != null
7063                && setOp.getCteList().size() > 0) {
7064            throw new SemanticIRBuildException(
7065                    Diagnostic.error(DiagnosticCode.NESTED_WITH_NOT_SUPPORTED,
7066                    "nested WITH/CTE inside a CTE body or subquery is not supported yet "
7067                            + "(set-op CTE body has its own CTE list)", (TParseTreeNode) null));
7068        }
7069        // Slice 21: ORDER BY is now collected from the outer set-op
7070        // (see buildSetOpOuterOrderByColumnRefs).
7071        // Slice 72: outer row-limit lifted via buildSetOpRowLimit.
7072        // The internal-node reject (parenthesized inner set-ops with
7073        // row-limit) fires inside flattenSetOpTreeIteratively. Compute
7074        // BEFORE the snapshot block so a defensive throw (Hive/Vertica/
7075        // ANSI-DB2 guards, MSSQL null-valued TOrderBy slots) propagates
7076        // without leaving stmts/lineage partially populated.
7077        RowLimit setOpRowLimit = buildSetOpRowLimit(setOp);
7078
7079        // Slice 16: SET-OP-WIDE TRANSACTIONAL ROLLBACK (codex rounds 3-5
7080        // adversarial findings). Snapshot `stmts.size()` and
7081        // `lineage.size()` BEFORE any branch mutation. On any
7082        // SemanticIRBuildException thrown by the per-branch loop or
7083        // post-build validation, truncate both lists back to the snapshot
7084        // and rethrow. This addresses the full class of "mutation-free
7085        // branch validation that fires after earlier-branch mutations":
7086        // FROM-subquery preflight (round 3), column-count check (round 4),
7087        // multi-scalar-projection per-branch validation (round 5), branch
7088        // duplicate-output-names check (round 5), and any future check
7089        // that may join the same class. The pre-loop preflight below
7090        // remains for fast-fail with better error messages on common
7091        // shapes, but the rollback is the safety net.
7092        int stmtsSnapshot = stmts.size();
7093        int lineageSnapshot = lineage.size();
7094        try {
7095            return buildSetOpProgramInternal(setOp, provider, stmts, lineage,
7096                    cteNameToStatementIndex, setOpName,
7097                    hasOuterCteListAlreadyProcessed, setOpRowLimit);
7098        } catch (RuntimeException e) {
7099            while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1);
7100            while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1);
7101            throw e;
7102        }
7103    }
7104
7105    /**
7106     * Internal body of {@link #buildSetOpProgram}. Wrapped with
7107     * snapshot/rollback by the public entry point; do not call directly.
7108     */
7109    private static int buildSetOpProgramInternal(TSelectSqlStatement setOp,
7110                                                  NameBindingProvider provider,
7111                                                  List<StatementGraph> stmts,
7112                                                  List<LineageEdge> lineage,
7113                                                  Map<String, Integer> cteNameToStatementIndex,
7114                                                  String setOpName,
7115                                                  boolean hasOuterCteListAlreadyProcessed,
7116                                                  RowLimit setOpRowLimit) {
7117
7118        SetOperator setOpKind = resolveSetOperator(setOp);
7119        List<TSelectSqlStatement> branches = flattenSetOpTreeIteratively(setOp, setOpKind);
7120        if (branches.size() < 2) {
7121            throw new SemanticIRBuildException(
7122                    Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COUNT_TOO_FEW,
7123                    "set-op flatten produced " + branches.size()
7124                            + " branches; expected at least 2", (TParseTreeNode) null));
7125        }
7126
7127        // Slice 16: PRE-LOOP PREFLIGHT (codex round-3 + round-4 adversarial
7128        // findings — medium). Run all mutation-free branch validation across
7129        // EVERY branch BEFORE the main build loop runs. Without this, a
7130        // scalar-bearing earlier branch can append scalar-body statements to
7131        // `stmts`/`lineage` BEFORE a later branch's rejection fires, leaving
7132        // partial state on the rejection path. The slice-16 safety claim is
7133        // "no half-built scalar bodies leak when a branch is rejected"; that
7134        // claim only holds when ALL mutation-free branch checks are
7135        // set-op-wide.
7136        //
7137        // Checks bundled here (each is AST-only and side-effect-free):
7138        //   - Defensive nested-set-op leaf check (slice 12).
7139        //   - Direct branch FROM-subquery rejection (slice 16 round 3).
7140        //   - Result-column-count compatibility across branches (slice 12,
7141        //     moved here in slice 16 round 4 — uses AST
7142        //     getResultColumnList().size() since BUILT outputColumns.size()
7143        //     is unavailable pre-build; the post-loop check on BUILT
7144        //     outputs stays as a defensive backup if AST-vs-built ever
7145        //     diverges for a future shape).
7146        int expectedAstCols = -1;
7147        for (int i = 0; i < branches.size(); i++) {
7148            TSelectSqlStatement br = branches.get(i);
7149            if (br.getSetOperatorType() != null
7150                    && br.getSetOperatorType() != ESetOperatorType.none) {
7151                throw new SemanticIRBuildException(
7152                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_IS_SET_OP,
7153                        "set-op branch is itself a set operation; nested set "
7154                                + "operations in branches are not supported yet", (TParseTreeNode) null));
7155            }
7156            rejectFromSubqueriesInSetOpBranch(br);
7157            int astCols = br.getResultColumnList() == null
7158                    ? 0
7159                    : br.getResultColumnList().size();
7160            if (i == 0) {
7161                expectedAstCols = astCols;
7162            } else if (astCols != expectedAstCols) {
7163                throw new SemanticIRBuildException(
7164                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COLUMN_COUNT_MISMATCH,
7165                        "set-op branch column-count mismatch: branch[0] has "
7166                                + expectedAstCols + " columns, branch[" + i + "] has "
7167                                + astCols, (TParseTreeNode) null));
7168            }
7169        }
7170
7171        int[] branchIdxs = new int[branches.size()];
7172        for (int i = 0; i < branches.size(); i++) {
7173            TSelectSqlStatement br = branches.get(i);
7174
7175            // Slice 16: per-branch enclosing scope. allowFromSubqueries
7176            // stays false at the branch level so subqueryAliasToIndex
7177            // is empty (no OUTER_REFERENCE-of-SUBQUERY in branches).
7178            // CTE-aware so a branch's scalar can correlate to a CTE
7179            // visible in scope (top-level set-ops have all outer CTEs
7180            // indexed; CTE-body set-ops have only prior visible CTEs
7181            // indexed — the current CTE is registered AFTER this method
7182            // returns, by design, mirroring the non-set-op CTE body path).
7183            EnclosingScope branchEnclosing = buildEnclosingScope(br,
7184                    cteNameToStatementIndex,
7185                    Collections.<String, Integer>emptyMap(),
7186                    /*parent=*/ null);
7187            // Slice 20: pass `false` so the slice-12 / slice-16 boundary
7188            // holds — branch scalar bodies must NOT host another scalar
7189            // projection. The branch's TOP-LEVEL scalar projection is
7190            // still allowed (slice 16); deeper recursion is not.
7191            Map<Integer, ScalarInfo> branchScalarMap =
7192                    extractScalarSubqueriesAsStatements(br, provider,
7193                            stmts, lineage, cteNameToStatementIndex, branchEnclosing,
7194                            /*allowRecursiveScalarSubqueryExtraction=*/ false);
7195
7196            // Slice 16: compute branchName AFTER scalar extraction so the
7197            // digit suffix in `<set_op_branch_<idx>>` matches the branch's
7198            // final position in `stmts`. Scalar bodies appended by
7199            // extractScalarSubqueriesAsStatements come BEFORE the branch
7200            // in `stmts`, so pre-extraction `stmts.size()` would be wrong
7201            // by (number of scalar bodies in this branch) — breaking the
7202            // slice-12 invariant that branch synthetic names round-trip
7203            // to their statement index.
7204            //
7205            // Slice 113 — predicate bodies extracted from the branch's
7206            // WHERE clause via {@link PredicateClauseContext#SET_OP_BRANCH_WHERE}
7207            // also land in {@code stmts} BEFORE the branch, INSIDE the
7208            // {@code buildSelectStatementImpl} call below. So the
7209            // pre-build {@code stmts.size()} can still understate the
7210            // branch's final position. The slice-12/16 invariant is
7211            // preserved by computing a tentative name pre-build (best
7212            // guess used by inner consumers that need a non-null name),
7213            // then rebuilding the StatementGraph with the corrected
7214            // name AFTER the build via {@link #withRenamedTo} if any
7215            // predicate body was extracted.
7216            int preBuildStmtsSize = stmts.size();
7217            String tentativeBranchName = SET_OP_BRANCH_PREFIX + preBuildStmtsSize + ">";
7218            StatementGraph branchStmt;
7219            if (isPivotSelect(br)) {
7220                // Slice 140: a nested PIVOT / UNPIVOT in a set-op branch
7221                // (`SELECT ... FROM base PIVOT(...) UNION ALL ...`). The
7222                // slice-129 pivot router in {@link #buildSelectStatementImpl}
7223                // is gated to `name == null && !isPredicateBody`; a set-op
7224                // branch carries the synthetic `<set_op_branch_N>` name, so
7225                // the gate misses and the branch would otherwise fall to the
7226                // normal path which can't bind a `pivoted_table` (rejects
7227                // with the misleading TABLE_BINDING_UNRESOLVED
7228                // "null(piviot_table)"). Route it to `buildPivotSelect` with
7229                // the synthetic branch name so the branch becomes a proper
7230                // pivot StatementGraph. PIVOT extra-clause / chained /
7231                // malformed-UNPIVOT / bare-`*`-without-catalog rejects stay
7232                // deferred via `buildPivotSelect`'s own guards. The
7233                // FROM-subquery-source case is pre-flight-rejected by the
7234                // extended {@link #rejectFromSubqueriesInSetOpBranch} above,
7235                // so `buildPivotSelect.buildRelation` never lands on an
7236                // unresolvable subquery TTable here. No
7237                // `withRenamedTo` rebuild block is needed below because
7238                // `buildPivotSelect` does not extract predicate bodies during
7239                // its build (its `rejectPivotExtraClauses` fires on WHERE /
7240                // GROUP BY / HAVING / ORDER BY / QUALIFY / DISTINCT /
7241                // row-limit / set-op).
7242                //
7243                // Direct analogue of slice 138 (FROM-subquery body via
7244                // `processDirectSubqueryTable`) and slice 139 (CTE body
7245                // via the four CTE walkers). This is the FIFTH and final
7246                // SemanticIRBuilder routing site that previously missed
7247                // the pivot router.
7248                branchStmt = buildPivotSelect(br, provider, tentativeBranchName);
7249            } else {
7250                branchStmt = buildSelectStatementImpl(br, provider, tentativeBranchName,
7251                        /*hasOuterCteListAlreadyProcessed=*/ false,
7252                        /*allowFromSubqueries=*/ false,
7253                        /*allowScalarProjectionSubqueries=*/ true,   // ← slice-16 lift
7254                        /*allowWindowProjection=*/ true,
7255                        // Slice 113 keeps JOIN-ON predicate subqueries rejected
7256                        // in set-op branches (slice 23 / 26 contract pinned by
7257                        // existsInSetOpBranchJoinOnStillRejected /
7258                        // lhsSubqueryInSetOpBranchRejected) — the lift is
7259                        // WHERE-only. The two flags are now independent.
7260                        /*allowJoinOnPredicateSubqueries=*/ false,
7261                        /*stmtsForExtraction=*/ stmts,               // ← slice-113
7262                        /*lineageForExtraction=*/ lineage,           // ← slice-113
7263                        /*cteMapForExtraction=*/ cteNameToStatementIndex, // ← slice-113
7264                        /*isPredicateBody=*/ false,
7265                        /*whereClauseContext=*/ PredicateClauseContext.SET_OP_BRANCH_WHERE,
7266                        /*allowWherePredicateSubqueries=*/ true);    // ← slice-113 lift
7267            }
7268            int idx = stmts.size();
7269            if (idx != preBuildStmtsSize) {
7270                // Slice 113 — predicate bodies were appended during
7271                // the branch build. Rebuild the StatementGraph with
7272                // the corrected name so the slice-12/16 invariant
7273                // (digit suffix == final position) survives. The
7274                // rebuild copies all 15 fields; no LineageRef is
7275                // affected because they are idx-based, not name-based
7276                // (codex round-1 Q4 resolution).
7277                String finalBranchName = SET_OP_BRANCH_PREFIX + idx + ">";
7278                branchStmt = withRenamedTo(branchStmt, finalBranchName);
7279            }
7280            rejectDuplicateOutputNames(branchStmt, branchStmt.getName());
7281            branchIdxs[i] = idx;
7282            stmts.add(branchStmt);
7283            // Branch's own per-output, filter, and join lineage. Pass
7284            // the branch's scalar map so STATEMENT_OUTPUT →
7285            // STATEMENT_OUTPUT edges to scalar bodies are emitted.
7286            // subqueryAliasToIndex stays empty (allowFromSubqueries=false
7287            // for branches, so no FROM-subquery aliases exist at this scope).
7288            emitLineageForStatement(branchStmt, idx, lineage,
7289                    cteNameToStatementIndex,
7290                    Collections.<String, Integer>emptyMap(),
7291                    branchScalarMap);
7292        }
7293
7294        // Validate column-count alignment via BUILT statements.
7295        int expectedCols = stmts.get(branchIdxs[0]).getOutputColumns().size();
7296        for (int i = 1; i < branches.size(); i++) {
7297            int n = stmts.get(branchIdxs[i]).getOutputColumns().size();
7298            if (n != expectedCols) {
7299                throw new SemanticIRBuildException(
7300                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COLUMN_COUNT_MISMATCH,
7301                        "set-op branch column-count mismatch: branch[0] has "
7302                                + expectedCols + " columns, branch[" + i + "] has " + n, (TParseTreeNode) null));
7303            }
7304        }
7305
7306        // Build outer outputs from branch[0]'s built outputs.
7307        StatementGraph branch0 = stmts.get(branchIdxs[0]);
7308        List<OutputColumn> outerOutputs = new ArrayList<>(expectedCols);
7309        Set<String> seenOuter = new HashSet<>();
7310        for (int i = 0; i < expectedCols; i++) {
7311            OutputColumn b0 = branch0.getOutputColumns().get(i);
7312            String name = b0.getName();
7313            if (name == null || name.isEmpty()) {
7314                throw new SemanticIRBuildException(
7315                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_OUTPUT_NAME_UNUSABLE,
7316                        "set-op output position " + i + " has no usable name in branch[0]; "
7317                                + "add an alias to the SELECT-list expression", (TParseTreeNode) null));
7318            }
7319            if (!seenOuter.add(name.toLowerCase(Locale.ROOT))) {
7320                throw new SemanticIRBuildException(
7321                        Diagnostic.error(DiagnosticCode.SET_OP_DUPLICATE_OUTER_OUTPUT_NAME,
7322                        "set-op outer output name '" + name + "' is duplicated "
7323                                + "(branch[0] has duplicate output names)", null));
7324            }
7325            outerOutputs.add(new OutputColumn(name,
7326                    /*derived=*/ true,
7327                    /*aggregate=*/ false,
7328                    /*sources=*/ Collections.<ColumnRef>emptyList(),
7329                    /*windowSpec=*/ null));
7330        }
7331
7332        // Slice 21: collect outer ORDER BY refs from branches' base sources.
7333        // A throw here unwinds via the slice-16 snapshot/rollback wrapper
7334        // in buildSetOpProgram(), so partially-built branches/scalar-bodies
7335        // do not leak into stmts/lineage on rejection.
7336        List<ColumnRef> outerOrderByRefs = buildSetOpOuterOrderByColumnRefs(
7337                setOp, outerOutputs, stmts, branchIdxs);
7338
7339        StatementGraph outer = new StatementGraph(setOpName, "SELECT",
7340                /*relations=*/ Collections.<RelationSource>emptyList(),
7341                outerOutputs,
7342                /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(),
7343                /*joinColumnRefs=*/  Collections.<ColumnRef>emptyList(),
7344                /*groupByColumnRefs=*/Collections.<ColumnRef>emptyList(),
7345                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
7346                /*orderByColumnRefs=*/outerOrderByRefs,
7347                /*distinctOnColumnRefs=*/Collections.<ColumnRef>emptyList(),
7348                /*distinct=*/ false,
7349                /*setOperator=*/ setOpKind,
7350                /*rowLimit=*/ setOpRowLimit);
7351        // Slice 180 (R5): the set-op outer block's span covers the whole
7352        // set-op statement.
7353        outer = outer.withSourceSpan(SourceSpan.of(setOp));
7354        int outerIdx = stmts.size();
7355        stmts.add(outer);
7356
7357        // Lineage: outer.outputs[i] → each branch.outputs[i] (in branch order).
7358        for (int i = 0; i < expectedCols; i++) {
7359            OutputColumn out = outer.getOutputColumns().get(i);
7360            for (int b = 0; b < branches.size(); b++) {
7361                StatementGraph branchStmt = stmts.get(branchIdxs[b]);
7362                String branchOutName = branchStmt.getOutputColumns().get(i).getName();
7363                lineage.add(new LineageEdge(
7364                        LineageRef.statementOutput(outerIdx, out.getName()),
7365                        LineageRef.statementOutput(branchIdxs[b], branchOutName)));
7366            }
7367        }
7368        return outerIdx;
7369    }
7370
7371    /**
7372     * Reject row-limit clauses on an INTERNAL (non-root) set-op node.
7373     * Slice 9 / 12 rationale: with ORDER BY they decide which rows
7374     * survive, so the canonical-model exclusion of ORDER BY is only
7375     * sound when no row-limit is present.
7376     *
7377     * <p>Slice 21 split this from {@code rejectSetOpInternalOrderBy}
7378     * because the OUTER set-op node lifts its ORDER BY (collected via
7379     * {@link #buildSetOpOuterOrderByColumnRefs}). Slice 72 narrows the
7380     * row-limit guard the same way: the OUTER set-op node now lifts
7381     * row-limit metadata (collected via {@link #buildSetOpRowLimit}),
7382     * while parenthesized inner combined operations carrying a
7383     * row-limit (e.g. {@code (A UNION B LIMIT 3) UNION C}) remain
7384     * rejected because the intermediate limit is destroyed by the
7385     * outer set operation.
7386     */
7387    private static void rejectSetOpRowLimit(TSelectSqlStatement node) {
7388        if (node.getLimitClause() != null) {
7389            throw new SemanticIRBuildException(
7390                    Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED,
7391                    "row-limit clause LIMIT on a non-root set-op node is not supported yet", (TParseTreeNode) null));
7392        }
7393        if (node.getTopClause() != null) {
7394            throw new SemanticIRBuildException(
7395                    Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED,
7396                    "row-limit clause TOP on a non-root set-op node is not supported yet", (TParseTreeNode) null));
7397        }
7398        if (node.getFetchFirstClause() != null) {
7399            throw new SemanticIRBuildException(
7400                    Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED,
7401                    "row-limit clause FETCH FIRST on a non-root set-op node is not supported yet", (TParseTreeNode) null));
7402        }
7403        if (node.getOffsetClause() != null) {
7404            throw new SemanticIRBuildException(
7405                    Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED,
7406                    "row-limit clause OFFSET on a non-root set-op node is not supported yet", (TParseTreeNode) null));
7407        }
7408    }
7409
7410    /**
7411     * Reject ORDER BY on an INTERNAL (non-root) set-op node. Slice 21
7412     * lifted ORDER BY on the OUTER (root) set-op, but parenthesized
7413     * inner combined nodes like
7414     * {@code (A UNION B ORDER BY id) UNION C} remain rejected: the
7415     * intermediate sort is destroyed by the outer set operation
7416     * (UNION does not preserve order), so the inner ORDER BY has no
7417     * observable effect. Lifting requires modelling intermediate sort
7418     * semantics — a future slice.
7419     */
7420    private static void rejectSetOpInternalOrderBy(TSelectSqlStatement node) {
7421        if (node.getOrderbyClause() != null) {
7422            throw new SemanticIRBuildException(
7423                    Diagnostic.error(DiagnosticCode.SET_OP_NON_ROOT_ORDER_BY_NOT_SUPPORTED,
7424                    "ORDER BY on a non-root set-op node is not supported yet "
7425                            + "(intermediate sort would be discarded by the outer set operation)", (TParseTreeNode) null));
7426        }
7427    }
7428
7429    /**
7430     * Collect physical column refs for the outer set-op's ORDER BY
7431     * clause. Slice 21 lifts the slice-12 rejection on set-op outer
7432     * ORDER BY using the slice-9 single-SELECT pattern, generalised:
7433     *
7434     * <ul>
7435     *   <li>Each sort-key item passes the same shape rejections as
7436     *       {@link #buildOrderByColumnRefs} (ordinals, constants,
7437     *       scalar / predicate subqueries, window functions, ORDER
7438     *       SIBLINGS BY, RESET WHEN, in-clause OFFSET/FETCH).
7439     *   <li>Each {@link TObjectName} reference dispatches via a
7440     *       four-case fail-closed taxonomy: {@code column_alias} →
7441     *       lookup via {@code toString()}; unqualified {@code column}
7442     *       → lookup via {@code getColumnNameOnly()}; qualified
7443     *       {@code column} → reject (set-op outer scope is the
7444     *       unioned outputs, not branches' tables); other
7445     *       {@code dbObjectType} → reject as unsupported.
7446     *   <li>The lookup is positional against {@code outerOutputs} (=
7447     *       branch[0].outputs by slice-12 design) — NOT per-branch
7448     *       name search. Per-branch name search would mis-bind
7449     *       swapped-name branches and silently accept names present
7450     *       only in non-branch[0]. Slice-21 codex rounds 1-2 MUSTs.
7451     *   <li>Each branch contributes its
7452     *       {@code outputColumns[pos].sources} for the matched
7453     *       position. Branches with empty sources at the matched
7454     *       position (scalar / fully-derived projection) reject the
7455     *       sort key with a tuned message — silent omission would
7456     *       lose dependency information (slice-21 codex round 1
7457     *       MUST 5).
7458     *   <li>Each branch-local {@link ColumnRef} is normalised to its
7459     *       catalog name via {@link RelationSource#getBinding()}'s
7460     *       {@code qualifiedName}. The set-op outer's relations list
7461     *       is empty, so branch-local aliases are not resolvable in
7462     *       the owning statement; normalisation yields self-contained
7463     *       refs (slice-21 codex round 1 MUST 4 + round 2 MUST 1).
7464     *   <li>A per-item empty-refs guard rejects sort keys that
7465     *       contributed zero physical refs (e.g. {@code ORDER BY
7466     *       1+0}, {@code ORDER BY UPPER('x')}). Mirrors slice-9
7467     *       single-SELECT invariant. Operates on a per-item local
7468     *       set, so duplicate cross-item refs ({@code ORDER BY id,
7469     *       id}) survive global LinkedHashSet de-duplication
7470     *       (slice-21 codex round 4 MUST 1).
7471     * </ul>
7472     *
7473     * <p>Like slice-9 ORDER BY for single-SELECT, this list does NOT
7474     * contribute to the canonical model — it is presentation metadata
7475     * only. The dlineage XML probe ({@code /tmp/SetOpOrderByLimitProbe})
7476     * confirmed dlineage emits no parity edges for set-op outer
7477     * ORDER BY.
7478     */
7479    private static List<ColumnRef> buildSetOpOuterOrderByColumnRefs(
7480            TSelectSqlStatement setOp,
7481            List<OutputColumn> outerOutputs,
7482            List<StatementGraph> stmts,
7483            int[] branchIdxs) {
7484        TOrderBy orderBy = setOp.getOrderbyClause();
7485        if (orderBy == null) {
7486            return new ArrayList<>();
7487        }
7488        if (orderBy.isSiblings()) {
7489            throw new SemanticIRBuildException(
7490                    Diagnostic.error(DiagnosticCode.ORDER_SIBLINGS_BY_NOT_SUPPORTED,
7491                    "ORDER SIBLINGS BY is not supported yet "
7492                            + "(Oracle hierarchical ordering)", orderBy));
7493        }
7494        if (orderBy.getResetWhenCondition() != null) {
7495            throw new SemanticIRBuildException(
7496                    Diagnostic.error(DiagnosticCode.ORDER_BY_RESET_WHEN_NOT_SUPPORTED,
7497                    "ORDER BY ... RESET WHEN is not supported yet "
7498                            + "(Teradata window-style restart)", orderBy));
7499        }
7500        // Slice 72: TOrderBy in-clause OFFSET/FETCH slots are admitted
7501        // for MSSQL set-op outer via buildSetOpRowLimit's TOrderBy
7502        // fallback (the MSSQL parser routes set-op outer OFFSET/FETCH
7503        // EXCLUSIVELY onto TOrderBy, not duplicated onto the SELECT
7504        // node as in single-SELECT). Removing the previous defensive
7505        // throws here so the slice-72 admit shapes aren't false-
7506        // rejected. The unused codes
7507        // ORDER_BY_FETCH_FIRST_NOT_SUPPORTED and
7508        // ORDER_BY_OFFSET_NOT_SUPPORTED stay as documentation of a
7509        // known reject taxonomy.
7510        TOrderByItemList items = orderBy.getItems();
7511        if (items == null || items.size() == 0) {
7512            return new ArrayList<>();
7513        }
7514        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
7515        for (int i = 0; i < items.size(); i++) {
7516            TOrderByItem item = items.getOrderByItem(i);
7517            if (item == null) continue;
7518            TExpression sortKey = item.getSortKey();
7519            if (sortKey == null) continue;
7520            // Same shape rejections as slice-9 single-SELECT.
7521            rejectOrderByOrdinalOrConstant(sortKey);
7522            rejectOrderByScalarSubquery(sortKey);
7523            rejectOrderByWindowFunction(sortKey);
7524            // (NOT rejectOrderByAliasReference — alias refs are valid
7525            // at set-op outer scope; they ARE the branch-output names,
7526            // looked up positionally below.)
7527
7528            // Per-item local set so the empty-refs guard counts refs
7529            // FOUND for this sort key, not refs ADDED to the global
7530            // set after de-dup. Otherwise `ORDER BY id, id` would
7531            // false-reject the second item (slice-21 codex round 4 MUST 1).
7532            LinkedHashSet<ColumnRef> itemRefs = new LinkedHashSet<>();
7533            collectSetOpOuterRefsForSortKey(sortKey, outerOutputs,
7534                    stmts, branchIdxs, itemRefs);
7535            if (itemRefs.isEmpty()) {
7536                throw new SemanticIRBuildException(
7537                        Diagnostic.error(DiagnosticCode.SET_OP_OUTER_ORDER_BY_NO_PHYSICAL_COLUMN_REFS,
7538                        "ORDER BY sort key '" + sortKey
7539                                + "' has no physical column references at set-op "
7540                                + "outer (constant or non-column expressions are "
7541                                + "not supported yet)", sortKey));
7542            }
7543            refs.addAll(itemRefs);
7544        }
7545        return new ArrayList<>(refs);
7546    }
7547
7548    /**
7549     * Collect refs for one set-op outer ORDER BY sort key. Walks the
7550     * sort-key expression for {@link TObjectName} nodes and dispatches
7551     * each through {@link #processSetOpOrderByObjectName}. Includes a
7552     * top-level fast path for the common {@code ORDER BY x} case where
7553     * the entire sort key IS the {@link TObjectName}.
7554     *
7555     * <p>The visitor filters its dispatch to {@code column},
7556     * {@code column_alias}, and {@code unknown} dbObjectTypes — these
7557     * are the shapes that represent sort-key column references. Other
7558     * TObjectName nodes (function names, schema qualifications) are
7559     * part of the surrounding expression structure and skipped
7560     * silently. The {@code unknown} case is included so the four-case
7561     * fail-closed taxonomy in {@link #processSetOpOrderByObjectName}
7562     * rejects vendor-typed unknown qualified refs (e.g.
7563     * {@code foo.id + id}, slice-21 codex round 2 MUST 2).
7564     */
7565    private static void collectSetOpOuterRefsForSortKey(
7566            TExpression sortKey,
7567            final List<OutputColumn> outerOutputs,
7568            final List<StatementGraph> stmts,
7569            final int[] branchIdxs,
7570            final LinkedHashSet<ColumnRef> outRefs) {
7571        // Top-level fast path: the visitor's `acceptChildren` may not
7572        // visit the root TObjectName when the sort key is itself a
7573        // bare TObjectName. Mirrors slice-9 rejectOrderByAliasReference.
7574        if (sortKey.getExpressionType() == EExpressionType.simple_object_name_t) {
7575            TObjectName op = sortKey.getObjectOperand();
7576            if (op != null) {
7577                processSetOpOrderByObjectName(op, outerOutputs, stmts,
7578                        branchIdxs, outRefs);
7579                return;
7580            }
7581        }
7582        sortKey.acceptChildren(new TParseTreeVisitor() {
7583            @Override
7584            public void preVisit(TObjectName node) {
7585                EDbObjectType ot = node.getDbObjectType();
7586                // Skip non-column-like TObjectNames (function names,
7587                // schema/server qualifications). The four-case
7588                // fail-closed taxonomy still runs for column /
7589                // column_alias / unknown to handle the slice-21 codex
7590                // round 2 MUST 2 partial-accept case (e.g.
7591                // `foo.id + id` rejects via `foo.id`'s `unknown`
7592                // dbObjectType).
7593                if (ot != EDbObjectType.column
7594                        && ot != EDbObjectType.column_alias
7595                        && ot != EDbObjectType.unknown) {
7596                    return;
7597                }
7598                processSetOpOrderByObjectName(node, outerOutputs, stmts,
7599                        branchIdxs, outRefs);
7600            }
7601        });
7602    }
7603
7604    /**
7605     * Resolve one {@link TObjectName} sort-key reference at set-op
7606     * outer scope. Four-case fail-closed taxonomy (slice-21 codex
7607     * round 2 MUST 2): column_alias / unqualified column / qualified
7608     * column / other.
7609     */
7610    private static void processSetOpOrderByObjectName(
7611            TObjectName node,
7612            List<OutputColumn> outerOutputs,
7613            List<StatementGraph> stmts,
7614            int[] branchIdxs,
7615            LinkedHashSet<ColumnRef> outRefs) {
7616        EDbObjectType ot = node.getDbObjectType();
7617        String name;
7618        if (ot == EDbObjectType.column_alias) {
7619            // Aliases at set-op outer carry tableToken=alias-name (the
7620            // /tmp/SetOpQualifiedRefProbe finding); accept regardless.
7621            name = node.toString();
7622        } else if (ot == EDbObjectType.column) {
7623            if (node.getTableToken() != null) {
7624                throw new SemanticIRBuildException(
7625                        Diagnostic.error(DiagnosticCode.ORDER_BY_QUALIFIED_REFERENCE_NOT_SUPPORTED,
7626                        "qualified column reference '" + node
7627                                + "' in set-op outer ORDER BY not supported "
7628                                + "(scope is the unioned outputs, not branches' tables)", node));
7629            }
7630            name = node.getColumnNameOnly();
7631        } else {
7632            throw new SemanticIRBuildException(
7633                    Diagnostic.error(DiagnosticCode.ORDER_BY_OBJECT_REFERENCE_UNSUPPORTED,
7634                    "unsupported ORDER BY object reference '" + node
7635                            + "' (dbObjectType=" + ot + ") in set-op outer", node));
7636        }
7637        if (name == null || name.isEmpty() || "*".equals(name)) {
7638            throw new SemanticIRBuildException(
7639                    Diagnostic.error(DiagnosticCode.ORDER_BY_OBJECT_REFERENCE_NO_USABLE_NAME,
7640                    "ORDER BY object reference '" + node + "' has no usable name", node));
7641        }
7642        String key = name.toLowerCase(Locale.ROOT);
7643        int pos = -1;
7644        for (int i = 0; i < outerOutputs.size(); i++) {
7645            String outName = outerOutputs.get(i).getName();
7646            if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) {
7647                pos = i;
7648                break;
7649            }
7650        }
7651        if (pos < 0) {
7652            throw new SemanticIRBuildException(
7653                    Diagnostic.error(DiagnosticCode.ORDER_BY_NAME_NOT_MATCHED_IN_SET_OP_OUTPUT,
7654                    "ORDER BY '" + name + "' does not match any set-op output "
7655                            + "column (set-op outer column names come from branch[0])", node));
7656        }
7657        for (int b = 0; b < branchIdxs.length; b++) {
7658            StatementGraph br = stmts.get(branchIdxs[b]);
7659            OutputColumn oc = br.getOutputColumns().get(pos);
7660            if (oc.getSources().isEmpty()) {
7661                throw new SemanticIRBuildException(
7662                        Diagnostic.error(DiagnosticCode.SET_OP_ORDER_BY_BRANCH_OUTPUT_NO_SOURCES,
7663                        "ORDER BY '" + name + "' references branch[" + b
7664                                + "] output '" + oc.getName()
7665                                + "' which has no physical sources "
7666                                + "(derived/scalar projection); cannot "
7667                                + "capture this dependency yet", node));
7668            }
7669            for (ColumnRef cr : oc.getSources()) {
7670                outRefs.add(normaliseSetOpBranchRef(cr, br));
7671            }
7672        }
7673    }
7674
7675    /**
7676     * Normalise a branch-local {@link ColumnRef} to a self-contained
7677     * ref using the underlying {@link RelationBinding#getQualifiedName()}.
7678     *
7679     * <p>Slice-21 invariant (codex round 2 MUST 1): the set-op outer's
7680     * {@code relations} list is empty. Branch-local aliases (like
7681     * {@code e} for {@code FROM employees e}) are not resolvable in
7682     * the outer statement, so {@code orderByColumnRefs} normalises to
7683     * the catalog name. Fail-closed if no matching RelationSource —
7684     * this would indicate corrupt branch lineage state.
7685     */
7686    private static ColumnRef normaliseSetOpBranchRef(ColumnRef cr,
7687                                                     StatementGraph branch) {
7688        String alias = cr.getRelationAlias();
7689        String aliasKey = alias.toLowerCase(Locale.ROOT);
7690        for (RelationSource rs : branch.getRelations()) {
7691            if (rs.getAlias().toLowerCase(Locale.ROOT).equals(aliasKey)) {
7692                return new ColumnRef(rs.getBinding().getQualifiedName(),
7693                        cr.getColumnName());
7694            }
7695        }
7696        throw new SemanticIRBuildException(
7697                Diagnostic.error(DiagnosticCode.BRANCH_COLUMN_REF_UNKNOWN_RELATION,
7698                "internal: branch ColumnRef relationAlias '" + alias
7699                        + "' does not match any RelationSource in the "
7700                        + "branch's relations list", null));
7701    }
7702
7703    /**
7704     * Reject duplicate output names within a single statement.
7705     * Lineage refs are keyed by {@code (statementIndex, outputName)}; two
7706     * outputs sharing a name silently merge their lineage chains.
7707     */
7708    private static void rejectDuplicateOutputNames(StatementGraph stmt, String label) {
7709        Set<String> seen = new HashSet<>();
7710        for (OutputColumn c : stmt.getOutputColumns()) {
7711            String name = c.getName();
7712            if (name == null || name.isEmpty()) continue;
7713            if (!seen.add(name.toLowerCase(Locale.ROOT))) {
7714                throw new SemanticIRBuildException(
7715                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_DUPLICATE_OUTPUT_NAME,
7716                        "set-op branch '" + label + "' has duplicate output name '"
7717                                + name + "'; lineage refs are keyed by output name "
7718                                + "and would collide", null));
7719            }
7720        }
7721    }
7722
7723    /**
7724     * Map ({@link ESetOperatorType}, {@code isAll()}) to the IR
7725     * {@link SetOperator} enum. The exhaustive switch makes a future
7726     * {@code ESetOperatorType} value fail loudly at build time
7727     * (mirrors slice-8 {@code resolveDistinctFlag} pattern).
7728     */
7729    private static SetOperator resolveSetOperator(TSelectSqlStatement setOp) {
7730        ESetOperatorType type = setOp.getSetOperatorType();
7731        if (type == null) {
7732            throw new SemanticIRBuildException(
7733                    Diagnostic.error(DiagnosticCode.SET_OP_ROOT_TYPE_NULL,
7734                    "expected non-null set-op type on the set-op root", (TParseTreeNode) null));
7735        }
7736        boolean all = setOp.isAll();
7737        switch (type) {
7738            case union:     return all ? SetOperator.UNION_ALL     : SetOperator.UNION;
7739            case intersect: return all ? SetOperator.INTERSECT_ALL : SetOperator.INTERSECT;
7740            case minus:     return all ? SetOperator.MINUS_ALL     : SetOperator.MINUS;
7741            case except:    return all ? SetOperator.EXCEPT_ALL    : SetOperator.EXCEPT;
7742            case none:
7743                throw new SemanticIRBuildException(
7744                        Diagnostic.error(DiagnosticCode.SET_OP_ROOT_TYPE_NONE,
7745                        "expected non-none set operator type on the set-op root", (TParseTreeNode) null));
7746            default:
7747                throw new SemanticIRBuildException(
7748                        Diagnostic.error(DiagnosticCode.SET_OP_UNKNOWN_OPERATOR_TYPE,
7749                        "unknown set operator type: " + type, (TParseTreeNode) null));
7750        }
7751    }
7752
7753    /**
7754     * Iteratively flatten the left-leaning set-op tree into a list of
7755     * leaf SELECT statements (CLAUDE.md mandates no recursion on
7756     * {@code leftStmt}/{@code rightStmt}; would StackOverflow on 2000+
7757     * UNIONs).
7758     *
7759     * <p>On every internal set-op node visited:
7760     * <ol>
7761     *   <li>Reject row-limit modifiers ({@link #rejectSetOpRowLimit})
7762     *       on every node (root + internal). Slice 12 +
7763     *       {@code /tmp/SetOpInnerModifierProbe}: parenthesized inner
7764     *       combined nodes can carry row-limits in Oracle / PostgreSQL /
7765     *       MSSQL.</li>
7766     *   <li>Reject ORDER BY ({@link #rejectSetOpInternalOrderBy}) only
7767     *       on INTERNAL (non-root) nodes. Slice 21 lifted ORDER BY on
7768     *       the root via {@link #buildSetOpOuterOrderByColumnRefs}; an
7769     *       internal {@code (A UNION B ORDER BY id) UNION C} sort is
7770     *       still discarded by the outer set operation, so it has no
7771     *       observable effect and remains rejected.</li>
7772     *   <li>Reject mixed-operator and mixed-{@code ALL} chains by checking
7773     *       the resolved kind matches the root's kind.</li>
7774     *   <li>Hard-reject malformed AST (null left/right child).</li>
7775     * </ol>
7776     *
7777     * <p>Push order is right-then-left so leaves emerge in left-to-right
7778     * declaration order.
7779     */
7780    private static List<TSelectSqlStatement> flattenSetOpTreeIteratively(
7781            TSelectSqlStatement root, SetOperator expected) {
7782        List<TSelectSqlStatement> leaves = new ArrayList<>();
7783        Deque<TSelectSqlStatement> stack = new ArrayDeque<>();
7784        stack.push(root);
7785        while (!stack.isEmpty()) {
7786            TSelectSqlStatement cur = stack.pop();
7787            ESetOperatorType t = cur.getSetOperatorType();
7788            if (t != null && t != ESetOperatorType.none) {
7789                // Slice 21: ORDER BY guard fires only on INTERNAL nodes.
7790                // The root (`cur == root`) lifts ORDER BY; the collection
7791                // happens in buildSetOpOuterOrderByColumnRefs.
7792                // Slice 72: row-limit guard ALSO fires only on INTERNAL
7793                // nodes. The root lifts via buildSetOpRowLimit (called
7794                // by buildSetOpProgram before this method).
7795                if (cur != root) {
7796                    rejectSetOpRowLimit(cur);
7797                    rejectSetOpInternalOrderBy(cur);
7798                }
7799                SetOperator curKind = resolveSetOperator(cur);
7800                if (curKind != expected) {
7801                    throw new SemanticIRBuildException(
7802                            Diagnostic.error(DiagnosticCode.MIXED_SET_OPERATORS_NOT_SUPPORTED,
7803                            "mixed set operators in a single chain are not supported yet "
7804                                    + "(root=" + expected + ", inner=" + curKind + ")", (TParseTreeNode) null));
7805                }
7806                if (cur.getLeftStmt() == null || cur.getRightStmt() == null) {
7807                    throw new SemanticIRBuildException(
7808                            Diagnostic.error(DiagnosticCode.MALFORMED_SET_OP_AST,
7809                            "malformed set-op AST: null left/right child", (TParseTreeNode) null));
7810                }
7811                stack.push(cur.getRightStmt());
7812                stack.push(cur.getLeftStmt());
7813            } else {
7814                leaves.add(cur);
7815            }
7816        }
7817        return leaves;
7818    }
7819
7820    private static Set<String> collectCteNames(TCTEList cteList) {
7821        if (cteList == null || cteList.size() == 0) return Collections.emptySet();
7822        Set<String> names = new HashSet<>();
7823        for (int i = 0; i < cteList.size(); i++) {
7824            String name = cteList.getCTE(i).getTableName().toString();
7825            if (name != null && !name.isEmpty()) {
7826                names.add(name.toLowerCase(Locale.ROOT));
7827            }
7828        }
7829        return names;
7830    }
7831
7832    /**
7833     * Slice 107 — return the first CTE name shared between the outer-WITH
7834     * and inner-WITH CTE lists on an INSERT (case-insensitive, lowercase
7835     * via {@code toLowerCase(Locale.ROOT)} matching the slice-15/103
7836     * duplicate-name walker convention), or {@code null} if the name sets
7837     * are disjoint. Used by {@code buildInsert} to keep the
7838     * shared-name case rejecting (PG/Oracle/Snowflake nested-WITH
7839     * inner-shadows-outer semantics not yet supported) while admitting
7840     * the disjoint case via flat-merge.
7841     *
7842     * <p>Pathological edge case (codex round-2 diff-review Q3): if one of
7843     * the two lists also contains an INTRA-list duplicate AND that
7844     * duplicated name happens to also appear in the other list, this
7845     * helper short-circuits with
7846     * INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED and masks the
7847     * more precise same-scope DUPLICATE_CTE_NAME the slice-103 walker
7848     * would have emitted. Accepted limitation — the diagnostic still
7849     * tells the user the shape is unsupported, and both codes point at
7850     * the same offending name. A future slice can pre-walk each list
7851     * for intra-list duplicates before the boundary check if a
7852     * customer reports confusion.
7853     */
7854    private static String findFirstSharedCteName(TCTEList outer, TCTEList inner) {
7855        Set<String> outerNames = new HashSet<>();
7856        for (int i = 0; i < outer.size(); i++) {
7857            outerNames.add(outer.getCTE(i).getTableName().toString().toLowerCase(Locale.ROOT));
7858        }
7859        for (int i = 0; i < inner.size(); i++) {
7860            String name = inner.getCTE(i).getTableName().toString();
7861            if (outerNames.contains(name.toLowerCase(Locale.ROOT))) {
7862                return name;
7863            }
7864        }
7865        return null;
7866    }
7867
7868    /**
7869     * Reject the case where a CTE body references a sibling CTE declared
7870     * <i>after</i> it. SQL chain semantics only allow left-to-right
7871     * references, but the bind-by-name provider would happily classify a
7872     * forward-declared CTE name as a base {@code TABLE} (because it's not
7873     * yet in {@code visibleSoFar}). Catching it here turns the silent
7874     * mislabeling into a clear error.
7875     */
7876    private static void rejectForwardCteReferences(final TCTE cte,
7877                                                   final Set<String> allCteNames,
7878                                                   final Set<String> visibleSoFar) {
7879        TSelectSqlStatement body = cte.getSubquery();
7880        if (body == null) return;
7881        final String selfName = cte.getTableName().toString().toLowerCase(Locale.ROOT);
7882        final List<String> forwards = new ArrayList<>();
7883        body.acceptChildren(new TParseTreeVisitor() {
7884            @Override
7885            public void preVisit(TTable t) {
7886                String tname = bareName(t);
7887                if (tname == null) return;
7888                String lower = tname.toLowerCase(Locale.ROOT);
7889                if (allCteNames.contains(lower)
7890                        && !visibleSoFar.contains(lower)
7891                        && !lower.equals(selfName)) {
7892                    forwards.add(tname);
7893                }
7894            }
7895        });
7896        if (!forwards.isEmpty()) {
7897            throw new SemanticIRBuildException(
7898                    Diagnostic.error(DiagnosticCode.CTE_FORWARD_REFERENCE,
7899                    "CTE '" + cte.getTableName() + "' forward-references later CTE(s) "
7900                            + forwards + "; only left-to-right CTE chains are supported", cte));
7901        }
7902    }
7903
7904    private static String bareName(TTable t) {
7905        if (t == null) return null;
7906        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) return null;
7907        return t.getName();
7908    }
7909
7910    /**
7911     * Reject {@code WITH RECURSIVE}. Slice 4 supports chained
7912     * (forward-referencing) CTEs; recursion is left for a later slice that
7913     * can model the fixpoint semantics.
7914     */
7915    private static void rejectRecursiveCtes(TCTEList cteList) {
7916        if (cteList == null) return;
7917        for (int i = 0; i < cteList.size(); i++) {
7918            TCTE cte = cteList.getCTE(i);
7919            if (cte.isRecursive()) {
7920                throw new SemanticIRBuildException(
7921                        Diagnostic.error(DiagnosticCode.CTE_WITH_RECURSIVE_NOT_SUPPORTED,
7922                        "WITH RECURSIVE is not supported yet (CTE: " + cte.getTableName() + ")", cte));
7923            }
7924        }
7925    }
7926
7927    /**
7928     * Slice 101 — walk the WITH clause on a MERGE statement and append
7929     * each CTE body to {@code stmts} as a preceding statement. Mirrors
7930     * the SELECT-side build() at lines ~516-653.
7931     *
7932     * <p>Returns a {@code cteNameToStatementIndex} map keyed by
7933     * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated
7934     * with each CTE's output column names so the {@code buildMerge}
7935     * USING-as-CTE branch can install them via
7936     * {@link NameBindingProvider#withInScopeRelationColumns}.
7937     *
7938     * <p>Rejects (chronological):
7939     * <ol>
7940     *   <li>WITH RECURSIVE — reuses {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}.
7941     *       Currently no admitting vendor (PG parser PARSE_FAILED, probe
7942     *       2026-05-17); defensive reject for forward compatibility.</li>
7943     *   <li>CTE with explicit column list — rejects with new
7944     *       {@link DiagnosticCode#MERGE_CTE_EXPLICIT_COLUMN_LIST_NOT_SUPPORTED}.
7945     *       PG and MSSQL parsers admit this shape; slice 101 defers
7946     *       because the inner CTE body output names ≠ user-visible CTE
7947     *       column names.</li>
7948     *   <li>Duplicate CTE name — reuses {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li>
7949     *   <li>Forward CTE reference — reuses {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li>
7950     * </ol>
7951     *
7952     * <p>Set-op CTE bodies route through {@link #buildSetOpProgram};
7953     * non-set-op CTE bodies route through {@link #buildSelectStatement}.
7954     * Each CTE's published columns are added to {@code ctePublishedColumnsOut}
7955     * after its body is built so a CTE cannot self-reference (mirrors
7956     * SELECT-side slice 60).
7957     */
7958    private static Map<String, Integer> buildMergeCteList(
7959            TMergeSqlStatement merge,
7960            NameBindingProvider provider,
7961            List<StatementGraph> stmts,
7962            List<LineageEdge> lineage,
7963            Map<String, List<String>> ctePublishedColumnsOut) {
7964        TCTEList cteList = merge.getCteList();
7965        Map<String, Integer> cteNameToStatementIndex = new HashMap<>();
7966        if (cteList == null || cteList.size() == 0) {
7967            return cteNameToStatementIndex;
7968        }
7969        rejectRecursiveCtes(cteList);
7970        // Slice 102 — explicit-column-list shapes (PG/MSSQL `WITH cte(a, b) AS
7971        // (...) MERGE ...`) are admitted by rebuilding the body's
7972        // StatementGraph with the explicit-list names and rewriting outgoing
7973        // STATEMENT_OUTPUT lineage refs. The slice-101 upfront reject is
7974        // replaced by per-CTE rename application below. The slice-101 code
7975        // (MERGE_CTE_EXPLICIT_COLUMN_LIST_NOT_SUPPORTED) stays declared for
7976        // API stability.
7977        Set<String> allCteNames = collectCteNames(cteList);
7978        Set<String> visibleSoFar = new HashSet<>();
7979        for (int i = 0; i < cteList.size(); i++) {
7980            TCTE cte = cteList.getCTE(i);
7981            String cteName = cte.getTableName().toString();
7982            String cteNameLower = cteName.toLowerCase(Locale.ROOT);
7983            if (visibleSoFar.contains(cteNameLower)) {
7984                throw new SemanticIRBuildException(
7985                        Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME,
7986                        "duplicate CTE name '" + cteName
7987                                + "' in WITH clause; CTE names must be unique",
7988                        cte));
7989            }
7990            rejectForwardCteReferences(cte, allCteNames, visibleSoFar);
7991            NameBindingProvider bodyProvider =
7992                    provider.withCteContext(visibleSoFar);
7993            // Slice 102 — snapshot the lineage size BEFORE either branch so
7994            // the rename helper can rewrite outgoing STATEMENT_OUTPUT refs
7995            // in [lineageSize0, lineage.size()) without touching prior CTE
7996            // bodies' edges. Covers BOTH set-op and non-set-op branches
7997            // (codex round-1 plan-review BLOCKING).
7998            int lineageSize0 = lineage.size();
7999            int bodyIdx;
8000            TSelectSqlStatement cteBody = cte.getSubquery();
8001            if (cteBody != null
8002                    && cteBody.getSetOperatorType() != null
8003                    && cteBody.getSetOperatorType() != ESetOperatorType.none) {
8004                bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts,
8005                        lineage, cteNameToStatementIndex, cteName,
8006                        /*hasOuterCteListAlreadyProcessed=*/ false);
8007                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8008            } else {
8009                int cteStmtsSize0 = stmts.size();
8010                int cteLineageSize0 = lineage.size();
8011                Map<String, Integer> cteSubqueryAliasToIndex;
8012                try {
8013                    cteSubqueryAliasToIndex =
8014                            extractFromSubqueriesAsStatements(cteBody,
8015                                    bodyProvider, stmts, lineage,
8016                                    cteNameToStatementIndex,
8017                                    ctePublishedColumnsOut);
8018                } catch (RuntimeException ex) {
8019                    while (stmts.size() > cteStmtsSize0) {
8020                        stmts.remove(stmts.size() - 1);
8021                    }
8022                    while (lineage.size() > cteLineageSize0) {
8023                        lineage.remove(lineage.size() - 1);
8024                    }
8025                    throw ex;
8026                }
8027                EnclosingScope cteEnclosing = buildEnclosingScope(cteBody,
8028                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8029                        /*parent=*/ null);
8030                Map<Integer, ScalarInfo> cteScalarMap =
8031                        extractScalarSubqueriesAsStatements(cteBody,
8032                                bodyProvider, stmts, lineage,
8033                                cteNameToStatementIndex, cteEnclosing,
8034                                /*allowRecursiveScalarSubqueryExtraction=*/ true);
8035                Map<String, List<String>> cteBodyInScope =
8036                        buildEffectiveAliasInScopeMap(cteBody, bodyProvider,
8037                                ctePublishedColumnsOut,
8038                                cteSubqueryAliasToIndex, stmts);
8039                NameBindingProvider cteBodyProviderWithStar = bodyProvider
8040                        .withInScopeRelationColumns(cteBodyInScope);
8041                // Slice 114 — switch to buildSelectStatementImpl with
8042                // snapshot/rollback (see the matching SELECT-side
8043                // CTE site for full rationale).
8044                int cteBodyStmtsSnapshot = stmts.size();
8045                int cteBodyLineageSnapshot = lineage.size();
8046                StatementGraph body;
8047                try {
8048                    if (isPivotSelect(cteBody)) {
8049                        // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body
8050                        // (mirrors the SELECT-side buildSelectCteList branch).
8051                        // The slice-129 pivot router in buildSelectStatementImpl
8052                        // is gated to the OUTER SELECT (name == null), so a pivot
8053                        // built here (name == cteName) would otherwise fall to the
8054                        // normal path and reject with TABLE_BINDING_UNRESOLVED
8055                        // ("null(piviot_table)"). The pivot CTE becomes its own
8056                        // StatementGraph; the DML's CTE-as-relation path (fed from
8057                        // ctePublishedColumns ← body.getOutputColumns()) carries
8058                        // the cross-stmt lineage. All pivot deferrals preserved by
8059                        // buildPivotSelect's own guards.
8060                        body = buildPivotSelect(cteBody,
8061                                cteBodyProviderWithStar, cteName);
8062                    } else {
8063                        body = buildSelectStatementImpl(cteBody,
8064                            cteBodyProviderWithStar, cteName,
8065                            /*hasOuterCteListAlreadyProcessed=*/ false,
8066                            /*allowFromSubqueries=*/ true,
8067                            /*allowScalarProjectionSubqueries=*/ true,
8068                            /*allowWindowProjection=*/ true,
8069                            /*allowJoinOnPredicateSubqueries=*/ false,
8070                            /*stmtsForExtraction=*/ stmts,
8071                            /*lineageForExtraction=*/ lineage,
8072                            /*cteMapForExtraction=*/ cteNameToStatementIndex,
8073                            /*isPredicateBody=*/ false,
8074                            /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE,
8075                            /*allowWherePredicateSubqueries=*/ true);
8076                    }
8077                } catch (RuntimeException ex) {
8078                    while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
8079                    while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
8080                    throw ex;
8081                }
8082                bodyIdx = stmts.size();
8083                stmts.add(body);
8084                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8085                emitLineageForStatement(body, bodyIdx, lineage,
8086                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8087                        cteScalarMap);
8088            }
8089            // Slice 102 — apply explicit-column-list rename if present.
8090            // Rebuilds stmts[bodyIdx] with renamed OutputColumns and
8091            // rewrites STATEMENT_OUTPUT(bodyIdx, oldName) refs in
8092            // lineage[lineageSize0..) to use the renamed name. Returns the
8093            // published column list (renamed if explicit list applied,
8094            // else inner names from the body).
8095            List<String> publishedCols = applyExplicitCteColumnListRename(
8096                    cte, stmts, lineage, bodyIdx, lineageSize0, "MERGE");
8097            ctePublishedColumnsOut.put(cteNameLower, publishedCols);
8098            visibleSoFar.add(cteNameLower);
8099        }
8100        return cteNameToStatementIndex;
8101    }
8102
8103    /**
8104     * Slice 105 — walk the WITH clause on an UPDATE statement and append
8105     * each CTE body to {@code stmts} as a preceding statement. Mirrors
8106     * the slice-101 MERGE walker {@link #buildMergeCteList} verbatim
8107     * except for the source of the CTE list and the
8108     * {@link #applyExplicitCteColumnListRename} {@code dmlKind} argument.
8109     *
8110     * <p>Returns a {@code cteNameToStatementIndex} map keyed by
8111     * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated
8112     * with each CTE's output column names so {@link #buildUpdateRelation}
8113     * + {@link #buildUpdateInScopeMap} can route FROM-side references to
8114     * the matching CTE as SUBQUERY-kind relations with the CTE's columns
8115     * published into the in-scope map.
8116     *
8117     * <p>The slice-103 SELECT-side CTE walker contract is reused via the
8118     * {@link #applyExplicitCteColumnListRename} helper with
8119     * {@code dmlKind="SELECT"} so the SELECT-side
8120     * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} code
8121     * fires on arity mismatch (codex round-1 Q2 confirmed YES — UPDATE is
8122     * closer to ordinary SELECT than to MERGE for CTE rename semantics).
8123     *
8124     * <p>Rejects (chronological):
8125     * <ol>
8126     *   <li>{@code WITH RECURSIVE} — {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}.
8127     *       Currently no admitting vendor (Oracle PARSE_FAILED on outer-WITH-UPDATE).</li>
8128     *   <li>Duplicate CTE name — {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li>
8129     *   <li>Forward CTE reference — {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li>
8130     *   <li>Explicit-column-list arity mismatch — handled by
8131     *       {@link #applyExplicitCteColumnListRename} via
8132     *       {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.</li>
8133     * </ol>
8134     */
8135    private static Map<String, Integer> buildUpdateCteList(
8136            TUpdateSqlStatement update,
8137            NameBindingProvider provider,
8138            List<StatementGraph> stmts,
8139            List<LineageEdge> lineage,
8140            Map<String, List<String>> ctePublishedColumnsOut) {
8141        TCTEList cteList = update.getCteList();
8142        Map<String, Integer> cteNameToStatementIndex = new HashMap<>();
8143        if (cteList == null || cteList.size() == 0) {
8144            return cteNameToStatementIndex;
8145        }
8146        rejectRecursiveCtes(cteList);
8147        Set<String> allCteNames = collectCteNames(cteList);
8148        Set<String> visibleSoFar = new HashSet<>();
8149        for (int i = 0; i < cteList.size(); i++) {
8150            TCTE cte = cteList.getCTE(i);
8151            String cteName = cte.getTableName().toString();
8152            String cteNameLower = cteName.toLowerCase(Locale.ROOT);
8153            if (visibleSoFar.contains(cteNameLower)) {
8154                throw new SemanticIRBuildException(
8155                        Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME,
8156                        "duplicate CTE name '" + cteName
8157                                + "' in WITH clause; CTE names must be unique",
8158                        cte));
8159            }
8160            rejectForwardCteReferences(cte, allCteNames, visibleSoFar);
8161            NameBindingProvider bodyProvider =
8162                    provider.withCteContext(visibleSoFar);
8163            int lineageSize0 = lineage.size();
8164            int bodyIdx;
8165            TSelectSqlStatement cteBody = cte.getSubquery();
8166            if (cteBody != null
8167                    && cteBody.getSetOperatorType() != null
8168                    && cteBody.getSetOperatorType() != ESetOperatorType.none) {
8169                bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts,
8170                        lineage, cteNameToStatementIndex, cteName,
8171                        /*hasOuterCteListAlreadyProcessed=*/ false);
8172                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8173            } else {
8174                int cteStmtsSize0 = stmts.size();
8175                int cteLineageSize0 = lineage.size();
8176                Map<String, Integer> cteSubqueryAliasToIndex;
8177                try {
8178                    cteSubqueryAliasToIndex =
8179                            extractFromSubqueriesAsStatements(cteBody,
8180                                    bodyProvider, stmts, lineage,
8181                                    cteNameToStatementIndex,
8182                                    ctePublishedColumnsOut);
8183                } catch (RuntimeException ex) {
8184                    while (stmts.size() > cteStmtsSize0) {
8185                        stmts.remove(stmts.size() - 1);
8186                    }
8187                    while (lineage.size() > cteLineageSize0) {
8188                        lineage.remove(lineage.size() - 1);
8189                    }
8190                    throw ex;
8191                }
8192                EnclosingScope cteEnclosing = buildEnclosingScope(cteBody,
8193                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8194                        /*parent=*/ null);
8195                Map<Integer, ScalarInfo> cteScalarMap =
8196                        extractScalarSubqueriesAsStatements(cteBody,
8197                                bodyProvider, stmts, lineage,
8198                                cteNameToStatementIndex, cteEnclosing,
8199                                /*allowRecursiveScalarSubqueryExtraction=*/ true);
8200                Map<String, List<String>> cteBodyInScope =
8201                        buildEffectiveAliasInScopeMap(cteBody, bodyProvider,
8202                                ctePublishedColumnsOut,
8203                                cteSubqueryAliasToIndex, stmts);
8204                NameBindingProvider cteBodyProviderWithStar = bodyProvider
8205                        .withInScopeRelationColumns(cteBodyInScope);
8206                // Slice 114 — switch to buildSelectStatementImpl with
8207                // snapshot/rollback (see the matching SELECT-side
8208                // CTE site for full rationale).
8209                int cteBodyStmtsSnapshot = stmts.size();
8210                int cteBodyLineageSnapshot = lineage.size();
8211                StatementGraph body;
8212                try {
8213                    if (isPivotSelect(cteBody)) {
8214                        // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body
8215                        // (mirrors the SELECT-side buildSelectCteList branch).
8216                        // The slice-129 pivot router in buildSelectStatementImpl
8217                        // is gated to the OUTER SELECT (name == null), so a pivot
8218                        // built here (name == cteName) would otherwise fall to the
8219                        // normal path and reject with TABLE_BINDING_UNRESOLVED
8220                        // ("null(piviot_table)"). The pivot CTE becomes its own
8221                        // StatementGraph; the DML's CTE-as-relation path (fed from
8222                        // ctePublishedColumns ← body.getOutputColumns()) carries
8223                        // the cross-stmt lineage. All pivot deferrals preserved by
8224                        // buildPivotSelect's own guards.
8225                        body = buildPivotSelect(cteBody,
8226                                cteBodyProviderWithStar, cteName);
8227                    } else {
8228                        body = buildSelectStatementImpl(cteBody,
8229                            cteBodyProviderWithStar, cteName,
8230                            /*hasOuterCteListAlreadyProcessed=*/ false,
8231                            /*allowFromSubqueries=*/ true,
8232                            /*allowScalarProjectionSubqueries=*/ true,
8233                            /*allowWindowProjection=*/ true,
8234                            /*allowJoinOnPredicateSubqueries=*/ false,
8235                            /*stmtsForExtraction=*/ stmts,
8236                            /*lineageForExtraction=*/ lineage,
8237                            /*cteMapForExtraction=*/ cteNameToStatementIndex,
8238                            /*isPredicateBody=*/ false,
8239                            /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE,
8240                            /*allowWherePredicateSubqueries=*/ true);
8241                    }
8242                } catch (RuntimeException ex) {
8243                    while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
8244                    while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
8245                    throw ex;
8246                }
8247                bodyIdx = stmts.size();
8248                stmts.add(body);
8249                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8250                emitLineageForStatement(body, bodyIdx, lineage,
8251                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8252                        cteScalarMap);
8253            }
8254            // Slice 105 — explicit column-list rename uses dmlKind="SELECT"
8255            // so the SELECT-side CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH
8256            // code fires (codex Q2 confirmed YES — UPDATE is closer to
8257            // ordinary SELECT than MERGE for CTE rename semantics).
8258            List<String> publishedCols = applyExplicitCteColumnListRename(
8259                    cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT");
8260            ctePublishedColumnsOut.put(cteNameLower, publishedCols);
8261            visibleSoFar.add(cteNameLower);
8262        }
8263        return cteNameToStatementIndex;
8264    }
8265
8266    /**
8267     * Slice 106 — walk the WITH clause on a DELETE statement and append
8268     * each CTE body to {@code stmts} as a preceding statement. Mirrors
8269     * the slice-105 UPDATE walker {@link #buildUpdateCteList} verbatim
8270     * except for the source of the CTE list ({@code delete.getCteList()}).
8271     *
8272     * <p>Returns a {@code cteNameToStatementIndex} map keyed by
8273     * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated
8274     * with each CTE's output column names so {@link #buildDeleteRelation}
8275     * + {@link #buildDeleteInScopeMap} can route FROM-side references to
8276     * the matching CTE as SUBQUERY-kind relations with the CTE's columns
8277     * published into the in-scope map.
8278     *
8279     * <p>The slice-103 SELECT-side CTE walker contract is reused via the
8280     * {@link #applyExplicitCteColumnListRename} helper with
8281     * {@code dmlKind="SELECT"} so the SELECT-side
8282     * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} code
8283     * fires on arity mismatch (slice-105 precedent: UPDATE/DELETE are
8284     * closer to ordinary SELECT than to MERGE for CTE rename semantics).
8285     *
8286     * <p>Rejects (chronological):
8287     * <ol>
8288     *   <li>{@code WITH RECURSIVE} —
8289     *       {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}.
8290     *       PG / MySQL admit the parse shape but slice 106 rejects at the
8291     *       semantic layer (mirrors slice-105 boundary).</li>
8292     *   <li>Duplicate CTE name — {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li>
8293     *   <li>Forward CTE reference — {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li>
8294     *   <li>Explicit-column-list arity mismatch — handled by
8295     *       {@link #applyExplicitCteColumnListRename} via
8296     *       {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.</li>
8297     * </ol>
8298     */
8299    private static Map<String, Integer> buildDeleteCteList(
8300            TDeleteSqlStatement delete,
8301            NameBindingProvider provider,
8302            List<StatementGraph> stmts,
8303            List<LineageEdge> lineage,
8304            Map<String, List<String>> ctePublishedColumnsOut) {
8305        TCTEList cteList = delete.getCteList();
8306        Map<String, Integer> cteNameToStatementIndex = new HashMap<>();
8307        if (cteList == null || cteList.size() == 0) {
8308            return cteNameToStatementIndex;
8309        }
8310        rejectRecursiveCtes(cteList);
8311        Set<String> allCteNames = collectCteNames(cteList);
8312        Set<String> visibleSoFar = new HashSet<>();
8313        for (int i = 0; i < cteList.size(); i++) {
8314            TCTE cte = cteList.getCTE(i);
8315            String cteName = cte.getTableName().toString();
8316            String cteNameLower = cteName.toLowerCase(Locale.ROOT);
8317            if (visibleSoFar.contains(cteNameLower)) {
8318                throw new SemanticIRBuildException(
8319                        Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME,
8320                        "duplicate CTE name '" + cteName
8321                                + "' in WITH clause; CTE names must be unique",
8322                        cte));
8323            }
8324            rejectForwardCteReferences(cte, allCteNames, visibleSoFar);
8325            NameBindingProvider bodyProvider =
8326                    provider.withCteContext(visibleSoFar);
8327            int lineageSize0 = lineage.size();
8328            int bodyIdx;
8329            TSelectSqlStatement cteBody = cte.getSubquery();
8330            if (cteBody != null
8331                    && cteBody.getSetOperatorType() != null
8332                    && cteBody.getSetOperatorType() != ESetOperatorType.none) {
8333                bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts,
8334                        lineage, cteNameToStatementIndex, cteName,
8335                        /*hasOuterCteListAlreadyProcessed=*/ false);
8336                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8337            } else {
8338                int cteStmtsSize0 = stmts.size();
8339                int cteLineageSize0 = lineage.size();
8340                Map<String, Integer> cteSubqueryAliasToIndex;
8341                try {
8342                    cteSubqueryAliasToIndex =
8343                            extractFromSubqueriesAsStatements(cteBody,
8344                                    bodyProvider, stmts, lineage,
8345                                    cteNameToStatementIndex,
8346                                    ctePublishedColumnsOut);
8347                } catch (RuntimeException ex) {
8348                    while (stmts.size() > cteStmtsSize0) {
8349                        stmts.remove(stmts.size() - 1);
8350                    }
8351                    while (lineage.size() > cteLineageSize0) {
8352                        lineage.remove(lineage.size() - 1);
8353                    }
8354                    throw ex;
8355                }
8356                EnclosingScope cteEnclosing = buildEnclosingScope(cteBody,
8357                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8358                        /*parent=*/ null);
8359                Map<Integer, ScalarInfo> cteScalarMap =
8360                        extractScalarSubqueriesAsStatements(cteBody,
8361                                bodyProvider, stmts, lineage,
8362                                cteNameToStatementIndex, cteEnclosing,
8363                                /*allowRecursiveScalarSubqueryExtraction=*/ true);
8364                Map<String, List<String>> cteBodyInScope =
8365                        buildEffectiveAliasInScopeMap(cteBody, bodyProvider,
8366                                ctePublishedColumnsOut,
8367                                cteSubqueryAliasToIndex, stmts);
8368                NameBindingProvider cteBodyProviderWithStar = bodyProvider
8369                        .withInScopeRelationColumns(cteBodyInScope);
8370                // Slice 114 — switch to buildSelectStatementImpl with
8371                // snapshot/rollback (see the matching SELECT-side
8372                // CTE site for full rationale).
8373                int cteBodyStmtsSnapshot = stmts.size();
8374                int cteBodyLineageSnapshot = lineage.size();
8375                StatementGraph body;
8376                try {
8377                    if (isPivotSelect(cteBody)) {
8378                        // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body
8379                        // (mirrors the SELECT-side buildSelectCteList branch).
8380                        // The slice-129 pivot router in buildSelectStatementImpl
8381                        // is gated to the OUTER SELECT (name == null), so a pivot
8382                        // built here (name == cteName) would otherwise fall to the
8383                        // normal path and reject with TABLE_BINDING_UNRESOLVED
8384                        // ("null(piviot_table)"). The pivot CTE becomes its own
8385                        // StatementGraph; the DML's CTE-as-relation path (fed from
8386                        // ctePublishedColumns ← body.getOutputColumns()) carries
8387                        // the cross-stmt lineage. All pivot deferrals preserved by
8388                        // buildPivotSelect's own guards.
8389                        body = buildPivotSelect(cteBody,
8390                                cteBodyProviderWithStar, cteName);
8391                    } else {
8392                        body = buildSelectStatementImpl(cteBody,
8393                            cteBodyProviderWithStar, cteName,
8394                            /*hasOuterCteListAlreadyProcessed=*/ false,
8395                            /*allowFromSubqueries=*/ true,
8396                            /*allowScalarProjectionSubqueries=*/ true,
8397                            /*allowWindowProjection=*/ true,
8398                            /*allowJoinOnPredicateSubqueries=*/ false,
8399                            /*stmtsForExtraction=*/ stmts,
8400                            /*lineageForExtraction=*/ lineage,
8401                            /*cteMapForExtraction=*/ cteNameToStatementIndex,
8402                            /*isPredicateBody=*/ false,
8403                            /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE,
8404                            /*allowWherePredicateSubqueries=*/ true);
8405                    }
8406                } catch (RuntimeException ex) {
8407                    while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
8408                    while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
8409                    throw ex;
8410                }
8411                bodyIdx = stmts.size();
8412                stmts.add(body);
8413                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8414                emitLineageForStatement(body, bodyIdx, lineage,
8415                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8416                        cteScalarMap);
8417            }
8418            // Slice 106 — explicit column-list rename uses dmlKind="SELECT"
8419            // so the SELECT-side CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH
8420            // code fires (slice-105 precedent: UPDATE/DELETE are closer
8421            // to ordinary SELECT than MERGE for CTE rename semantics).
8422            List<String> publishedCols = applyExplicitCteColumnListRename(
8423                    cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT");
8424            ctePublishedColumnsOut.put(cteNameLower, publishedCols);
8425            visibleSoFar.add(cteNameLower);
8426        }
8427        return cteNameToStatementIndex;
8428    }
8429
8430    /**
8431     * Slice 105 — combine the slice-83 subqueryAliasToIndex with the
8432     * slice-105 CTE-as-FROM-relation alias→cteIdx entries so
8433     * {@link #emitUpdateSubquerySourceEdges} produces cross-stmt
8434     * lineage edges for SET RHS references resolving to a CTE column.
8435     *
8436     * <p>Without this merge the visible {@link OutputColumn#getSources}
8437     * stays correct (CTE refs surface as {@link ColumnRef}s) but
8438     * {@code lineage[]} silently loses the canonical
8439     * {@code STATEMENT_OUTPUT(update,col) → STATEMENT_OUTPUT(cte,col)}
8440     * edge (codex round-2 Q5 silent-correctness bug).
8441     *
8442     * <p>Walks {@code update.getJoins()} the same way
8443     * {@link #buildUpdateRelation} does to keep the alias resolution
8444     * identical: CTE-bound FROM-side relations are detected by their
8445     * bare name (case-insensitive) and registered under their effective
8446     * alias. Subquery aliases stay keyed lowercase to match the
8447     * slice-83 contract.
8448     */
8449    private static Map<String, Integer> buildUpdateCombinedAliasToSubIdx(
8450            TUpdateSqlStatement update,
8451            Map<String, Integer> subqueryAliasToIndex,
8452            Map<String, Integer> cteNameToStatementIndex) {
8453        Map<String, Integer> combined = new HashMap<>();
8454        if (subqueryAliasToIndex != null) {
8455            combined.putAll(subqueryAliasToIndex);
8456        }
8457        if (cteNameToStatementIndex == null
8458                || cteNameToStatementIndex.isEmpty()) {
8459            return combined;
8460        }
8461        TJoinList joins = update.getJoins();
8462        if (joins == null) return combined;
8463        for (TJoin join : joins) {
8464            addCteAliasToCombinedMap(join.getTable(),
8465                    cteNameToStatementIndex, combined);
8466            TJoinItemList items = join.getJoinItems();
8467            if (items == null) continue;
8468            for (int i = 0; i < items.size(); i++) {
8469                TJoinItem item = items.getJoinItem(i);
8470                if (item == null) continue;
8471                addCteAliasToCombinedMap(item.getTable(),
8472                        cteNameToStatementIndex, combined);
8473            }
8474        }
8475        return combined;
8476    }
8477
8478    private static void addCteAliasToCombinedMap(TTable t,
8479            Map<String, Integer> cteNameToStatementIndex,
8480            Map<String, Integer> combined) {
8481        if (t == null) return;
8482        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) {
8483            return;
8484        }
8485        TObjectName tName = t.getTableName();
8486        if (tName == null) return;
8487        String bare = tName.toString();
8488        if (bare == null || bare.isEmpty()) return;
8489        String bareLower = bare.toLowerCase(Locale.ROOT);
8490        Integer cteIdx = cteNameToStatementIndex.get(bareLower);
8491        if (cteIdx == null) return;
8492        String aliasKey = effectiveAliasLowerCaseOrNull(t);
8493        if (aliasKey == null) aliasKey = bareLower;
8494        combined.put(aliasKey, cteIdx);
8495    }
8496
8497    /**
8498     * Slice 123 — DELETE analogue of
8499     * {@link #buildUpdateCombinedAliasToSubIdx}: combine the slice-84
8500     * {@code subqueryAliasToIndex} (USING-(SELECT) FROM-subqueries) with the
8501     * slice-106 CTE-as-FROM-relation alias&rarr;cteIdx entries so the
8502     * slice-85 {@link #buildReturningColumns} walker can promote a
8503     * RETURNING/OUTPUT source ref that resolves to a SUBQUERY-kind FROM-side
8504     * relation to a cross-stmt {@code STATEMENT_OUTPUT → STATEMENT_OUTPUT}
8505     * edge.
8506     *
8507     * <p>Walks {@code delete.getReferenceJoins()} (NOT {@code getJoins()},
8508     * which catches MySQL self-ref / multi-target shapes that still reject)
8509     * the same way {@link #buildDeleteRelation} does so alias resolution is
8510     * identical. Subquery aliases stay keyed lowercase (slice-84 contract);
8511     * CTE-as-relation entries are keyed by their effective alias via the
8512     * shared {@link #addCteAliasToCombinedMap} helper.
8513     *
8514     * <p>The {@code putAll} then {@code put} order is collision-free: the two
8515     * entry sources are disjoint in valid SQL. {@code subqueryAliasToIndex}
8516     * keys are aliases of {@code (SELECT …) sub} FROM-subqueries (non-{@code
8517     * objectname} table sources), while {@code addCteAliasToCombinedMap} only
8518     * adds {@code objectname}-typed tables whose bare name matches a declared
8519     * CTE — a single alias cannot be both, and two FROM relations sharing one
8520     * alias is a SQL error the parser/resolver rejects. Mirrors the slice-105
8521     * {@link #buildUpdateCombinedAliasToSubIdx} contract.
8522     */
8523    private static Map<String, Integer> buildDeleteCombinedAliasToSubIdx(
8524            TDeleteSqlStatement delete,
8525            Map<String, Integer> subqueryAliasToIndex,
8526            Map<String, Integer> cteNameToStatementIndex) {
8527        Map<String, Integer> combined = new HashMap<>();
8528        if (subqueryAliasToIndex != null) {
8529            combined.putAll(subqueryAliasToIndex);
8530        }
8531        if (cteNameToStatementIndex == null
8532                || cteNameToStatementIndex.isEmpty()) {
8533            return combined;
8534        }
8535        TJoinList refJoins = delete.getReferenceJoins();
8536        if (refJoins == null) return combined;
8537        for (int ji = 0; ji < refJoins.size(); ji++) {
8538            TJoin join = refJoins.getJoin(ji);
8539            addCteAliasToCombinedMap(join.getTable(),
8540                    cteNameToStatementIndex, combined);
8541            TJoinItemList items = join.getJoinItems();
8542            if (items == null) continue;
8543            for (int i = 0; i < items.size(); i++) {
8544                TJoinItem item = items.getJoinItem(i);
8545                if (item == null) continue;
8546                addCteAliasToCombinedMap(item.getTable(),
8547                        cteNameToStatementIndex, combined);
8548            }
8549        }
8550        return combined;
8551    }
8552
8553    /**
8554     * Slice 102 / Slice 103 — when a WITH-clause CTE declares an explicit
8555     * column list ({@code WITH cte(a, b) AS (SELECT x, y FROM t)}), rebuild
8556     * {@code stmts[bodyIdx]} so its {@link OutputColumn} names match the
8557     * explicit list at each ordinal and rewrite outgoing
8558     * {@link LineageRef.Kind#STATEMENT_OUTPUT} refs in
8559     * {@code lineage[lineageSize0..lineage.size())} so the inner-projection
8560     * names are replaced by the explicit-list names.
8561     *
8562     * <p>Returns the published column list for the caller's
8563     * {@code ctePublishedColumns} map: the renamed list when an explicit list
8564     * is present; otherwise the body's inner names (matching pre-slice-102
8565     * behavior). Slice 103 reuses this helper from the outer SELECT CTE
8566     * walker via {@code dmlKind="SELECT"} (slice-100 cross-DML reuse
8567     * precedent).
8568     *
8569     * <p>Rejects:
8570     * <ul>
8571     *   <li>Arity mismatch — explicit-list size != body output count →
8572     *       {@link DiagnosticCode#MERGE_CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}
8573     *       when {@code dmlKind="MERGE"}, otherwise
8574     *       {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.
8575     *       Slice 103 cannot rename the MERGE-side code (it is pinned by
8576     *       {@code Slice102Test.valueOfPinsResolveBothCodes} and adopting
8577     *       it on the SELECT side would also miswire the message text);
8578     *       the SELECT-side gets its own parallel code (codex round-1
8579     *       plan-review BLOCKING).</li>
8580     *   <li>Duplicate explicit name ({@code WITH cte(a, a) AS ...}) →
8581     *       {@link DiagnosticCode#DUPLICATE_OUTPUT_NAME}. STATEMENT_OUTPUT
8582     *       refs are keyed by output name; duplicates would collide
8583     *       (codex round-2 plan-review advisory).</li>
8584     * </ul>
8585     *
8586     * <p>{@link OutputColumn} and {@link StatementGraph} are immutable; the
8587     * rebuild uses the slice-85 15-arg primary constructor copying every
8588     * field unchanged except {@code outputColumns}. {@link LineageEdge} and
8589     * {@link LineageRef} are immutable; the rewrite walker constructs new
8590     * instances and replaces them in the mutable {@code lineage} list via
8591     * {@link List#set}.
8592     */
8593    private static List<String> applyExplicitCteColumnListRename(
8594            TCTE cte,
8595            List<StatementGraph> stmts,
8596            List<LineageEdge> lineage,
8597            int bodyIdx,
8598            int lineageSize0,
8599            String dmlKind) {
8600        StatementGraph body = stmts.get(bodyIdx);
8601        if (cte.getColumnList() == null || cte.getColumnList().size() == 0) {
8602            return outputColumnNames(body);
8603        }
8604        // Materialize the explicit list of renamed names (in declaration order).
8605        boolean isMerge = "MERGE".equals(dmlKind);
8606        String dmlLabel = isMerge ? "MERGE CTE" : "CTE";
8607        String withClauseLabel = isMerge ? "MERGE WITH clause CTE" : "WITH clause CTE";
8608        List<String> renamed = new ArrayList<>(cte.getColumnList().size());
8609        Set<String> seenLower = new HashSet<>();
8610        for (int k = 0; k < cte.getColumnList().size(); k++) {
8611            TObjectName col = cte.getColumnList().getObjectName(k);
8612            String name = (col == null) ? null : col.getColumnNameOnly();
8613            if (name == null || name.isEmpty()) {
8614                // Defensive — parser normally fills these; if not, fall
8615                // back to a synthetic name so the constructor invariant
8616                // (non-empty name) holds, and the arity check still works.
8617                name = "col" + (k + 1);
8618            }
8619            String lower = name.toLowerCase(Locale.ROOT);
8620            if (!seenLower.add(lower)) {
8621                throw new SemanticIRBuildException(Diagnostic.error(
8622                        DiagnosticCode.DUPLICATE_OUTPUT_NAME,
8623                        "duplicate column name '" + name + "' in " + dmlLabel + " '"
8624                                + cte.getTableName()
8625                                + "' explicit column list; output names must "
8626                                + "be unique within a CTE published column list",
8627                        cte));
8628            }
8629            renamed.add(name);
8630        }
8631        List<OutputColumn> bodyOutputs = body.getOutputColumns();
8632        if (bodyOutputs.size() != renamed.size()) {
8633            DiagnosticCode arityCode = isMerge
8634                    ? DiagnosticCode.MERGE_CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH
8635                    : DiagnosticCode.CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH;
8636            throw new SemanticIRBuildException(Diagnostic.error(
8637                    arityCode,
8638                    withClauseLabel + " '" + cte.getTableName()
8639                            + "' declares " + renamed.size()
8640                            + " explicit column(s) but the body's SELECT "
8641                            + "publishes " + bodyOutputs.size() + " column(s); "
8642                            + "the explicit list must have exactly one entry "
8643                            + "per body output column",
8644                    cte));
8645        }
8646        // Capture the old → new name mapping by ordinal BEFORE building the
8647        // new OutputColumns, so the lineage rewrite can look up the
8648        // substitution by old (inner) name. Codex round-1 diff-review
8649        // (non-blocking → upgraded to defensive guard): if the body has
8650        // duplicate inner output names (e.g. `SELECT id, id`), name-keyed
8651        // rewrite collapses both old refs to the last mapping and
8652        // produces wrong lineage. The IR contract already states output
8653        // names must be unique (see DUPLICATE_OUTPUT_NAME javadoc and the
8654        // line-4378 scalar-subquery guard) but is not enforced
8655        // generically. Reject here so explicit-rename paths cannot
8656        // silently break lineage.
8657        Set<String> seenInnerLower = new HashSet<>();
8658        for (OutputColumn oc : bodyOutputs) {
8659            String n = oc.getName();
8660            if (n == null || n.isEmpty()) continue;
8661            String lower = n.toLowerCase(Locale.ROOT);
8662            if (!seenInnerLower.add(lower)) {
8663                throw new SemanticIRBuildException(Diagnostic.error(
8664                        DiagnosticCode.DUPLICATE_OUTPUT_NAME,
8665                        dmlLabel + " '" + cte.getTableName()
8666                                + "' body publishes duplicate inner column "
8667                                + "name '" + n + "'; the explicit column "
8668                                + "list rename requires unique inner names "
8669                                + "because lineage refs are keyed by output "
8670                                + "name and would collide",
8671                        cte));
8672            }
8673        }
8674        Map<String, String> oldToNewLower = new HashMap<>();
8675        List<OutputColumn> newOutputs = new ArrayList<>(bodyOutputs.size());
8676        for (int k = 0; k < bodyOutputs.size(); k++) {
8677            OutputColumn oc = bodyOutputs.get(k);
8678            String oldName = oc.getName();
8679            String newName = renamed.get(k);
8680            if (oldName != null && !oldName.isEmpty()) {
8681                oldToNewLower.put(oldName.toLowerCase(Locale.ROOT), newName);
8682            }
8683            newOutputs.add(new OutputColumn(newName, oc.isDerived(),
8684                    oc.isAggregate(), oc.getSources(), oc.getWindowSpec()));
8685        }
8686        // Rebuild the body's StatementGraph using the slice-85 15-arg primary
8687        // constructor — copies every field (including the slice-85
8688        // returningColumns slot per codex round-1 plan-review BLOCKING)
8689        // except outputColumns.
8690        StatementGraph renamedBody = new StatementGraph(
8691                body.getName(), body.getKind(), body.getRelations(),
8692                newOutputs, body.getReturningColumns(),
8693                body.getFilterColumnRefs(), body.getJoinColumnRefs(),
8694                body.getGroupByColumnRefs(), body.getHavingColumnRefs(),
8695                body.getOrderByColumnRefs(), body.getDistinctOnColumnRefs(),
8696                body.isDistinct(), body.getSetOperator(), body.getRowLimit(),
8697                body.getTarget())
8698                // Slice 180 (R5): the rebuild via the legacy ctor would strip
8699                // the block span the original carried; preserve it so the
8700                // renamed body keeps its scope span. (The legacy ctor also
8701                // resets joinAnalysisFacts; that pre-existing facts-strip is
8702                // left as-is here — out of R5 scope — to avoid changing the
8703                // emitted JSON for these renamed bodies.)
8704                .withSourceSpan(body.getSourceSpan());
8705        stmts.set(bodyIdx, renamedBody);
8706        // Rewrite outgoing STATEMENT_OUTPUT refs in the window. Both `from`
8707        // and `to` are checked because edges can place the body-output ref
8708        // on either side (producer-side: from=TABLE_COLUMN, to=STATEMENT_OUTPUT;
8709        // consumer-side from a deeper inner stmt: from=STATEMENT_OUTPUT,
8710        // to=STATEMENT_OUTPUT — neither shape today places bodyIdx on
8711        // `from` for THIS body, but the symmetric check is cheap and
8712        // future-proof). LineageRef and LineageEdge are immutable, so new
8713        // instances are constructed and `lineage.set` replaces in place.
8714        for (int idx = lineageSize0; idx < lineage.size(); idx++) {
8715            LineageEdge edge = lineage.get(idx);
8716            LineageRef from = edge.getFrom();
8717            LineageRef to = edge.getTo();
8718            LineageRef newFrom = maybeRewriteStatementOutputRef(
8719                    from, bodyIdx, oldToNewLower);
8720            LineageRef newTo = maybeRewriteStatementOutputRef(
8721                    to, bodyIdx, oldToNewLower);
8722            if (newFrom != from || newTo != to) {
8723                lineage.set(idx, new LineageEdge(newFrom, newTo));
8724            }
8725        }
8726        return Collections.unmodifiableList(renamed);
8727    }
8728
8729    /**
8730     * Slice 113 — copy a {@link StatementGraph} with a new {@code name}
8731     * field. Every other field is preserved verbatim. Used by the
8732     * set-op branch loop to assign the synthetic
8733     * {@code <set_op_branch_<idx>>} name AFTER the branch build, in case
8734     * the branch's WHERE-side predicate-subquery extraction
8735     * (slice 113 via {@link PredicateClauseContext#SET_OP_BRANCH_WHERE})
8736     * appended predicate-body statements to {@code stmts}, which would
8737     * otherwise leave the pre-computed digit suffix lagging behind the
8738     * branch's final position.
8739     *
8740     * <p>The rebuild is purely cosmetic on the {@link StatementGraph#getName()}
8741     * field. No {@link LineageRef} is affected because all lineage refs
8742     * are idx-based (see {@link LineageRef#statementOutput(int, String)}),
8743     * not name-based. {@code outputColumns}, {@code relations},
8744     * {@code filterColumnRefs}, {@code joinColumnRefs} and every other
8745     * field are reused unchanged.
8746     */
8747    private static StatementGraph withRenamedTo(StatementGraph s, String newName) {
8748        return new StatementGraph(newName, s.getKind(),
8749                s.getRelations(), s.getOutputColumns(), s.getReturningColumns(),
8750                s.getFilterColumnRefs(), s.getJoinColumnRefs(),
8751                s.getGroupByColumnRefs(), s.getHavingColumnRefs(),
8752                s.getOrderByColumnRefs(), s.getDistinctOnColumnRefs(),
8753                s.isDistinct(), s.getSetOperator(), s.getRowLimit(),
8754                s.getTarget())
8755                // Slice 180 (R5): preserve the block span the legacy ctor
8756                // would otherwise drop, so this rename keeps the scope span.
8757                .withSourceSpan(s.getSourceSpan());
8758    }
8759
8760    /**
8761     * Slice 102 — return a new STATEMENT_OUTPUT {@link LineageRef} with the
8762     * output name substituted when {@code ref} targets {@code bodyIdx} and
8763     * its current output name is a key in {@code oldToNewLower}. Otherwise
8764     * return {@code ref} unchanged (identity-comparable so the caller can
8765     * skip the {@code lineage.set} for no-op rewrites).
8766     */
8767    private static LineageRef maybeRewriteStatementOutputRef(
8768            LineageRef ref, int bodyIdx,
8769            Map<String, String> oldToNewLower) {
8770        if (ref == null) return null;
8771        if (ref.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) return ref;
8772        if (ref.getStatementIndex() != bodyIdx) return ref;
8773        String oldName = ref.getOutputName();
8774        if (oldName == null || oldName.isEmpty()) return ref;
8775        String newName = oldToNewLower.get(oldName.toLowerCase(Locale.ROOT));
8776        if (newName == null) return ref;
8777        return LineageRef.statementOutput(bodyIdx, newName);
8778    }
8779
8780    /**
8781     * Emit one lineage edge per (output, source) pair. Edges target a
8782     * {@link LineageRef.Kind#STATEMENT_OUTPUT} when the source's relation
8783     * is a CTE or a FROM-clause subquery, or a
8784     * {@link LineageRef.Kind#TABLE_COLUMN} when it's a base table.
8785     * Multi-source derived columns produce one edge per source.
8786     *
8787     * <p>{@code subqueryAliasToStatementIndex} is statement-local; the
8788     * caller supplies the alias map for this statement's own FROM list.
8789     * That avoids cross-scope alias collisions.
8790     */
8791    private static void emitLineageForStatement(StatementGraph stmt,
8792                                                int statementIndex,
8793                                                List<LineageEdge> lineage,
8794                                                Map<String, Integer> cteNameToStatementIndex,
8795                                                Map<String, Integer> subqueryAliasToStatementIndex,
8796                                                Map<Integer, ScalarInfo> ordinalToScalarInfo) {
8797        // Slice 87: lowercase alias keys so SQL identifiers written with
8798        // different casing in the FROM clause vs. SELECT qualifier resolve
8799        // correctly (e.g. `SELECT t.name FROM employees T`). Mirrors the
8800        // same fix in emitUpdateSubquerySourceEdges (slice 83). When two
8801        // relations collide after lowercasing (unusual, but not guaranteed
8802        // caught by the duplicate-alias preflight in all call paths per
8803        // codex Q1 advisory), last-write-wins — the same policy as slice 83.
8804        Map<String, RelationSource> aliasToRelation = new HashMap<>();
8805        for (RelationSource r : stmt.getRelations()) {
8806            String key = r.getAlias();
8807            if (key == null || key.isEmpty()) continue;
8808            aliasToRelation.put(key.toLowerCase(Locale.ROOT), r);
8809        }
8810        List<OutputColumn> outputs = stmt.getOutputColumns();
8811        for (int outOrdinal = 0; outOrdinal < outputs.size(); outOrdinal++) {
8812            OutputColumn out = outputs.get(outOrdinal);
8813            // Slice 11: scalar-subquery projections have empty sources
8814            // by construction; their lineage edge is a single
8815            // STATEMENT_OUTPUT → STATEMENT_OUTPUT pointing at the
8816            // extracted scalar body's only output. Emit it once and
8817            // skip the per-source loop (which would be a no-op anyway).
8818            ScalarInfo scalar = ordinalToScalarInfo.get(outOrdinal);
8819            if (scalar != null) {
8820                lineage.add(new LineageEdge(
8821                        LineageRef.statementOutput(statementIndex, out.getName()),
8822                        LineageRef.statementOutput(scalar.statementIndex,
8823                                scalar.innerOutputName)));
8824                continue;
8825            }
8826            for (ColumnRef src : out.getSources()) {
8827                String srcAlias = src.getRelationAlias();
8828                RelationSource rel = aliasToRelation.get(
8829                        srcAlias == null ? null : srcAlias.toLowerCase(Locale.ROOT));
8830                if (rel == null) {
8831                    throw new SemanticIRBuildException(
8832                            Diagnostic.error(DiagnosticCode.OUTPUT_REFERENCES_UNKNOWN_RELATION,
8833                            "output '" + out.getName() + "' references unknown relation '"
8834                                    + src.getRelationAlias() + "'", null));
8835                }
8836                LineageRef from = LineageRef.statementOutput(statementIndex, out.getName());
8837                LineageRef to;
8838                // Slice 15: resolved-kind dispatch. For OUTER_REFERENCE
8839                // bindings the underlying outerKind decides which
8840                // table-column or statement-output edge we emit.
8841                // Codex round-1 MUST 2 / round-2 MUST 1: exhaustive
8842                // dispatch instead of catch-all.
8843                RelationKind kind = rel.getBinding().getKind();
8844                RelationKind resolvedKind = (kind == RelationKind.OUTER_REFERENCE)
8845                        ? rel.getBinding().getOuterKind()
8846                        : kind;
8847                if (resolvedKind == RelationKind.CTE) {
8848                    Integer cteIndex = cteNameToStatementIndex.get(
8849                            rel.getBinding().getQualifiedName().toLowerCase(Locale.ROOT));
8850                    if (cteIndex == null) {
8851                        throw new SemanticIRBuildException(
8852                                Diagnostic.error(DiagnosticCode.CTE_BODY_MISSING,
8853                                "CTE '" + rel.getBinding().getQualifiedName() + "' has no body statement", null));
8854                    }
8855                    to = LineageRef.statementOutput(cteIndex, src.getColumnName());
8856                } else if (resolvedKind == RelationKind.SUBQUERY) {
8857                    Integer subIndex = subqueryAliasToStatementIndex.get(
8858                            rel.getAlias().toLowerCase(Locale.ROOT));
8859                    if (subIndex == null) {
8860                        throw new SemanticIRBuildException(
8861                                Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_BINDING_UNRESOLVED,
8862                                "FROM-clause subquery '" + rel.getAlias()
8863                                        + "' has no body statement registered", null));
8864                    }
8865                    to = LineageRef.statementOutput(subIndex, src.getColumnName());
8866                } else if (resolvedKind == RelationKind.TABLE
8867                        || resolvedKind == RelationKind.FUNCTION) {
8868                    // FUNCTION (table-valued function) is an opaque source:
8869                    // lineage terminates at the function's qualified name,
8870                    // same shape as a base table column.
8871                    to = LineageRef.tableColumn(
8872                            rel.getBinding().getQualifiedName(),
8873                            src.getColumnName());
8874                } else {
8875                    throw new SemanticIRBuildException(
8876                            Diagnostic.error(DiagnosticCode.OUTPUT_REFERENCES_UNSUPPORTED_BINDING_KIND,
8877                            "output '" + out.getName()
8878                                    + "' references relation '" + rel.getAlias()
8879                                    + "' with unsupported binding kind " + kind
8880                                    + (kind == RelationKind.OUTER_REFERENCE
8881                                        ? " (outerKind=" + rel.getBinding().getOuterKind() + ")"
8882                                        : ""), null));
8883                }
8884                lineage.add(new LineageEdge(from, to));
8885            }
8886        }
8887    }
8888
8889    /**
8890     * Build one SELECT statement (CTE body or outer). The {@code name}
8891     * argument is non-null for a CTE body, null otherwise. When
8892     * {@code hasOuterCteListAlreadyProcessed} is true, the SELECT's own
8893     * {@code getCteList()} is not rejected because the caller has already
8894     * extracted those CTEs into separate statements; in all other cases a
8895     * non-empty CTE list on this node is rejected (so nested WITH inside
8896     * a CTE body does not silently slip through).
8897     */
8898    private static StatementGraph buildSelectStatement(TSelectSqlStatement select,
8899                                                       NameBindingProvider provider,
8900                                                       String name,
8901                                                       boolean hasOuterCteListAlreadyProcessed,
8902                                                       boolean allowFromSubqueries,
8903                                                       boolean allowScalarProjectionSubqueries,
8904                                                       boolean allowWindowProjection) {
8905        // Slice 23: legacy 7-arg call site. Predicate-subquery extraction is
8906        // disabled (allowJoinOnPredicateSubqueries=false) and this is not a
8907        // predicate body itself (isPredicateBody=false). All non-outer call
8908        // sites use this overload — the slice-17 `rejectSubqueriesInJoinOn`
8909        // continues to fire at every non-outer JOIN-ON site.
8910        return buildSelectStatementImpl(select, provider, name,
8911                hasOuterCteListAlreadyProcessed,
8912                allowFromSubqueries,
8913                allowScalarProjectionSubqueries,
8914                allowWindowProjection,
8915                /*allowJoinOnPredicateSubqueries=*/ false,
8916                /*stmtsForExtraction=*/ null,
8917                /*lineageForExtraction=*/ null,
8918                /*cteMapForExtraction=*/ null,
8919                /*isPredicateBody=*/ false,
8920                /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE,
8921                /*allowWherePredicateSubqueries=*/ false);
8922    }
8923
8924    /**
8925     * Internal body shared between the legacy 7-arg overload and the
8926     * outer-SELECT entry point used by {@link #build}. Slice 23 added two new
8927     * concepts; slice 24 added one more.
8928     * <ul>
8929     *   <li>{@code allowJoinOnPredicateSubqueries} + {@code stmts}/{@code lineage}
8930     *       — when {@code allow...} is {@code true}, JOIN-ON uncorrelated
8931     *       EXISTS subqueries are extracted as their own
8932     *       {@code <predicate_subquery_<i>>} statements appended to
8933     *       {@code stmts} (slice-11/12 synthetic-name pattern). Outer-SELECT
8934     *       entry only.</li>
8935     *   <li>{@code isPredicateBody} — when {@code true}, this statement IS
8936     *       the inner SELECT of an extracted EXISTS body. The constant-only
8937     *       projection rejection in {@link #buildOutputColumns} is bypassed
8938     *       and a single synthetic OutputColumn is emitted in its place.</li>
8939     *   <li>{@code cteMapForExtraction} (slice 24) — outer's CTE
8940     *       name-to-statement-index map, plumbed in only when
8941     *       {@code allowJoinOnPredicateSubqueries=true}. Required so the
8942     *       slice-24 column-bearing inner projection can emit
8943     *       STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edges into outer-
8944     *       visible CTE bodies. Non-outer call sites pass {@code null}.</li>
8945     * </ul>
8946     */
8947    private static StatementGraph buildSelectStatementImpl(
8948            TSelectSqlStatement select,
8949            NameBindingProvider provider,
8950            String name,
8951            boolean hasOuterCteListAlreadyProcessed,
8952            boolean allowFromSubqueries,
8953            boolean allowScalarProjectionSubqueries,
8954            boolean allowWindowProjection,
8955            boolean allowJoinOnPredicateSubqueries,
8956            List<StatementGraph> stmtsForExtraction,
8957            List<LineageEdge> lineageForExtraction,
8958            Map<String, Integer> cteMapForExtraction,
8959            boolean isPredicateBody,
8960            PredicateClauseContext whereClauseContext,
8961            boolean allowWherePredicateSubqueries) {
8962        rejectUnsupportedShape(select, hasOuterCteListAlreadyProcessed);
8963        // Slice 129: PIVOT sub-slice (a). A SELECT whose single FROM source
8964        // is a PIVOT routes to the dedicated pivot builder, which binds the
8965        // underlying source relation and captures the consumed FOR /
8966        // aggregation-arg columns into StatementGraph.pivotColumnRefs. Scoped
8967        // to the outer SELECT context (name == null, not a predicate body);
8968        // nested / non-outer PIVOTs are deferred to a later sub-slice and
8969        // fall through to the normal path (which rejects with the existing
8970        // TABLE_BINDING_UNRESOLVED as before).
8971        if (name == null && !isPredicateBody && isPivotSelect(select)) {
8972            return buildPivotSelect(select, provider, name);
8973        }
8974        // Slice 143: a PIVOT/UNPIVOT driver that ALSO has a JOIN partner
8975        // (e.g. FROM sales PIVOT(...) AS p JOIN dim d ON ...) trips
8976        // isPivotSelect's "no trailing JOIN" guard and would otherwise fall to
8977        // the normal path and reject with the GENERIC TABLE_BINDING_UNRESOLVED.
8978        // Emit a PRECISE deferral reusing the existing
8979        // PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED code (no new lineage admitted),
8980        // mirroring the precise chained-PIVOT reject.
8981        //
8982        // Slice 145: this precise routing no longer carries the slice-143/144
8983        // `name == null && !isPredicateBody` guard, so it also fires in NESTED
8984        // contexts (CTE body / FROM-subquery body / set-op branch /
8985        // predicate-subquery body) where a pivot-with-join reaches
8986        // buildSelectStatementImpl under a non-null name (or isPredicateBody).
8987        // The slice-129 ADMISSION router above keeps its outer-only guard —
8988        // nested ADMITTED pivots (isPivotSelect) are routed to buildPivotSelect
8989        // at their own call sites and never reach here; only NON-admitted
8990        // pivot-with-join shapes do, which were previously rejected with the
8991        // generic TABLE_BINDING_UNRESOLVED. The helpers only match when a
8992        // pivoted_table is genuinely present, so a plain JOIN is untouched.
8993        if (isPivotWithJoinPartner(select)) {
8994            TTable pivotTable = select.joins.getJoin(0).getTable();
8995            // Slice 146: name the ACTUAL operator (PIVOT vs UNPIVOT) so an
8996            // UNPIVOT-then-JOIN does not mis-report "PIVOT".
8997            throw new SemanticIRBuildException(
8998                    Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
8999                    pivotOperatorKeyword(pivotTable) + " combined with a JOIN is not supported yet",
9000                    pivotTable));
9001        }
9002        // Slice 144: the symmetric positions — a PIVOT/UNPIVOT appearing as a
9003        // JOIN PARTNER rather than the FROM driver: the right-hand side of an
9004        // explicit JOIN (FROM dim JOIN sales PIVOT(...) ON ...) or a comma-join
9005        // sibling (FROM sales PIVOT(...), dim). Slice 143 closed the
9006        // pivot-as-driver case; these shapes previously fell to the normal path
9007        // and rejected with the GENERIC TABLE_BINDING_UNRESOLVED. Emit the same
9008        // PRECISE deferral reusing PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED (no new
9009        // lineage admitted). Slice 145 (see above) lifts the outer-only guard so
9010        // this also fires for a pivot join partner in any nesting context.
9011        TTable pivotPartner = findPivotJoinPartner(select);
9012        if (pivotPartner != null) {
9013            // Slice 146: name the ACTUAL operator (PIVOT vs UNPIVOT).
9014            throw new SemanticIRBuildException(
9015                    Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
9016                    pivotOperatorKeyword(pivotPartner) + " combined with a JOIN is not supported yet",
9017                    pivotPartner));
9018        }
9019        boolean distinct = resolveDistinctFlag(select);
9020        // Slice 65 — reset using scope at entry so a parent SELECT's
9021        // scope cannot leak into recursive nested builds. The using
9022        // scope for THIS SELECT is installed only AFTER buildRelations
9023        // completes (see below) so the predicate-subquery extraction
9024        // walk inside buildRelations does not inherit the outer scope
9025        // (codex slice-65 diff-review round-1 P2 #1: an inner
9026        // {@code EXISTS (SELECT SUM(x.v) FILTER (WHERE k > 0) FROM x)}
9027        // would have its bare `k` expand to outer's merged sources,
9028        // causing a valid uncorrelated body to be rejected as
9029        // correlated). The slice-64 → 65 JOIN-ON merged-key reject
9030        // also runs BEFORE buildRelations so ON-clause refs aren't
9031        // collected with a stale or future scope.
9032        provider = provider.withUsingScope(UsingScope.EMPTY);
9033        // Reset the join-graph structural anchor at entry (mirrors the
9034        // UsingScope reset above) so a parent SELECT's anchor cannot leak into
9035        // this recursive nested build. It is re-installed below for THIS
9036        // SELECT's own FROM clause once relations are bound.
9037        provider = provider.withJoinStructureAnchor(false);
9038        rejectUnqualifiedMergedKeyInJoinOn(select, provider);
9039        List<ColumnRef> joinRefs = new ArrayList<>();
9040        List<RelationSource> relations;
9041        if (isSetOpBranchSyntheticName(name)
9042                && hasNoFromSource(select)
9043                && allResultColumnsAreConstantExpressions(select)) {
9044            // Slice 61: allow FROM-less constant-only set-op branches
9045            // such as SELECT 1 UNION ALL SELECT 2. The general SELECT
9046            // boundary remains unchanged: non-branch SELECT 1 still
9047            // fails in buildRelations with "must have at least one
9048            // FROM source".
9049            relations = Collections.emptyList();
9050        } else {
9051            relations = buildRelations(select, provider, joinRefs,
9052                    allowFromSubqueries,
9053                    allowJoinOnPredicateSubqueries,
9054                    stmtsForExtraction, lineageForExtraction, cteMapForExtraction);
9055        }
9056        // Slice 65 — install this SELECT's own using scope AFTER
9057        // buildRelations / predicate-subquery extraction. From here
9058        // forward the clause collectors (output / filter / groupBy /
9059        // having / orderBy) see the merged-key scope for THIS SELECT.
9060        UsingScope ownScope = buildUsingScope(select, provider);
9061        if (!ownScope.isEmpty()) {
9062            provider = provider.withUsingScope(ownScope);
9063        }
9064        // Install the join-graph structural anchor for THIS SELECT: two or more
9065        // bound FROM endpoints mean the join graph is fully determined (an
9066        // unqualified column can only be NON_EXACT when ≥2 candidate relations
9067        // exist, and reaching here means every relation bound — buildRelations
9068        // throws TABLE_BINDING_UNRESOLVED otherwise). The anchor lets
9069        // rejectNonExactBindings degrade an unqualified, catalog-less column
9070        // miss over an explicit ON / CROSS / comma join to a warning instead of
9071        // discarding the whole statement graph — the same treatment the USING
9072        // merged-key anchor already gives.
9073        //
9074        // CATALOG-LESS GATE: the degrade exists only because, WITHOUT a catalog,
9075        // we cannot know which side an unqualified column belongs to. When every
9076        // FROM relation has a known column list (catalog or in-scope derived
9077        // columns), the catalog CAN adjudicate — an unqualified column matching
9078        // no side is a genuine error and must stay fatal (a multi-relation
9079        // resolver returns a null binding either way, so the reject result alone
9080        // cannot distinguish the two). So anchor only when at least one endpoint's
9081        // columns are unknown.
9082        if (relations.size() >= 2 && !fromRelationColumnsFullyKnown(select, provider)) {
9083            provider = provider.withJoinStructureAnchor(true);
9084        }
9085        List<OutputColumn> outputColumns = buildOutputColumns(select, provider,
9086                allowScalarProjectionSubqueries, allowWindowProjection,
9087                isPredicateBody, name);
9088        // Slice 112 — thread the SELECT path's outer extraction context
9089        // through buildFilterColumnRefs so top-level SELECT WHERE can
9090        // lift uncorrelated predicate-subquery wrappers via the
9091        // slice-23+ extraction pipeline (PredicateClauseContext.SELECT_WHERE).
9092        // Slice 113 — the same threading extends to set-op branch WHERE
9093        // via PredicateClauseContext.SET_OP_BRANCH_WHERE, distinguished
9094        // only by clauseLabel for diagnostic messages (codes are shared).
9095        //
9096        // {@code allowWherePredicateSubqueries} is INDEPENDENT of
9097        // {@code allowJoinOnPredicateSubqueries} (slice 113 split):
9098        // set-op branches admit WHERE-side predicate subqueries while
9099        // KEEPING JOIN-ON predicate subqueries rejected (slice 23 / 26
9100        // contract — pinned by Slice23Test#existsInSetOpBranchJoinOnStillRejected
9101        // and Slice26Test#lhsSubqueryInSetOpBranchRejected). Nested
9102        // SELECTs without extraction context
9103        // (allowWherePredicateSubqueries=false) keep the slice-80
9104        // blanket reject inside buildFilterColumnRefs.
9105        // R8 — collector for predicate-derived semi-joins (EXISTS / IN /
9106        // NOT EXISTS / NOT IN in WHERE). Populated by buildFilterColumnRefs'
9107        // extraction; converted to SEMI / ANTI_SEMI JoinEntities at
9108        // JoinGraph assembly below (which has `relations` to resolve the
9109        // outer left endpoint).
9110        List<SemiJoinFact> semiFacts = new ArrayList<>();
9111        // R8 — the outer FROM relation aliases (lower-cased) gate the
9112        // correlated-EXISTS degrade: only a correlation to a genuine outer
9113        // relation degrades; an unknown alias still rejects.
9114        Set<String> outerRelationAliases = new HashSet<>();
9115        for (RelationSource rs : relations) {
9116            if (rs.getAlias() != null && !rs.getAlias().isEmpty()) {
9117                outerRelationAliases.add(rs.getAlias().toLowerCase(Locale.ROOT));
9118            }
9119        }
9120        List<ColumnRef> filterRefs = buildFilterColumnRefs(select, provider,
9121                allowWherePredicateSubqueries,
9122                stmtsForExtraction, lineageForExtraction, cteMapForExtraction,
9123                whereClauseContext, semiFacts, outerRelationAliases);
9124        List<ColumnRef> groupByRefs = buildGroupByColumnRefs(select, provider);
9125        List<ColumnRef> havingRefs = buildHavingColumnRefs(select, provider);
9126        List<ColumnRef> orderByRefs = buildOrderByColumnRefs(select, provider, outputColumns);
9127        // Slice 73: DISTINCT ON refs collected here so they observe the
9128        // same {@code provider} (with UsingScope already installed) used
9129        // by buildGroupByColumnRefs / buildHavingColumnRefs /
9130        // buildOrderByColumnRefs. This keeps `DISTINCT ON (k)` over
9131        // `JOIN ... USING (k)` consistent with slice-65 merged-key
9132        // semantics and prevents parent-scope leakage into nested
9133        // builds.
9134        List<ColumnRef> distinctOnRefs = buildDistinctOnColumnRefs(select, provider);
9135        // Slice 125: QUALIFY filter refs. Needs outputColumns for the
9136        // projection-alias form (QUALIFY rn = 1); observes the same
9137        // provider (with UsingScope already installed) as the other
9138        // clause-ref builders so QUALIFY over JOIN ... USING (k) stays
9139        // consistent with slice-65 merged-key semantics.
9140        List<ColumnRef> qualifyRefs = buildQualifyColumnRefs(select, provider, outputColumns);
9141        // Slice 128: structured GROUP BY view. Built after
9142        // buildGroupByColumnRefs so its slice-61/slice-13 guards have
9143        // already fired; observes the same provider as the other clause-ref
9144        // builders. Empty unless the statement has a GROUP BY.
9145        List<GroupingElement> groupingElements = buildGroupingElements(select, provider);
9146        RowLimit rowLimit = buildRowLimit(select);
9147        StatementGraph sg = new StatementGraph(name, "SELECT", relations, outputColumns,
9148                filterRefs, joinRefs, groupByRefs, havingRefs, orderByRefs,
9149                distinctOnRefs,
9150                qualifyRefs,
9151                groupingElements,
9152                distinct,
9153                /*setOperator=*/ null,
9154                rowLimit);
9155        // Slice 179 (R5): carry this block's own source span (covers the
9156        // full select [... where ...]). All SELECT blocks — main, CTE bodies,
9157        // FROM-subquery bodies — funnel through this site, so each gets its
9158        // span from its own parse node. Read by attachQueryBlockScopes.
9159        sg = sg.withSourceSpan(SourceSpan.of(select));
9160        // Slice 167 (S6): attach the ordered structured JoinGraph (GAP 1).
9161        // Slice 169 (S8): attach the WHERE filter predicate tree (GAP 2/3).
9162        // Both additive — the flat joinColumnRefs / filterColumnRefs above
9163        // are untouched.
9164        JoinGraph joinGraph = buildJoinGraph(select, provider);
9165        // R8 — append predicate-derived semi-join entities (EXISTS / IN /
9166        // NOT EXISTS / NOT IN) after the FROM-clause joins, continuing the
9167        // order numbering. The left endpoint is the outer relation owning
9168        // the correlated outer column (falling back to the first FROM
9169        // relation); the right endpoint is the lifted subquery block.
9170        joinGraph = appendSemiJoins(joinGraph, semiFacts, relations);
9171        // Oracle-style implicit-join predicate promotion: when a WHERE conjunct
9172        // links both sides of a comma-join (IMPLICIT_CROSS) entity, move it from
9173        // the filter list into that join's conditions and upgrade the join to
9174        // INNER. A two-sided predicate — equi, non-equi, or a BETWEEN/band range
9175        // across both relations — is a join condition, not a cartesian filter.
9176        // The entity keeps sourceSyntax=COMMA so its implicit origin stays
9177        // distinguishable from an explicit ON join. Single-sided predicates
9178        // remain filters; an IMPLICIT_CROSS with no linking predicate stays a
9179        // true cross product.
9180        TExpression implicitWhere = (select.getWhereClause() != null)
9181                ? select.getWhereClause().getCondition() : null;
9182        List<Predicate> filterPredicates;
9183        if (implicitWhere != null && hasImplicitCrossJoin(joinGraph)) {
9184            ImplicitJoinPromotion promo = promoteImplicitJoinConditions(
9185                    joinGraph, implicitWhere, provider);
9186            joinGraph = promo.graph;
9187            filterPredicates = promo.filterPredicates;
9188        } else {
9189            filterPredicates = buildFilterPredicates(select, provider);
9190        }
9191        JoinAnalysisFacts facts = sg.getJoinAnalysisFacts();
9192        if (!joinGraph.isEmpty()) {
9193            facts = facts.withJoinGraph(joinGraph);
9194        }
9195        if (!filterPredicates.isEmpty()) {
9196            facts = facts.withFilterPredicates(filterPredicates);
9197        }
9198        if (!facts.isEmpty()) {
9199            sg = sg.withJoinAnalysisFacts(facts);
9200        }
9201        return sg;
9202    }
9203
9204    /**
9205     * Slice 169 (S8) — decompose the WHERE clause into a resolved filter
9206     * predicate list (GAP 2/3), reusing the slice-166 PredicateTreeBuilder.
9207     * Tolerant by construction: unknown shapes (including predicate
9208     * subqueries) fold into COMPLEX without leaking inner columns or
9209     * throwing. The flat {@code filterColumnRefs} are unaffected.
9210     */
9211    private static List<Predicate> buildFilterPredicates(TSelectSqlStatement select,
9212                                                         NameBindingProvider provider) {
9213        if (select == null || select.getWhereClause() == null
9214                || select.getWhereClause().getCondition() == null) {
9215            return Collections.emptyList();
9216        }
9217        return PredicateTreeBuilder.build(select.getWhereClause().getCondition(), provider);
9218    }
9219
9220    /** True iff {@code graph} contains a comma-join {@link SemanticJoinType#IMPLICIT_CROSS} entity. */
9221    private static boolean hasImplicitCrossJoin(JoinGraph graph) {
9222        for (JoinEntity e : graph.getJoins()) {
9223            if (e.getJoinType() == SemanticJoinType.IMPLICIT_CROSS) {
9224                return true;
9225            }
9226        }
9227        return false;
9228    }
9229
9230    /** Result of {@link #promoteImplicitJoinConditions}: the rewritten graph and residual WHERE filters. */
9231    private static final class ImplicitJoinPromotion {
9232        final JoinGraph graph;
9233        final List<Predicate> filterPredicates;
9234
9235        ImplicitJoinPromotion(JoinGraph graph, List<Predicate> filterPredicates) {
9236            this.graph = graph;
9237            this.filterPredicates = filterPredicates;
9238        }
9239    }
9240
9241    /**
9242     * Oracle implicit-join predicate promotion. For each top-level WHERE
9243     * conjunct that references aliases from BOTH sides of an
9244     * {@link SemanticJoinType#IMPLICIT_CROSS} comma-join entity, move its
9245     * {@link Predicate} into that entity's conditions and upgrade the entity's
9246     * join type. Aliases are read from the conjunct's AST (not the decomposed
9247     * predicate) so a {@code BETWEEN}'s range bounds — which collapse into a
9248     * single COMPLEX operand — are still seen as the right side.
9249     *
9250     * <p>The upgraded type depends on Oracle's {@code (+)} outer-join marker:
9251     * <ul>
9252     *   <li>no {@code (+)} → {@link SemanticJoinType#INNER};</li>
9253     *   <li>{@code (+)} on the column of the entity's RIGHT endpoint (the
9254     *       right side is null-producing) → {@link SemanticJoinType#LEFT};</li>
9255     *   <li>{@code (+)} on a column of the entity's LEFT endpoint (the left
9256     *       side is null-producing) → {@link SemanticJoinType#RIGHT}.</li>
9257     * </ul>
9258     * Direction is relative to the entity's endpoints, not operand position;
9259     * for GSP's left-deep comma model the LEFT endpoint is the accumulated
9260     * comma-join result and the RIGHT endpoint is the newly-added relation.
9261     * Multiple {@code (+)} conjuncts naming the same outer side all attach to
9262     * the one entity. A conjunct with {@code (+)} on both operands (illegal
9263     * Oracle) or conjuncts implying conflicting directions degrade the entity
9264     * back to {@link SemanticJoinType#IMPLICIT_CROSS} (its predicates stay
9265     * filters) rather than failing.
9266     *
9267     * <p>Each conjunct is assigned to the FIRST IMPLICIT_CROSS entity (in
9268     * FROM/graph order) whose right relation it references together with at
9269     * least one left-side relation, and all of whose referenced relations are
9270     * available at that boundary. A conjunct touching only one side, or no
9271     * comma-join boundary, stays a filter; an IMPLICIT_CROSS with no linking
9272     * conjunct stays a true cross product. The entity's {@code sourceSyntax}
9273     * stays {@link JoinSourceSyntax#COMMA}.
9274     */
9275    private static ImplicitJoinPromotion promoteImplicitJoinConditions(
9276            JoinGraph graph, TExpression whereCond, NameBindingProvider provider) {
9277        List<PredicateTreeBuilder.ConjunctPredicate> conjuncts =
9278                PredicateTreeBuilder.classifyConjuncts(whereCond, provider);
9279        List<JoinEntity> joins = graph.getJoins();
9280        int n = conjuncts.size();
9281        // Pass 1: assign each conjunct to the comma-join boundary it links,
9282        // and accumulate the Oracle (+) outer-join direction signal per entity.
9283        int[] assigned = new int[n];
9284        java.util.Arrays.fill(assigned, -1);
9285        boolean[] plusPresent = new boolean[n];
9286        java.util.Map<Integer, java.util.Set<SemanticJoinType>> dirByEntity =
9287                new java.util.HashMap<>();
9288        // Entities to degrade: a (+) that is on both sides (illegal Oracle), or
9289        // a (+) we can detect but cannot classify to exactly one endpoint.
9290        java.util.Set<Integer> degradePlusEntities = new java.util.HashSet<>();
9291        for (int i = 0; i < n; i++) {
9292            PredicateTreeBuilder.ConjunctPredicate cp = conjuncts.get(i);
9293            java.util.Set<String> aliases = collectConjunctRelationAliases(cp.conjunct, provider);
9294            for (int ix = 0; ix < joins.size(); ix++) {
9295                JoinEntity e = joins.get(ix);
9296                if (e.getJoinType() != SemanticJoinType.IMPLICIT_CROSS) continue;
9297                if (conjunctLinksImplicitJoin(e, aliases)) {
9298                    assigned[i] = ix;
9299                    break;
9300                }
9301            }
9302            int target = assigned[i];
9303            if (target < 0) continue;
9304            // Oracle (+) outer-join direction for this conjunct on its entity.
9305            // Deep-scan so a (+) anywhere in the conjunct is noticed even when
9306            // we cannot classify it (then we degrade rather than silently
9307            // treating it as an inner join).
9308            plusPresent[i] = expressionHasOraclePlus(cp.conjunct);
9309            if (!plusPresent[i]) continue;
9310            String outer = oraclePlusOuterAlias(cp.conjunct, provider);
9311            if (outer == ORACLE_PLUS_INVALID) {
9312                degradePlusEntities.add(target);
9313            } else {
9314                SemanticJoinType dir = (outer == null)
9315                        ? null : oraclePlusJoinDirection(joins.get(target), outer);
9316                if (dir != null) {
9317                    dirByEntity.computeIfAbsent(target,
9318                            k -> new java.util.HashSet<SemanticJoinType>()).add(dir);
9319                } else {
9320                    // (+) is present but unclassifiable (not a simple
9321                    // comparison, ambiguous operand, or outer column not an
9322                    // endpoint of this boundary) — degrade rather than guess.
9323                    degradePlusEntities.add(target);
9324                }
9325            }
9326        }
9327        // Pass 2: decide each linked entity's final join type. INNER when no
9328        // (+) is present; LEFT/RIGHT when all (+) conjuncts agree on direction;
9329        // DEGRADE (entity stays IMPLICIT_CROSS, its conjuncts fall back to
9330        // filters) when a (+) is invalid/unclassifiable or different conjuncts
9331        // imply conflicting directions.
9332        java.util.Map<Integer, SemanticJoinType> finalType = new java.util.HashMap<>();
9333        java.util.Set<Integer> degraded = new java.util.HashSet<>();
9334        for (int ix = 0; ix < joins.size(); ix++) {
9335            if (joins.get(ix).getJoinType() != SemanticJoinType.IMPLICIT_CROSS) continue;
9336            if (degradePlusEntities.contains(ix)) {
9337                degraded.add(ix);
9338                continue;
9339            }
9340            java.util.Set<SemanticJoinType> dirs = dirByEntity.get(ix);
9341            if (dirs == null || dirs.isEmpty()) {
9342                finalType.put(ix, SemanticJoinType.INNER);
9343            } else if (dirs.size() == 1) {
9344                finalType.put(ix, dirs.iterator().next());
9345            } else {
9346                degraded.add(ix);
9347            }
9348        }
9349        // Pass 3: in conjunct order, route each predicate to its entity's
9350        // condition list or to the residual filter list. For an OUTER (LEFT/
9351        // RIGHT) join only the (+)-marked conjuncts are join conditions: an
9352        // unmarked two-sided predicate is a post-join WHERE filter in Oracle
9353        // (it eliminates null-extended rows), so it must stay residual.
9354        java.util.Map<Integer, List<Predicate>> condsByEntity =
9355                new java.util.LinkedHashMap<>();
9356        List<Predicate> residualFilters = new ArrayList<>();
9357        for (int i = 0; i < n; i++) {
9358            int t = assigned[i];
9359            boolean asCondition = t >= 0 && !degraded.contains(t);
9360            if (asCondition) {
9361                SemanticJoinType ft = finalType.get(t);
9362                if (ft != SemanticJoinType.INNER && !plusPresent[i]) {
9363                    asCondition = false;
9364                }
9365            }
9366            if (asCondition) {
9367                condsByEntity.computeIfAbsent(t, k -> new ArrayList<Predicate>())
9368                        .add(conjuncts.get(i).predicate);
9369            } else {
9370                residualFilters.add(conjuncts.get(i).predicate);
9371            }
9372        }
9373        if (condsByEntity.isEmpty()) {
9374            return new ImplicitJoinPromotion(graph, residualFilters);
9375        }
9376        List<JoinEntity> upgraded = new ArrayList<>(joins.size());
9377        for (int ix = 0; ix < joins.size(); ix++) {
9378            JoinEntity e = joins.get(ix);
9379            List<Predicate> conds = condsByEntity.get(ix);
9380            if (conds == null || conds.isEmpty()) {
9381                upgraded.add(e);
9382            } else {
9383                upgraded.add(new JoinEntity(finalType.get(ix),
9384                        e.getLeftEndpoint(), e.getRightEndpoint(), e.getOrder(),
9385                        e.getSourceSyntax(), e.isNatural(), e.getUsingColumns(),
9386                        conds, e.getSourceSpan(), e.getConditionText(), e.isLateral()));
9387            }
9388        }
9389        return new ImplicitJoinPromotion(new JoinGraph(upgraded), residualFilters);
9390    }
9391
9392    /** Sentinel returned by {@link #oraclePlusOuterAlias} for an invalid (+)-on-both-sides predicate. */
9393    private static final String ORACLE_PLUS_INVALID = "+both+";
9394
9395    /**
9396     * Inspect a WHERE conjunct for an Oracle {@code (+)} outer-join marker and
9397     * return the lower-cased relation alias of the null-producing (outer) side.
9398     *
9399     * <p>Returns {@code null} when there is no {@code (+)} (or it is not on a
9400     * simple comparison — the only shape Oracle's {@code (+)} attaches to), or
9401     * when the marked column's relation cannot be resolved (caller degrades
9402     * when a marker was nonetheless detected). Returns
9403     * {@link #ORACLE_PLUS_INVALID} when both operands carry {@code (+)} (illegal
9404     * Oracle syntax), so the caller can degrade rather than guess a direction.
9405     *
9406     * <p>Operand subtrees are deep-scanned for the marker so a parenthesised
9407     * column ({@code (b.y(+))}) is recognised as well as a bare one.
9408     */
9409    private static String oraclePlusOuterAlias(TExpression conjunct,
9410                                               NameBindingProvider provider) {
9411        TExpression e = unwrapParenExpression(conjunct);
9412        if (e == null || e.getExpressionType() != EExpressionType.simple_comparison_t) {
9413            return null;
9414        }
9415        TExpression lo = e.getLeftOperand();
9416        TExpression ro = e.getRightOperand();
9417        boolean leftPlus = expressionHasOraclePlus(lo);
9418        boolean rightPlus = expressionHasOraclePlus(ro);
9419        if (leftPlus && rightPlus) return ORACLE_PLUS_INVALID;
9420        if (leftPlus) return soleOperandRelationAlias(lo, provider);
9421        if (rightPlus) return soleOperandRelationAlias(ro, provider);
9422        return null;
9423    }
9424
9425    /**
9426     * True iff {@code e} or any descendant (outside a nested subquery) carries
9427     * the Oracle {@code (+)} outer-join marker. Used both to notice a marker we
9428     * cannot otherwise classify and to find it under a {@code parenthesis_t}
9429     * operand wrapper.
9430     */
9431    private static boolean expressionHasOraclePlus(TExpression e) {
9432        if (e == null) return false;
9433        if (e.isOracleOuterJoin()) return true;
9434        final boolean[] found = {false};
9435        e.acceptChildren(new TParseTreeVisitor() {
9436            int nestedSelectDepth = 0;
9437
9438            @Override
9439            public void preVisit(TSelectSqlStatement nested) {
9440                nestedSelectDepth++;
9441            }
9442
9443            @Override
9444            public void postVisit(TSelectSqlStatement nested) {
9445                nestedSelectDepth--;
9446            }
9447
9448            @Override
9449            public void preVisit(TExpression x) {
9450                if (nestedSelectDepth == 0 && x.isOracleOuterJoin()) {
9451                    found[0] = true;
9452                }
9453            }
9454        });
9455        return found[0];
9456    }
9457
9458    /**
9459     * The single relation alias an operand expression references, lower-cased,
9460     * or {@code null} when it references zero or more than one relation (an
9461     * Oracle {@code (+)} marks exactly one column).
9462     */
9463    private static String soleOperandRelationAlias(TExpression operand,
9464                                                   NameBindingProvider provider) {
9465        java.util.Set<String> aliases = collectConjunctRelationAliases(operand, provider);
9466        return aliases.size() == 1 ? aliases.iterator().next() : null;
9467    }
9468
9469    /**
9470     * Map an Oracle {@code (+)} outer (null-producing) relation alias to the
9471     * {@link SemanticJoinType} for {@code entity}, relative to its endpoints:
9472     * a null-producing RIGHT endpoint yields {@link SemanticJoinType#LEFT}
9473     * (the left side is preserved); a null-producing LEFT endpoint yields
9474     * {@link SemanticJoinType#RIGHT} (the right side is preserved). Returns
9475     * {@code null} when the outer alias is not one of this entity's endpoints.
9476     */
9477    private static SemanticJoinType oraclePlusJoinDirection(JoinEntity entity,
9478                                                            String outerAliasLc) {
9479        String right = entity.getRightEndpoint().getAlias();
9480        if (right != null && right.toLowerCase(Locale.ROOT).equals(outerAliasLc)) {
9481            return SemanticJoinType.LEFT;
9482        }
9483        for (String left : endpointAliasesLowerCase(entity.getLeftEndpoint())) {
9484            if (left.equals(outerAliasLc)) {
9485                return SemanticJoinType.RIGHT;
9486            }
9487        }
9488        return null;
9489    }
9490
9491    /** Unwrap nested parenthesis expressions iteratively; returns the inner non-paren expression. */
9492    private static TExpression unwrapParenExpression(TExpression e) {
9493        while (e != null && e.getExpressionType() == EExpressionType.parenthesis_t
9494                && e.getLeftOperand() != null) {
9495            e = e.getLeftOperand();
9496        }
9497        return e;
9498    }
9499
9500    /**
9501     * True iff the comma-join {@code entity} is the boundary at which a WHERE
9502     * conjunct referencing {@code aliases} should be evaluated. Three
9503     * conditions must hold:
9504     *
9505     * <ul>
9506     *   <li>the conjunct references the entity's right relation (so the
9507     *       newly-added relation actually participates — otherwise the
9508     *       predicate belongs to an earlier boundary);</li>
9509     *   <li>it references at least one left-side relation (so it is genuinely
9510     *       two-sided, not a single-table filter);</li>
9511     *   <li>EVERY relation it references is available at this boundary — i.e.
9512     *       contained in the left aliases plus the right alias. This prevents
9513     *       a predicate that also touches a not-yet-joined relation (e.g.
9514     *       {@code a.x + c.z = b.y} at the {@code a×b} boundary) from being
9515     *       promoted too early; it is instead picked up at the later boundary
9516     *       where its last relation is added.</li>
9517     * </ul>
9518     */
9519    private static boolean conjunctLinksImplicitJoin(JoinEntity entity,
9520                                                     java.util.Set<String> aliases) {
9521        String right = entity.getRightEndpoint().getAlias();
9522        if (right == null || right.isEmpty()) return false;
9523        String rightLc = right.toLowerCase(Locale.ROOT);
9524        if (!aliases.contains(rightLc)) return false;
9525        java.util.Set<String> available = new java.util.HashSet<>();
9526        available.add(rightLc);
9527        boolean linksLeft = false;
9528        for (String left : endpointAliasesLowerCase(entity.getLeftEndpoint())) {
9529            available.add(left);
9530            if (!left.equals(rightLc) && aliases.contains(left)) {
9531                linksLeft = true;
9532            }
9533        }
9534        if (!linksLeft) return false;
9535        // All referenced relations must be available at this boundary.
9536        return available.containsAll(aliases);
9537    }
9538
9539    /** Lower-cased relation aliases an endpoint represents (RELATION → one; JOIN_RESULT → all contributors). */
9540    private static java.util.List<String> endpointAliasesLowerCase(JoinEndpoint ep) {
9541        java.util.List<String> out = new ArrayList<>();
9542        if (ep == null) return out;
9543        if (ep.getKind() == JoinEndpointKind.RELATION) {
9544            if (ep.getAlias() != null && !ep.getAlias().isEmpty()) {
9545                out.add(ep.getAlias().toLowerCase(Locale.ROOT));
9546            }
9547        } else if (ep.getKind() == JoinEndpointKind.JOIN_RESULT) {
9548            for (String a : ep.getContributingAliases()) {
9549                if (a != null && !a.isEmpty()) {
9550                    out.add(a.toLowerCase(Locale.ROOT));
9551                }
9552            }
9553        }
9554        return out;
9555    }
9556
9557    /**
9558     * Collect the lower-cased relation aliases a WHERE conjunct references, by
9559     * walking its column {@link TObjectName}s. Each column contributes exactly
9560     * ONE canonical alias: the resolver-bound relation alias (which matches the
9561     * join endpoints' {@code effectiveAliasOf}, even when the column is written
9562     * with the table name rather than the alias); when the column does not
9563     * bind, the SQL-written qualifier is the fallback. A column that neither
9564     * binds nor carries a qualifier (a bare unqualified name that the resolver
9565     * cannot place) contributes nothing — it cannot pick a side deterministically.
9566     *
9567     * <p>One canonical alias per column keeps the set comparable to the
9568     * endpoint alias sets, which the containment check in
9569     * {@link #conjunctLinksImplicitJoin} relies on. Nested subqueries are not
9570     * descended into.
9571     */
9572    private static java.util.Set<String> collectConjunctRelationAliases(
9573            TExpression conjunct, final NameBindingProvider provider) {
9574        final java.util.Set<String> aliases = new java.util.HashSet<>();
9575        conjunct.acceptChildren(new TParseTreeVisitor() {
9576            int nestedSelectDepth = 0;
9577
9578            @Override
9579            public void preVisit(TSelectSqlStatement nested) {
9580                nestedSelectDepth++;
9581            }
9582
9583            @Override
9584            public void postVisit(TSelectSqlStatement nested) {
9585                nestedSelectDepth--;
9586            }
9587
9588            @Override
9589            public void preVisit(TObjectName node) {
9590                if (nestedSelectDepth > 0) return;
9591                if (node.getDbObjectType() != EDbObjectType.column) return;
9592                ColumnBinding binding = provider.bindColumn(node);
9593                if (binding != null && binding.getRelationAlias() != null
9594                        && !binding.getRelationAlias().isEmpty()) {
9595                    aliases.add(binding.getRelationAlias().toLowerCase(Locale.ROOT));
9596                    return;
9597                }
9598                String qualifier = node.getTableString();
9599                if (qualifier != null && !qualifier.isEmpty()) {
9600                    aliases.add(qualifier.toLowerCase(Locale.ROOT));
9601                }
9602            }
9603        });
9604        return aliases;
9605    }
9606
9607    // -----------------------------------------------------------------
9608    // Slice 129 — PIVOT sub-slice (a): source binding + consumed-refs slot.
9609    //
9610    // A PIVOT FROM source is a TTable of type pivoted_table carrying a
9611    // TPivotedTable: getRelations().get(0) is the underlying source relation
9612    // and getPivotClause() carries the aggregation function(s), the FOR /
9613    // pivot column(s), and the IN-list. Before slice 129 this rejected with
9614    // a misleading generic TABLE_BINDING_UNRESOLVED ("could not bind table
9615    // null(...)") because bindRelation returns null for a pivoted_table.
9616    //
9617    // Sub-slice (a) admits only the narrow base-table-source / explicit-
9618    // projection / no-other-clauses shape; it binds the source relation and
9619    // captures the consumed FOR + aggregation-arg columns into the new
9620    // StatementGraph.pivotColumnRefs slot. Output-column lineage and
9621    // SELECT * expansion are sub-slice (b); UNPIVOT is (c); subquery source,
9622    // chained PIVOT, extra clauses and nested PIVOT are later sub-slices and
9623    // reject here with structured PIVOT_* diagnostics.
9624    // -----------------------------------------------------------------
9625
9626    /**
9627     * True when {@code select}'s sole FROM source is a PIVOT/UNPIVOT operator
9628     * with no trailing JOINs. A trailing JOIN on the pivot result
9629     * (e.g. {@code FROM t PIVOT(...) p JOIN x ON ...}) is out of scope for
9630     * slice 129 and stays on the normal path.
9631     */
9632    private static boolean isPivotSelect(TSelectSqlStatement select) {
9633        if (select.joins == null || select.joins.size() != 1) {
9634            return false;
9635        }
9636        TJoin join = select.joins.getJoin(0);
9637        if (join == null) {
9638            return false;
9639        }
9640        TJoinItemList items = join.getJoinItems();
9641        if (items != null && items.size() > 0) {
9642            return false;
9643        }
9644        TTable t = join.getTable();
9645        return t != null
9646                && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
9647                && t.getPivotedTable() != null;
9648    }
9649
9650    /**
9651     * Slice 143 — mirror of {@link #isPivotSelect} but for the PIVOT-then-JOIN
9652     * shape: a single explicit JOIN ({@code joinItems} PRESENT) whose driver
9653     * table is a {@code pivoted_table}. Routes to a precise deferral reject
9654     * (reusing {@code PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED}) instead of the
9655     * generic {@code TABLE_BINDING_UNRESOLVED} the normal path would emit.
9656     */
9657    private static boolean isPivotWithJoinPartner(TSelectSqlStatement select) {
9658        if (select.joins == null || select.joins.size() != 1) {
9659            return false;
9660        }
9661        TJoin join = select.joins.getJoin(0);
9662        if (join == null) {
9663            return false;
9664        }
9665        TJoinItemList items = join.getJoinItems();
9666        if (items == null || items.size() == 0) {
9667            return false;
9668        }
9669        TTable t = join.getTable();
9670        return t != null
9671                && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
9672                && t.getPivotedTable() != null;
9673    }
9674
9675    /**
9676     * Slice 144 — find a PIVOT/UNPIVOT operator that appears as a JOIN
9677     * PARTNER rather than the sole FROM driver. Two positions are covered:
9678     * <ul>
9679     *   <li>the right-hand side of an explicit JOIN
9680     *       ({@code FROM dim d JOIN sales PIVOT(...) AS p ON ...}), and</li>
9681     *   <li>a comma-join sibling ({@code FROM sales PIVOT(...) AS p, dim d}),
9682     *       which the parser represents as {@code select.joins.size() > 1}.</li>
9683     * </ul>
9684     *
9685     * <p>The admitted single-PIVOT shape ({@link #isPivotSelect}) and the
9686     * slice-143 pivot-as-driver-with-a-join shape
9687     * ({@link #isPivotWithJoinPartner}) are both routed earlier and never reach
9688     * here, so this helper only upgrades shapes that would otherwise fall to the
9689     * generic {@code TABLE_BINDING_UNRESOLVED}. The driver of the SOLE
9690     * {@code TJoin} is the slice-129/-143 driver and is skipped; for comma-join
9691     * siblings ({@code size > 1}) every {@code TJoin} is an independent FROM
9692     * item whose driver may itself be the pivot.
9693     *
9694     * @return the pivoted {@link TTable} to anchor the diagnostic on, or null.
9695     */
9696    private static TTable findPivotJoinPartner(TSelectSqlStatement select) {
9697        if (select.joins == null) {
9698            return null;
9699        }
9700        boolean singleJoin = select.joins.size() == 1;
9701        for (int j = 0; j < select.joins.size(); j++) {
9702            TJoin join = select.joins.getJoin(j);
9703            if (join == null) {
9704                continue;
9705            }
9706            if (!singleJoin && isPivotedTable(join.getTable())) {
9707                return join.getTable();
9708            }
9709            TJoinItemList items = join.getJoinItems();
9710            if (items != null) {
9711                for (int i = 0; i < items.size(); i++) {
9712                    if (items.getJoinItem(i) != null
9713                            && isPivotedTable(items.getJoinItem(i).getTable())) {
9714                        return items.getJoinItem(i).getTable();
9715                    }
9716                }
9717            }
9718        }
9719        return null;
9720    }
9721
9722    /**
9723     * Slice 146 — the SQL keyword ("PIVOT" or "UNPIVOT") naming the row-shaping
9724     * operator carried by {@code pivotedTable}, used to make the JOIN-deferral
9725     * message name the actual operator instead of always saying "PIVOT".
9726     * Defaults to "PIVOT" when the clause is absent (e.g. a chained-clause-list
9727     * shape where {@link TPivotClause#getType()} is not reachable via
9728     * {@code getPivotClause()}), which is harmless for the deferral message.
9729     */
9730    private static String pivotOperatorKeyword(TTable pivotedTable) {
9731        if (pivotedTable != null && pivotedTable.getPivotedTable() != null) {
9732            TPivotedTable pivoted = pivotedTable.getPivotedTable();
9733            // Slice 149 — a CHAINED pivoted_table (clause list > 1) has no single
9734            // getPivotClause(): that accessor surfaces only ONE of the chained
9735            // clauses, so a MIXED chain (PIVOT(...) UNPIVOT(...)) as the JOIN
9736            // driver/partner would name just that one operator. Delegate to the
9737            // slice-148 list-walking helper so a mixed chain names the generic
9738            // "PIVOT / UNPIVOT" (an all-PIVOT chain still names "PIVOT", an
9739            // all-UNPIVOT chain still names "UNPIVOT" — byte-identical to before).
9740            if (pivoted.getPivotClauseList() != null
9741                    && pivoted.getPivotClauseList().size() > 1) {
9742                return chainedPivotOperatorKeyword(pivoted);
9743            }
9744            TPivotClause pc = pivoted.getPivotClause();
9745            if (pc != null && pc.getType() != TPivotClause.pivot) {
9746                return "UNPIVOT";
9747            }
9748        }
9749        return "PIVOT";
9750    }
9751
9752    /**
9753     * Slice 148 — the SQL keyword(s) naming the operator(s) carried by a
9754     * CHAINED {@code pivoted_table} ({@code pivotClauseList.size() > 1}), used to
9755     * make the {@code PIVOT_MULTIPLE_CLAUSES_NOT_SUPPORTED} deferral message name
9756     * the actual operator(s) instead of always saying "PIVOT / UNPIVOT".
9757     * Inspects every clause's {@link TPivotClause#getType()}: all PIVOT &rarr;
9758     * "PIVOT"; all UNPIVOT &rarr; "UNPIVOT"; a genuine mix (or an empty/unknown
9759     * list) &rarr; the generic "PIVOT / UNPIVOT". Unlike
9760     * {@link #pivotOperatorKeyword}, this reads the whole clause LIST because the
9761     * single {@code getPivotClause()} accessor is null for a chained shape.
9762     */
9763    private static String chainedPivotOperatorKeyword(TPivotedTable pivoted) {
9764        boolean sawPivot = false;
9765        boolean sawUnpivot = false;
9766        if (pivoted != null && pivoted.getPivotClauseList() != null) {
9767            for (int i = 0; i < pivoted.getPivotClauseList().size(); i++) {
9768                TPivotClause pc = pivoted.getPivotClauseList().getElement(i);
9769                if (pc == null) {
9770                    continue;
9771                }
9772                if (pc.getType() == TPivotClause.pivot) {
9773                    sawPivot = true;
9774                } else {
9775                    sawUnpivot = true;
9776                }
9777            }
9778        }
9779        if (sawPivot && !sawUnpivot) {
9780            return "PIVOT";
9781        }
9782        if (sawUnpivot && !sawPivot) {
9783            return "UNPIVOT";
9784        }
9785        return "PIVOT / UNPIVOT";
9786    }
9787
9788    /**
9789     * Slice 150 — true when {@code pc} is Oracle's PIVOT XML variant
9790     * ({@code PIVOT XML (...)}). PIVOT XML aggregates all pivoted values into a
9791     * SINGLE XMLType output column rather than producing one column per IN-list
9792     * value, so its output-column lineage differs from a regular PIVOT and
9793     * {@link #buildPivotSelect} defers it.
9794     *
9795     * <p>The parser exposes no XML flag on {@link TPivotClause}; the only signal
9796     * is the clause's start token, which is the {@code XML} keyword when the XML
9797     * modifier is present (it is the opening {@code (} otherwise). UNPIVOT has no
9798     * XML variant, so this only matches a {@code pivot}-type clause.
9799     */
9800    private static boolean isPivotXml(TPivotClause pc) {
9801        if (pc == null || pc.getType() != TPivotClause.pivot) {
9802            return false;
9803        }
9804        TSourceToken start = pc.getStartToken();
9805        return start != null && "XML".equalsIgnoreCase(start.astext);
9806    }
9807
9808    /**
9809     * Slice 144 — true when {@code t} is a PIVOT/UNPIVOT operator
9810     * ({@code pivoted_table} carrying a {@code TPivotedTable}).
9811     */
9812    private static boolean isPivotedTable(TTable t) {
9813        return t != null
9814                && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
9815                && t.getPivotedTable() != null;
9816    }
9817
9818    /**
9819     * Slice 129 — build the StatementGraph for a PIVOT-source SELECT
9820     * (see {@link #isPivotSelect}). Applies the admit-set rejects in a fixed
9821     * order, binds the underlying source relation, captures the consumed
9822     * FOR + aggregation-arg columns, and emits output-column NAMES with empty
9823     * sources (lineage deferred to a later sub-slice).
9824     */
9825    private static StatementGraph buildPivotSelect(TSelectSqlStatement select,
9826                                                   NameBindingProvider provider,
9827                                                   String name) {
9828        TTable pivotTable = select.joins.getJoin(0).getTable();
9829        TPivotedTable pivoted = pivotTable.getPivotedTable();
9830
9831        // 1. single pivot clause only (chained PIVOTs deferred).
9832        if (pivoted.getPivotClauseList() != null
9833                && pivoted.getPivotClauseList().size() > 1) {
9834            // Slice 148 — name the ACTUAL chained operator(s) (all PIVOT,
9835            // all UNPIVOT, or a genuine mix), completing the message-operator
9836            // consistency begun on the JOIN-deferral (slice 146) and
9837            // extra-clause-deferral (slice 147) paths.
9838            throw new SemanticIRBuildException(
9839                    Diagnostic.error(DiagnosticCode.PIVOT_MULTIPLE_CLAUSES_NOT_SUPPORTED,
9840                    "chained " + chainedPivotOperatorKeyword(pivoted)
9841                            + " clauses are not supported yet", pivotTable));
9842        }
9843        TPivotClause pc = pivoted.getPivotClause();
9844        if (pc == null) {
9845            throw new SemanticIRBuildException(
9846                    Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED,
9847                    "could not bind PIVOT source (no pivot clause)", pivotTable));
9848        }
9849        // 1b. Slice 150 — Oracle PIVOT XML is a fundamentally different shape:
9850        //     it produces a SINGLE XMLType output column (an XML aggregate of
9851        //     all pivoted values), NOT one column per IN-list value. Treating
9852        //     it as a regular PIVOT (the only path below) would emit incorrect
9853        //     output-column lineage, so defer it with the generic
9854        //     PIVOT/UNPIVOT-unsupported code. Checked here — before the
9855        //     UNPIVOT-shape, extra-clause, and bare-* handling — so the XML
9856        //     limitation is the reason reported regardless of the other clauses.
9857        if (isPivotXml(pc)) {
9858            throw new SemanticIRBuildException(
9859                    Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED,
9860                    "PIVOT XML is not supported yet (the single XMLType output "
9861                            + "column has different lineage than a regular PIVOT)",
9862                    pivotTable));
9863        }
9864        // 2. UNPIVOT sub-slice (c): the deterministic single-value-column /
9865        //    simple-IN-list shape is now supported (see buildUnpivotOutputColumns).
9866        //    A multi-value-column or column-group ((a,b) ...) UNPIVOT stays
9867        //    deferred — checked here so PIVOT_UNPIVOT_NOT_SUPPORTED stays reached
9868        //    and the harder positional mapping is not mis-attributed.
9869        boolean isUnpivot = pc.getType() != TPivotClause.pivot;
9870        if (isUnpivot) {
9871            rejectUnsupportedUnpivotShape(pc, pivotTable);
9872        }
9873        // 3. extra query clauses deferred (WHERE / GROUP BY / HAVING /
9874        //    ORDER BY / QUALIFY / DISTINCT / row-limit / set-op). Processing
9875        //    them would hit the same resolver NOT_FOUND quirk that forces the
9876        //    direct consumed-ref construction below.
9877        rejectPivotExtraClauses(select, pivotTable);
9878        // 4. source must be a base table (subquery source deferred).
9879        if (pivoted.getRelations().isEmpty() || pivoted.getRelations().get(0) == null) {
9880            throw new SemanticIRBuildException(
9881                    Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED,
9882                    "could not bind PIVOT source (no source relation)", pivotTable));
9883        }
9884        TTable sourceTable = pivoted.getRelations().get(0);
9885        // Slice 136: a FROM-subquery source is admitted for EXPLICIT projections.
9886        // The inner SELECT was already extracted as its own statement by the
9887        // processDirectSubqueryTable pre-pass (which unwraps the pivoted_table);
9888        // binding here with allowFromSubqueries=true yields a SUBQUERY-kind
9889        // relation whose alias matches the registered subquery-statement alias, so
9890        // emitLineageForStatement wires the pivot output → subquery output edges.
9891        // Slice 137: a bare SELECT * over a subquery source is now ALSO admitted —
9892        // the slice-137 addRelationToInScopeMap unwrap registers the subquery's
9893        // published columns under the source alias, so expandPivot/UnpivotBareStar
9894        // can synthesise the passthrough schema without an external catalog.
9895        RelationSource sourceRel = buildRelation(sourceTable, provider, /*allowFromSubqueries=*/ true);
9896
9897        // 4b. Slice 156 (PIVOT) / slice 157 (UNPIVOT) — a WHERE clause over a
9898        //     PIVOT or UNPIVOT is now admitted when every ref is a PROVABLE
9899        //     passthrough source column (resolved directly to the underlying
9900        //     source column); anything else (a pivot-output / UNPIVOT value or
9901        //     FOR-name / consumed / non-provable ref, or a subquery predicate)
9902        //     keeps the slice-129 deferral. Resolved BEFORE output building so the
9903        //     WHERE deferral wins over a (deferred) output shape, mirroring the
9904        //     previous rejectPivotExtraClauses precedence.
9905        List<ColumnRef> filterRefs = buildPivotWhereColumnRefs(select, pc, isUnpivot,
9906                sourceRel.getAlias(), sourceTable, provider, pivotTable);
9907
9908        // 4c. Slice 158 — a GROUP BY clause over a PIVOT or UNPIVOT is now
9909        //     admitted (into groupByColumnRefs) when every grouped column is a
9910        //     PROVABLE passthrough source column, the sibling of the slice-156/157
9911        //     WHERE admission. Resolved AFTER the WHERE so a WHERE deferral wins
9912        //     over a GROUP BY deferral (preserving the previous
9913        //     rejectPivotExtraClauses precedence, which checked WHERE before
9914        //     GROUP BY). A GROUP BY paired with a HAVING already deferred earlier
9915        //     in rejectPivotExtraClauses (HAVING branch), so it never reaches here.
9916        List<ColumnRef> groupByRefs = buildPivotGroupByColumnRefs(select, pc, isUnpivot,
9917                sourceRel.getAlias(), sourceTable, provider, pivotTable);
9918
9919        // 4d. Slice 160 — a HAVING clause over a PIVOT or UNPIVOT is now admitted
9920        //     (into havingColumnRefs) when every column the HAVING touches is a
9921        //     PROVABLE passthrough source column, the sibling of the slice-156/157
9922        //     WHERE, slice-158 GROUP BY, and slice-159 QUALIFY admissions. Resolved
9923        //     AFTER the WHERE and GROUP BY and BEFORE the QUALIFY — the natural SQL
9924        //     clause order (WHERE, GROUP BY, HAVING, QUALIFY) — so a deferred WHERE
9925        //     / GROUP BY wins over a deferred HAVING, and a deferred HAVING wins
9926        //     over a deferred QUALIFY.
9927        List<ColumnRef> havingRefs = buildPivotHavingColumnRefs(select, pc, isUnpivot,
9928                sourceRel.getAlias(), sourceTable, provider, pivotTable);
9929
9930        // 4e. Slice 159 — a QUALIFY clause over a PIVOT or UNPIVOT is now admitted
9931        //     (into qualifyColumnRefs) when every column the QUALIFY touches is a
9932        //     PROVABLE passthrough source column, the sibling of the slice-156/157
9933        //     WHERE, slice-158 GROUP BY, and slice-160 HAVING admissions. Resolved
9934        //     AFTER the WHERE, GROUP BY, and HAVING so any earlier-clause deferral
9935        //     wins — the natural SQL clause order (WHERE, GROUP BY, HAVING,
9936        //     QUALIFY).
9937        List<ColumnRef> qualifyRefs = buildPivotQualifyColumnRefs(select, pc, isUnpivot,
9938                sourceRel.getAlias(), sourceTable, provider, pivotTable);
9939
9940        // 4f. Slice 161 - an ORDER BY clause over a PIVOT or UNPIVOT is already
9941        //     admitted (slice 142, lineage-neutral); this records its
9942        //     orderByColumnRefs when every sort-key column is a PROVABLE passthrough
9943        //     source column, the sibling of the slice-156/157 WHERE, slice-158 GROUP
9944        //     BY, slice-159 QUALIFY, and slice-160 HAVING admissions. UNLIKE those,
9945        //     it NEVER defers: a non-passthrough / unprovable / subquery sort key
9946        //     simply leaves the slot empty (the lineage-neutral slice-142 behaviour).
9947        List<ColumnRef> orderByRefs = buildPivotOrderByColumnRefs(select, pc, isUnpivot,
9948                sourceRel.getAlias(), sourceTable, provider);
9949
9950        // 5. + 6. consumed refs and output-column lineage, branched on PIVOT
9951        //    vs UNPIVOT. PIVOT consumes the FOR + aggregation-arg columns and
9952        //    its outputs are the passthrough + pivot-output columns; UNPIVOT
9953        //    consumes the IN-list source columns and its outputs are the
9954        //    passthrough + value + FOR columns (slice 131 sub-slice (c)).
9955        List<ColumnRef> consumedRefs;
9956        List<OutputColumn> outputs;
9957        if (isUnpivot) {
9958            consumedRefs = collectUnpivotInListRefs(pc, sourceRel.getAlias());
9959            outputs = buildUnpivotOutputColumns(select, pivotTable, pc,
9960                    sourceRel.getAlias(), sourceTable, provider);
9961        } else {
9962            consumedRefs = collectPivotConsumedRefs(pc, sourceRel.getAlias());
9963            outputs = buildPivotOutputColumns(select, pivotTable, pc,
9964                    sourceRel.getAlias(), sourceTable, provider);
9965        }
9966
9967        // Slice 180 (R5): PIVOT/UNPIVOT SELECT blocks are built here, not at
9968        // the main SELECT site, so set their block span too.
9969        return new StatementGraph(name, "SELECT",
9970                Collections.singletonList(sourceRel), outputs, filterRefs, groupByRefs,
9971                havingRefs, qualifyRefs, orderByRefs, consumedRefs)
9972                .withSourceSpan(SourceSpan.of(select));
9973    }
9974
9975    /**
9976     * Slice 129 — reject a PIVOT SELECT that carries a COLUMN-TOUCHING query
9977     * clause beyond the projection and the FROM-PIVOT source. One
9978     * {@code DiagnosticCode} with clause-discriminated message text (the
9979     * deferral is the same semantic boundary: "PIVOT combined with an
9980     * additional query clause is not supported yet"). Deferred because
9981     * resolving a {@code WHERE} / {@code GROUP BY} / {@code HAVING} /
9982     * {@code QUALIFY} column reference needs to disambiguate a passthrough
9983     * SOURCE column from a PIVOT-OUTPUT column.
9984     *
9985     * <p>Slice 142 — the ROW-SHAPING clauses ({@code ORDER BY}, {@code DISTINCT},
9986     * and the row-limit clauses {@code TOP} / {@code LIMIT} / {@code OFFSET} /
9987     * {@code FETCH FIRST}) are NO LONGER rejected: they only re-shape the row
9988     * SET (order / dedup / truncate) and do not change the output COLUMN set or
9989     * the column lineage, which {@link #buildPivotSelect} reads only from the
9990     * pivot clause, the projection, and the source. Admitting them is therefore
9991     * lineage-neutral — the resulting {@code StatementGraph} is byte-identical
9992     * to the same query without the clause. The (defensive) set-operator branch
9993     * stays: a pivot SELECT node never carries a set operator in practice
9994     * ({@link #isPivotSelect} requires a FROM-PIVOT, which a union node has not),
9995     * but the guard is kept for robustness.
9996     */
9997    private static void rejectPivotExtraClauses(TSelectSqlStatement select, TTable pivotTable) {
9998        String clause = null;
9999        // Slice 156 — the WHERE clause is no longer rejected here; it is handled
10000        // by buildPivotWhereColumnRefs (called after the source is bound), which
10001        // ADMITS a WHERE whose every ref is a provable passthrough source column
10002        // and otherwise throws the same PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED
10003        // deferral. Slice 158 — likewise the GROUP BY clause is now handled by
10004        // buildPivotGroupByColumnRefs (passthrough-only admission). Slice 159 —
10005        // likewise the QUALIFY clause is now handled by buildPivotQualifyColumnRefs
10006        // (passthrough-only admission). Slice 160 — likewise the HAVING clause is
10007        // now handled by buildPivotHavingColumnRefs (passthrough-only admission).
10008        // The only remaining column-touching clause that still defers
10009        // unconditionally here is a set operator (a pivot SELECT node never carries
10010        // one in practice, but the guard is kept defensively). The WHERE / GROUP BY
10011        // / HAVING / QUALIFY admission helpers run after this guard, in natural SQL
10012        // clause order, so a deferred earlier clause wins over a deferred later one.
10013        if (select.getSetOperatorType() != ESetOperatorType.none) {
10014            clause = "a set operator";
10015        }
10016        if (clause != null) {
10017            // Slice 147 — name the ACTUAL operator (PIVOT vs UNPIVOT) via the
10018            // shared slice-146 helper, completing the message-operator
10019            // consistency begun on the JOIN-deferral path.
10020            throw new SemanticIRBuildException(
10021                    Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10022                    pivotOperatorKeyword(pivotTable) + " combined with " + clause
10023                            + " is not supported yet", pivotTable));
10024        }
10025    }
10026
10027    /**
10028     * Slice 156 (PIVOT) / slice 157 (UNPIVOT) — resolve the column refs of a
10029     * {@code WHERE} clause over a PIVOT or UNPIVOT, admitting the clause only when
10030     * EVERY referenced column is a PROVABLE passthrough source column. Returns the
10031     * resolved refs (document order, deduped) for the {@code StatementGraph}
10032     * filterColumnRefs slot, or an empty list when there is no WHERE.
10033     *
10034     * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the
10035     * slice-129 deferral, code kept reached) when the WHERE cannot be admitted:
10036     * the WHERE contains a subquery predicate; or any ref is NOT a provable
10037     * passthrough column. For a PIVOT, a non-passthrough ref is a pivot-output
10038     * column, a consumed FOR / aggregation-arg column, or a column whose source
10039     * membership cannot be proven without a catalog. For an UNPIVOT (slice 157),
10040     * it is the synthesised VALUE or FOR/name output column, a consumed IN-list
10041     * source column (narrowed into rows), or — again — a column with no provable
10042     * source membership. A passthrough column passes through the operator
10043     * unchanged, so its WHERE ref resolves directly to the underlying source
10044     * column ({@code sourceAlias.col}) regardless of the aggregation count or
10045     * IN-list shape — the same provable-membership doctrine as slice 153's
10046     * passthrough output columns. The {@code dbObjectType == column} filter
10047     * excludes function names and other non-column tokens.
10048     */
10049    private static List<ColumnRef> buildPivotWhereColumnRefs(TSelectSqlStatement select,
10050                                                             TPivotClause pc,
10051                                                             boolean isUnpivot,
10052                                                             String sourceAlias,
10053                                                             TTable sourceTable,
10054                                                             NameBindingProvider provider,
10055                                                             TTable pivotTable) {
10056        TWhereClause where = select.getWhereClause();
10057        if (where == null || where.getCondition() == null) {
10058            return Collections.emptyList();
10059        }
10060        // Slice 156 (PIVOT) / slice 157 (UNPIVOT) — the PROVABLE passthrough source
10061        // columns of the operator. A passthrough column survives the PIVOT/UNPIVOT
10062        // unchanged, so a WHERE touching ONLY those columns resolves directly to
10063        // the underlying source column. The set is empty when membership cannot be
10064        // proven (a base table with no catalog) — keeping the WHERE deferred.
10065        Set<String> passthroughLC = isUnpivot
10066                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10067                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10068        if (!passthroughLC.isEmpty()) {
10069            final List<TObjectName> whereCols = new ArrayList<>();
10070            final boolean[] hasSubquery = {false};
10071            TParseTreeVisitor v = new TParseTreeVisitor() {
10072                @Override
10073                public void preVisit(TObjectName n) {
10074                    whereCols.add(n);
10075                }
10076
10077                @Override
10078                public void preVisit(TSelectSqlStatement s) {
10079                    hasSubquery[0] = true;
10080                }
10081            };
10082            where.getCondition().acceptChildren(v);
10083            if (!hasSubquery[0]) {
10084                LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10085                boolean allPassthrough = true;
10086                for (TObjectName n : whereCols) {
10087                    if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10088                        continue;
10089                    }
10090                    String col = n.getColumnNameOnly();
10091                    if (col == null || col.isEmpty() || "*".equals(col)) {
10092                        continue;
10093                    }
10094                    if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10095                        allPassthrough = false;
10096                        break;
10097                    }
10098                    refs.add(new ColumnRef(sourceAlias, col));
10099                }
10100                if (allPassthrough) {
10101                    return new ArrayList<>(refs);
10102                }
10103            }
10104        }
10105        throw new SemanticIRBuildException(
10106                Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10107                pivotOperatorKeyword(pivotTable) + " combined with a WHERE clause "
10108                        + "is not supported yet", pivotTable));
10109    }
10110
10111    /**
10112     * Slice 158 — resolve the column refs of a {@code GROUP BY} clause over a
10113     * PIVOT or UNPIVOT, admitting the clause only when EVERY grouped column is a
10114     * PROVABLE passthrough source column. Returns the resolved refs (document
10115     * order, deduped) for the {@code StatementGraph} groupByColumnRefs slot, or an
10116     * empty list when there is no GROUP BY. This is the GROUP BY sibling of the
10117     * slice-156/157 WHERE admission ({@link #buildPivotWhereColumnRefs}) and uses
10118     * the same provable-passthrough doctrine: a passthrough column survives the
10119     * PIVOT/UNPIVOT unchanged, so grouping on it resolves directly to the
10120     * underlying source column ({@code sourceAlias.col}).
10121     *
10122     * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the
10123     * slice-129 deferral, code kept reached) when the GROUP BY cannot be admitted:
10124     * it contains a subquery; or any grouped ref is NOT a provable passthrough
10125     * column (a pivot-output column, a consumed FOR / aggregation-arg or IN-list
10126     * column, or a column whose source membership cannot be proven without a
10127     * catalog). A GROUP BY paired with a HAVING already deferred earlier in
10128     * {@link #rejectPivotExtraClauses}, so it never reaches here. The
10129     * {@code dbObjectType == column} filter excludes function names and other
10130     * non-column tokens.
10131     */
10132    private static List<ColumnRef> buildPivotGroupByColumnRefs(TSelectSqlStatement select,
10133                                                               TPivotClause pc,
10134                                                               boolean isUnpivot,
10135                                                               String sourceAlias,
10136                                                               TTable sourceTable,
10137                                                               NameBindingProvider provider,
10138                                                               TTable pivotTable) {
10139        TGroupBy groupBy = select.getGroupByClause();
10140        if (groupBy == null || groupBy.getItems() == null
10141                || groupBy.getItems().size() == 0) {
10142            return Collections.emptyList();
10143        }
10144        // The PROVABLE passthrough source columns of the operator — the same set
10145        // the WHERE admission uses. Empty when membership cannot be proven (a base
10146        // table with no catalog), keeping the GROUP BY deferred.
10147        Set<String> passthroughLC = isUnpivot
10148                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10149                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10150        if (!passthroughLC.isEmpty()) {
10151            final List<TObjectName> groupCols = new ArrayList<>();
10152            final boolean[] hasSubquery = {false};
10153            TParseTreeVisitor v = new TParseTreeVisitor() {
10154                @Override
10155                public void preVisit(TObjectName n) {
10156                    groupCols.add(n);
10157                }
10158
10159                @Override
10160                public void preVisit(TSelectSqlStatement s) {
10161                    hasSubquery[0] = true;
10162                }
10163            };
10164            TGroupByItemList items = groupBy.getItems();
10165            for (int i = 0; i < items.size(); i++) {
10166                TGroupByItem item = items.getGroupByItem(i);
10167                if (item != null && item.getExpr() != null) {
10168                    item.getExpr().acceptChildren(v);
10169                }
10170            }
10171            if (!hasSubquery[0]) {
10172                LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10173                boolean allPassthrough = true;
10174                for (TObjectName n : groupCols) {
10175                    if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10176                        continue;
10177                    }
10178                    String col = n.getColumnNameOnly();
10179                    if (col == null || col.isEmpty() || "*".equals(col)) {
10180                        continue;
10181                    }
10182                    if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10183                        allPassthrough = false;
10184                        break;
10185                    }
10186                    refs.add(new ColumnRef(sourceAlias, col));
10187                }
10188                if (allPassthrough) {
10189                    return new ArrayList<>(refs);
10190                }
10191            }
10192        }
10193        throw new SemanticIRBuildException(
10194                Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10195                pivotOperatorKeyword(pivotTable) + " combined with a GROUP BY clause "
10196                        + "is not supported yet", pivotTable));
10197    }
10198
10199    /**
10200     * Slice 159 — resolve the column refs of a {@code QUALIFY} clause over a PIVOT
10201     * or UNPIVOT, admitting the clause only when EVERY column the QUALIFY touches
10202     * (its window-function partition / order columns and any other column refs) is
10203     * a PROVABLE passthrough source column. Returns the resolved refs (document
10204     * order, deduped) for the {@code StatementGraph} qualifyColumnRefs slot, or an
10205     * empty list when there is no QUALIFY. This is the QUALIFY sibling of the
10206     * slice-156/157 WHERE admission ({@link #buildPivotWhereColumnRefs}) and the
10207     * slice-158 GROUP BY admission ({@link #buildPivotGroupByColumnRefs}), using
10208     * the same provable-passthrough doctrine: a passthrough column survives the
10209     * PIVOT/UNPIVOT unchanged, so a QUALIFY touching only those columns resolves
10210     * directly to the underlying source column ({@code sourceAlias.col}).
10211     *
10212     * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the
10213     * slice-129 deferral, code kept reached) when the QUALIFY cannot be admitted:
10214     * it contains a subquery; or any ref is NOT a provable passthrough column (a
10215     * pivot-output column, an UNPIVOT VALUE / FOR column, a consumed FOR / agg-arg
10216     * or IN-list column, or a column whose source membership cannot be proven
10217     * without a catalog). A QUALIFY paired with a HAVING already deferred earlier
10218     * in {@link #rejectPivotExtraClauses}, so it never reaches here. The
10219     * {@code dbObjectType == column} filter excludes the window-function name and
10220     * other non-column tokens.
10221     */
10222    private static List<ColumnRef> buildPivotQualifyColumnRefs(TSelectSqlStatement select,
10223                                                               TPivotClause pc,
10224                                                               boolean isUnpivot,
10225                                                               String sourceAlias,
10226                                                               TTable sourceTable,
10227                                                               NameBindingProvider provider,
10228                                                               TTable pivotTable) {
10229        if (select.getQualifyClause() == null
10230                || select.getQualifyClause().getSearchConditoin() == null) {
10231            return Collections.emptyList();
10232        }
10233        TExpression qualify = select.getQualifyClause().getSearchConditoin();
10234        // The PROVABLE passthrough source columns of the operator — the same set
10235        // the WHERE and GROUP BY admissions use. Empty when membership cannot be
10236        // proven (a base table with no catalog), keeping the QUALIFY deferred.
10237        Set<String> passthroughLC = isUnpivot
10238                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10239                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10240        if (!passthroughLC.isEmpty()) {
10241            final List<TObjectName> qualifyCols = new ArrayList<>();
10242            final boolean[] hasSubquery = {false};
10243            TParseTreeVisitor v = new TParseTreeVisitor() {
10244                @Override
10245                public void preVisit(TObjectName n) {
10246                    qualifyCols.add(n);
10247                }
10248
10249                @Override
10250                public void preVisit(TSelectSqlStatement s) {
10251                    hasSubquery[0] = true;
10252                }
10253            };
10254            qualify.acceptChildren(v);
10255            if (!hasSubquery[0]) {
10256                LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10257                boolean allPassthrough = true;
10258                for (TObjectName n : qualifyCols) {
10259                    if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10260                        continue;
10261                    }
10262                    String col = n.getColumnNameOnly();
10263                    if (col == null || col.isEmpty() || "*".equals(col)) {
10264                        continue;
10265                    }
10266                    if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10267                        allPassthrough = false;
10268                        break;
10269                    }
10270                    refs.add(new ColumnRef(sourceAlias, col));
10271                }
10272                if (allPassthrough) {
10273                    return new ArrayList<>(refs);
10274                }
10275            }
10276        }
10277        throw new SemanticIRBuildException(
10278                Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10279                pivotOperatorKeyword(pivotTable) + " combined with a QUALIFY clause "
10280                        + "is not supported yet", pivotTable));
10281    }
10282
10283    /**
10284     * Slice 161 — resolve the column refs of an {@code ORDER BY} clause over a
10285     * PIVOT or UNPIVOT, recording them in the {@code orderByColumnRefs} slot only
10286     * when EVERY sort-key column is a PROVABLE passthrough source column. Returns
10287     * the resolved refs (document order, deduped) or an empty list.
10288     *
10289     * <p>UNLIKE the slice-156/157 WHERE, slice-158 GROUP BY, slice-159 QUALIFY, and
10290     * slice-160 HAVING admissions (which THROW
10291     * {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} when the clause
10292     * cannot be admitted), an ORDER BY over a pivot is ALREADY admitted (slice 142,
10293     * lineage-neutral — it only re-shapes the row order, not the output column set).
10294     * This helper therefore NEVER throws: it merely refines the lineage by recording
10295     * the passthrough refs when they can be proven, and otherwise returns an empty
10296     * list — exactly the lineage-neutral slice-142 behaviour. The slot stays empty
10297     * when: there is no ORDER BY; the source has no provable schema (a base table
10298     * with no catalog); any sort key contains a subquery; or any sort-key column is
10299     * NOT a provable passthrough column (a pivot-output / UNPIVOT-value / FOR /
10300     * consumed column, an ordinal, or a projection-alias reference). The
10301     * all-or-nothing gate mirrors the WHERE doctrine: a mixed ORDER BY (one
10302     * passthrough + one non-passthrough key) records nothing rather than partial
10303     * lineage. The {@code dbObjectType == column} filter excludes function names and
10304     * other non-column tokens; ordinals/aliases that are not source columns fail the
10305     * passthrough membership test and keep the slot empty.
10306     */
10307    private static List<ColumnRef> buildPivotOrderByColumnRefs(TSelectSqlStatement select,
10308                                                               TPivotClause pc,
10309                                                               boolean isUnpivot,
10310                                                               String sourceAlias,
10311                                                               TTable sourceTable,
10312                                                               NameBindingProvider provider) {
10313        TOrderBy orderBy = select.getOrderbyClause();
10314        if (orderBy == null || orderBy.getItems() == null) {
10315            return Collections.emptyList();
10316        }
10317        // The PROVABLE passthrough source columns of the operator — the same set the
10318        // WHERE, GROUP BY, HAVING, and QUALIFY admissions use. Empty when membership
10319        // cannot be proven (a base table with no catalog), keeping ORDER BY
10320        // lineage-neutral.
10321        Set<String> passthroughLC = isUnpivot
10322                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10323                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10324        if (passthroughLC.isEmpty()) {
10325            return Collections.emptyList();
10326        }
10327        final List<TObjectName> orderByCols = new ArrayList<>();
10328        final boolean[] hasSubquery = {false};
10329        TParseTreeVisitor v = new TParseTreeVisitor() {
10330            @Override
10331            public void preVisit(TObjectName n) {
10332                orderByCols.add(n);
10333            }
10334
10335            @Override
10336            public void preVisit(TSelectSqlStatement s) {
10337                hasSubquery[0] = true;
10338            }
10339        };
10340        TOrderByItemList items = orderBy.getItems();
10341        for (int i = 0; i < items.size(); i++) {
10342            TOrderByItem item = items.getOrderByItem(i);
10343            if (item == null || item.getSortKey() == null) {
10344                continue;
10345            }
10346            item.getSortKey().acceptChildren(v);
10347        }
10348        if (hasSubquery[0]) {
10349            return Collections.emptyList();
10350        }
10351        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10352        for (TObjectName n : orderByCols) {
10353            if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10354                continue;
10355            }
10356            String col = n.getColumnNameOnly();
10357            if (col == null || col.isEmpty() || "*".equals(col)) {
10358                continue;
10359            }
10360            if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10361                // Not all passthrough (a pivot-output / consumed column, or an
10362                // alias) — stay lineage-neutral (slice 142), do NOT defer.
10363                return Collections.emptyList();
10364            }
10365            refs.add(new ColumnRef(sourceAlias, col));
10366        }
10367        return new ArrayList<>(refs);
10368    }
10369
10370    /**
10371     * Slice 160 — resolve the column refs of a {@code HAVING} clause over a PIVOT
10372     * or UNPIVOT, admitting the clause only when EVERY column the HAVING touches
10373     * (its aggregate-function arguments and any other column refs) is a PROVABLE
10374     * passthrough source column. Returns the resolved refs (document order,
10375     * deduped) for the {@code StatementGraph} havingColumnRefs slot, or an empty
10376     * list when there is no HAVING. This is the HAVING sibling of the slice-156/157
10377     * WHERE admission ({@link #buildPivotWhereColumnRefs}), the slice-158 GROUP BY
10378     * admission ({@link #buildPivotGroupByColumnRefs}), and the slice-159 QUALIFY
10379     * admission ({@link #buildPivotQualifyColumnRefs}), using the same
10380     * provable-passthrough doctrine: a passthrough column survives the
10381     * PIVOT/UNPIVOT unchanged, so a HAVING touching only those columns resolves
10382     * directly to the underlying source column ({@code sourceAlias.col}). A
10383     * {@code HAVING COUNT(*)} touches no column at all, so it admits over a provable
10384     * source (empty refs) but still defers over a base table with no catalog (the
10385     * provable-passthrough set is empty, so membership cannot be proven).
10386     *
10387     * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the
10388     * slice-129 deferral, code kept reached) when the HAVING cannot be admitted: it
10389     * contains a subquery; or any ref is NOT a provable passthrough column (a
10390     * pivot-output column, an UNPIVOT VALUE / FOR column, a consumed FOR / agg-arg
10391     * or IN-list column, or a column whose source membership cannot be proven
10392     * without a catalog). The {@code dbObjectType == column} filter excludes the
10393     * aggregate-function names and other non-column tokens.
10394     */
10395    private static List<ColumnRef> buildPivotHavingColumnRefs(TSelectSqlStatement select,
10396                                                              TPivotClause pc,
10397                                                              boolean isUnpivot,
10398                                                              String sourceAlias,
10399                                                              TTable sourceTable,
10400                                                              NameBindingProvider provider,
10401                                                              TTable pivotTable) {
10402        TGroupBy groupBy = select.getGroupByClause();
10403        TExpression having = (groupBy == null) ? null : groupBy.getHavingClause();
10404        if (having == null) {
10405            return Collections.emptyList();
10406        }
10407        // The PROVABLE passthrough source columns of the operator — the same set
10408        // the WHERE, GROUP BY, and QUALIFY admissions use. Empty when membership
10409        // cannot be proven (a base table with no catalog), keeping the HAVING
10410        // deferred.
10411        Set<String> passthroughLC = isUnpivot
10412                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10413                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10414        if (!passthroughLC.isEmpty()) {
10415            final List<TObjectName> havingCols = new ArrayList<>();
10416            final boolean[] hasSubquery = {false};
10417            TParseTreeVisitor v = new TParseTreeVisitor() {
10418                @Override
10419                public void preVisit(TObjectName n) {
10420                    havingCols.add(n);
10421                }
10422
10423                @Override
10424                public void preVisit(TSelectSqlStatement s) {
10425                    hasSubquery[0] = true;
10426                }
10427            };
10428            having.acceptChildren(v);
10429            if (!hasSubquery[0]) {
10430                LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10431                boolean allPassthrough = true;
10432                for (TObjectName n : havingCols) {
10433                    if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10434                        continue;
10435                    }
10436                    String col = n.getColumnNameOnly();
10437                    if (col == null || col.isEmpty() || "*".equals(col)) {
10438                        continue;
10439                    }
10440                    if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10441                        allPassthrough = false;
10442                        break;
10443                    }
10444                    refs.add(new ColumnRef(sourceAlias, col));
10445                }
10446                if (allPassthrough) {
10447                    return new ArrayList<>(refs);
10448                }
10449            }
10450        }
10451        throw new SemanticIRBuildException(
10452                Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10453                pivotOperatorKeyword(pivotTable) + " combined with a HAVING clause "
10454                        + "is not supported yet", pivotTable));
10455    }
10456
10457    /**
10458     * Slice 156 — the lower-cased PROVABLE passthrough (group) source column
10459     * names of a PIVOT: the source's published columns (the catalog or a
10460     * FROM-subquery's published schema) MINUS the columns consumed by the PIVOT
10461     * (the FOR / pivot column(s) and the aggregation argument(s)). Empty when the
10462     * source has no provable schema (a base table with no catalog). This is the
10463     * slice-153 passthrough rule, factored out so the WHERE-clause admission
10464     * (slice 156) and {@link #buildPivotOutputColumns}' passthrough column
10465     * resolution share one source of truth.
10466     */
10467    private static Set<String> provablePivotPassthroughColsLC(TPivotClause pc,
10468                                                              String sourceAlias,
10469                                                              TTable sourceTable,
10470                                                              NameBindingProvider provider) {
10471        List<String> srcCols = lookupRelationColumnNames(sourceTable, provider);
10472        if (srcCols == null || srcCols.isEmpty()) {
10473            return Collections.emptySet();
10474        }
10475        Set<String> consumedLC = new HashSet<>();
10476        List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias));
10477        consumed.addAll(collectPivotAggArgRefs(pc, sourceAlias));
10478        for (ColumnRef r : consumed) {
10479            String cn = (r == null) ? null : r.getColumnName();
10480            if (cn != null && !cn.isEmpty()) {
10481                consumedLC.add(cn.toLowerCase(Locale.ROOT));
10482            }
10483        }
10484        Set<String> passthrough = new HashSet<>();
10485        for (String c : srcCols) {
10486            if (c == null || c.isEmpty()) {
10487                continue;
10488            }
10489            String lc = c.toLowerCase(Locale.ROOT);
10490            if (!consumedLC.contains(lc)) {
10491                passthrough.add(lc);
10492            }
10493        }
10494        return passthrough;
10495    }
10496
10497    /**
10498     * Slice 157 — the lower-cased PROVABLE passthrough source column names of an
10499     * UNPIVOT: the source's published columns (the catalog or a FROM-subquery's
10500     * published schema) MINUS the IN-list SOURCE columns the UNPIVOT narrows into
10501     * rows ({@link #collectUnpivotInListRefs}). Empty when the source has no
10502     * provable schema (a base table with no catalog). These are exactly the
10503     * passthrough OUTPUT columns {@link #expandUnpivotBareStar} keeps unchanged;
10504     * the synthesised VALUE / FOR-name output columns are NOT source columns (by
10505     * UNPIVOT semantics they cannot name an existing input column), so they are
10506     * absent from the published set and a WHERE touching them stays deferred. This
10507     * is the UNPIVOT mirror of {@link #provablePivotPassthroughColsLC}, shared by
10508     * the WHERE-clause admission ({@link #buildPivotWhereColumnRefs}).
10509     */
10510    private static Set<String> provableUnpivotPassthroughColsLC(TPivotClause pc,
10511                                                                String sourceAlias,
10512                                                                TTable sourceTable,
10513                                                                NameBindingProvider provider) {
10514        List<String> srcCols = lookupRelationColumnNames(sourceTable, provider);
10515        if (srcCols == null || srcCols.isEmpty()) {
10516            return Collections.emptySet();
10517        }
10518        Set<String> consumedLC = new HashSet<>();
10519        for (ColumnRef r : collectUnpivotInListRefs(pc, sourceAlias)) {
10520            String cn = (r == null) ? null : r.getColumnName();
10521            if (cn != null && !cn.isEmpty()) {
10522                consumedLC.add(cn.toLowerCase(Locale.ROOT));
10523            }
10524        }
10525        Set<String> passthrough = new HashSet<>();
10526        for (String c : srcCols) {
10527            if (c == null || c.isEmpty()) {
10528                continue;
10529            }
10530            String lc = c.toLowerCase(Locale.ROOT);
10531            if (!consumedLC.contains(lc)) {
10532                passthrough.add(lc);
10533            }
10534        }
10535        return passthrough;
10536    }
10537
10538    /**
10539     * Slice 129 — capture the columns CONSUMED by a PIVOT: the FOR / pivot
10540     * column(s) first, then the aggregation-function argument column(s), in
10541     * document order, deduplicated by (alias, columnName). Each is built
10542     * DIRECTLY as a {@link ColumnRef} against the bound source relation —
10543     * resolver2 marks these refs NOT_FOUND in PIVOT context even though
10544     * Phase-1 {@code linkColumnToTable} sets their {@code sourceTable}, so the
10545     * strict {@code collectColumnRefs} path would reject them.
10546     */
10547    private static List<ColumnRef> collectPivotConsumedRefs(TPivotClause pc,
10548                                                            String sourceAlias) {
10549        // Consumed = FOR / pivot column(s) FIRST, then aggregation-arg
10550        // column(s), document order, deduped (LinkedHashSet). Slice 130 split
10551        // the two halves into reusable helpers: the agg-arg refs are also the
10552        // sources of a pivot-output column (see buildPivotOutputColumns).
10553        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10554        refs.addAll(collectPivotForRefs(pc, sourceAlias));
10555        refs.addAll(collectPivotAggArgRefs(pc, sourceAlias));
10556        return new ArrayList<>(refs);
10557    }
10558
10559    /**
10560     * Slice 130 — the FOR / pivot column ref(s) consumed by a PIVOT, built
10561     * directly against {@code sourceAlias} (deduped, document order). Split out
10562     * of {@link #collectPivotConsumedRefs} so the consumed-refs slot and the
10563     * output-column lineage share one source of truth.
10564     */
10565    private static List<ColumnRef> collectPivotForRefs(TPivotClause pc, String sourceAlias) {
10566        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10567        TObjectNameList forCols = pc.getPivotColumnList();
10568        if (forCols != null) {
10569            for (int i = 0; i < forCols.size(); i++) {
10570                addPivotConsumedRef(refs, forCols.getObjectName(i), sourceAlias);
10571            }
10572        } else if (pc.getPivotColumn() != null) {
10573            addPivotConsumedRef(refs, pc.getPivotColumn(), sourceAlias);
10574        }
10575        return new ArrayList<>(refs);
10576    }
10577
10578    /**
10579     * Slice 130 — the aggregation-function argument column ref(s) (the value
10580     * being aggregated), built directly against {@code sourceAlias} (deduped,
10581     * document order). SQL Server / Snowflake carry a single
10582     * {@code TFunctionCall}; Oracle a {@code TResultColumnList}. These are the
10583     * sources of a pivot-output column. Function names and column-alias nodes
10584     * are skipped by the {@code dbObjectType} filter in addPivotConsumedRef.
10585     */
10586    private static List<ColumnRef> collectPivotAggArgRefs(TPivotClause pc, String sourceAlias) {
10587        final List<TObjectName> argCols = new ArrayList<>();
10588        TParseTreeVisitor argVisitor = new TParseTreeVisitor() {
10589            @Override
10590            public void preVisit(TObjectName n) {
10591                argCols.add(n);
10592            }
10593        };
10594        if (pc.getAggregation_function() != null
10595                && pc.getAggregation_function().getArgs() != null) {
10596            pc.getAggregation_function().getArgs().acceptChildren(argVisitor);
10597        }
10598        if (pc.getAggregation_function_list() != null) {
10599            pc.getAggregation_function_list().acceptChildren(argVisitor);
10600        }
10601        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10602        for (TObjectName n : argCols) {
10603            addPivotConsumedRef(refs, n, sourceAlias);
10604        }
10605        return new ArrayList<>(refs);
10606    }
10607
10608    /**
10609     * Slice 154 — the aggregation-argument column ref(s) for a SINGLE aggregation
10610     * in a multi-aggregation PIVOT's {@code aggregation_function_list} (the value
10611     * being aggregated by THIS one function), built directly against
10612     * {@code sourceAlias} (deduped, document order). This is the per-aggregation
10613     * counterpart of {@link #collectPivotAggArgRefs} (which unions ALL
10614     * aggregations' args): a multi-agg cross-product output column
10615     * {@code <in_value>_<agg_alias>} draws from ONLY the named aggregation's
10616     * argument, not every aggregation's. Function names / alias nodes are excluded
10617     * by the {@code dbObjectType == column} filter in {@link #addPivotConsumedRef}.
10618     */
10619    private static List<ColumnRef> collectSingleAggArgRefs(TResultColumn aggRc,
10620                                                           String sourceAlias) {
10621        final List<TObjectName> argCols = new ArrayList<>();
10622        TParseTreeVisitor argVisitor = new TParseTreeVisitor() {
10623            @Override
10624            public void preVisit(TObjectName n) {
10625                argCols.add(n);
10626            }
10627        };
10628        if (aggRc != null && aggRc.getExpr() != null
10629                && aggRc.getExpr().getFunctionCall() != null
10630                && aggRc.getExpr().getFunctionCall().getArgs() != null) {
10631            aggRc.getExpr().getFunctionCall().getArgs().acceptChildren(argVisitor);
10632        }
10633        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10634        for (TObjectName n : argCols) {
10635            addPivotConsumedRef(refs, n, sourceAlias);
10636        }
10637        return new ArrayList<>(refs);
10638    }
10639
10640    /** Slice 130 — number of aggregation functions in the PIVOT clause. */
10641    private static int pivotAggregationCount(TPivotClause pc) {
10642        if (pc.getAggregation_function() != null) {
10643            return 1;
10644        }
10645        if (pc.getAggregation_function_list() != null) {
10646            return pc.getAggregation_function_list().size();
10647        }
10648        return 0;
10649    }
10650
10651    /**
10652     * Slice 130 — true when the PIVOT IN-list is a STATIC value list, so the
10653     * pivot-output column names are known at build time. False for a subquery
10654     * or {@code ANY} dynamic IN-list (the parser leaves {@code getItems()}
10655     * null or empty), where output lineage stays deferred.
10656     */
10657    private static boolean hasStaticInList(TPivotClause pc) {
10658        return pc.getPivotInClause() != null
10659                && pc.getPivotInClause().getItems() != null
10660                && pc.getPivotInClause().getItems().size() > 0;
10661    }
10662
10663    /**
10664     * Slice 130 — the set of pivot-output column names: the IN-list items'
10665     * display names ({@code [2006]} for SQL Server bracket values, the IN-list
10666     * alias such as {@code pa} / {@code jan} for Oracle / Snowflake). A
10667     * projected column whose underlying column name
10668     * ({@link TResultColumn#getColumnNameOnly()}) is in this set is a pivot
10669     * output (sourced from the aggregation argument); any other simple-column
10670     * projection is a passthrough (group) column. Caller must have confirmed a
10671     * static IN-list via {@link #hasStaticInList}.
10672     *
10673     * <p>Returns a {@link LinkedHashSet} so the names are in IN-list DOCUMENT
10674     * ORDER. Slice 130 only used this for {@code contains()} membership, but
10675     * slice 133's {@code SELECT *} expansion ({@link #expandPivotBareStar})
10676     * relies on the ordered iteration to emit pivot-output columns in IN-list
10677     * order — so the ordered return type is part of the contract.
10678     */
10679    private static LinkedHashSet<String> collectPivotOutputNames(TPivotClause pc) {
10680        LinkedHashSet<String> names = new LinkedHashSet<>();
10681        TPivotInClause in = pc.getPivotInClause();
10682        if (in != null && in.getItems() != null) {
10683            for (int i = 0; i < in.getItems().size(); i++) {
10684                TResultColumn item = in.getItems().getResultColumn(i);
10685                if (item == null) {
10686                    continue;
10687                }
10688                String dn = item.getDisplayName();
10689                if (dn != null && !dn.isEmpty()) {
10690                    names.add(dn);
10691                }
10692            }
10693        }
10694        return names;
10695    }
10696
10697    /**
10698     * Slice 129 helper — add one consumed column ref to {@code refs} iff it is
10699     * an actual column (not a function name, column alias, or {@code *}). The
10700     * ref is attributed DIRECTLY to {@code sourceAlias}.
10701     *
10702     * <p>No source-table match guard: sub-slice (a) admits only a single
10703     * base-table PIVOT source, so every column consumed by the FOR clause or
10704     * an aggregation argument necessarily belongs to that one source relation
10705     * — there is no other relation to mis-attribute to, and resolver2 leaves
10706     * these refs NOT_FOUND in PIVOT context anyway. Guarding on
10707     * {@code sourceTable} identity / name would SILENTLY DROP a valid consumed
10708     * column when Phase-1 left {@code sourceTable} null or shaped its name
10709     * differently (alias-normalized / schema-qualified) — codex diff-review
10710     * round 1 BLOCKING. The {@code dbObjectType == column} filter already
10711     * excludes function names and column-alias nodes.
10712     */
10713    private static void addPivotConsumedRef(LinkedHashSet<ColumnRef> refs,
10714                                            TObjectName ref,
10715                                            String sourceAlias) {
10716        if (ref == null || ref.getDbObjectType() != EDbObjectType.column) {
10717            return;
10718        }
10719        String col = ref.getColumnNameOnly();
10720        if (col == null || col.isEmpty() || "*".equals(col)) {
10721            return;
10722        }
10723        refs.add(new ColumnRef(sourceAlias, col));
10724    }
10725
10726    /**
10727     * Slice 129 / 130 — build output columns for a PIVOT SELECT. Each projected
10728     * result column yields one OutputColumn whose name is the projection's
10729     * effective name. A {@code SELECT *} / {@code t.*} over a PIVOT is deferred
10730     * (it needs the full synthesised output schema / a catalog).
10731     *
10732     * <p>Slice 130 sub-slice (b1) attaches output-column LINEAGE when the
10733     * output schema maps deterministically to source columns — i.e. a SINGLE
10734     * aggregation over a STATIC IN-list ({@code lineageEnabled}):
10735     * <ul>
10736     *   <li><b>pivot-output column</b> — the projected column's underlying name
10737     *       ({@link TResultColumn#getColumnNameOnly()}) is one of the IN-list
10738     *       display names. It is the aggregate sliced by the FOR value, so
10739     *       {@code derived=true}, {@code aggregate=true}, and sources are the
10740     *       aggregation-argument column(s).</li>
10741     *   <li><b>passthrough (group) column</b> — any other simple-column
10742     *       projection. It is a direct reference to the source column, so
10743     *       {@code derived=false}, {@code aggregate=false}, and the single
10744     *       source is {@code sourceAlias.getColumnNameOnly()}. The output NAME
10745     *       may be aliased ({@code vendorid AS v}) but the SOURCE column is the
10746     *       underlying name.</li>
10747     * </ul>
10748     * Multi-aggregation (vendor-specific cross-product output names like
10749     * {@code pa_s}), a dynamic / subquery IN-list, and expression projections
10750     * keep the slice-129 NAME-only skeleton ({@code derived=true},
10751     * {@code aggregate=false}, EMPTY sources) — no false lineage.
10752     *
10753     * <p>All refs are built DIRECTLY against {@code sourceAlias}: sub-slice (a)
10754     * admits only a single base-table source, so every passthrough / agg-arg
10755     * column belongs to that one relation, and resolver2 marks them NOT_FOUND
10756     * in PIVOT context anyway (slice 129).
10757     */
10758    private static List<OutputColumn> buildPivotOutputColumns(TSelectSqlStatement select,
10759                                                              TTable pivotTable,
10760                                                              TPivotClause pc,
10761                                                              String sourceAlias,
10762                                                              TTable sourceTable,
10763                                                              NameBindingProvider provider) {
10764        TResultColumnList rcl = select.getResultColumnList();
10765        if (rcl == null || rcl.size() == 0) {
10766            throw new SemanticIRBuildException(
10767                    Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS,
10768                    "SELECT has no projected columns", select));
10769        }
10770        boolean lineageEnabled = pivotAggregationCount(pc) == 1 && hasStaticInList(pc);
10771        Set<String> pivotOutputNames = lineageEnabled
10772                ? collectPivotOutputNames(pc) : Collections.<String>emptySet();
10773        List<ColumnRef> aggArgRefs = lineageEnabled
10774                ? collectPivotAggArgRefs(pc, sourceAlias) : Collections.<ColumnRef>emptyList();
10775
10776        // Slice 153 — a NON-lineage-enabled PIVOT (a multi-aggregation PIVOT or a
10777        // dynamic / subquery IN-list) still has determinable PASSTHROUGH (group)
10778        // column lineage: a passthrough column passes through UNCHANGED from the
10779        // source regardless of the aggregation count or IN-list shape. Slice 130
10780        // emitted every projected column of such a PIVOT as a NAME-only skeleton
10781        // (empty sources); this attaches the source lineage to the passthrough
10782        // columns only. The set below is the lower-cased SOURCE column names that
10783        // are NOT consumed by the PIVOT (the FOR / pivot column(s) and the
10784        // aggregation-argument column(s)); a projected simple-column whose name is
10785        // in it is a passthrough. This is keyed off PROVABLE source membership
10786        // (the source catalog or a FROM-subquery's published columns) so it never
10787        // invents a false source for a vendor-specific cross-product output column
10788        // (e.g. `y6_s`) — those names are not source columns. Without a source
10789        // catalog (a base table and no Catalog) the set is empty, so the behaviour
10790        // is unchanged. (The lineage-enabled single-agg path below keys off the
10791        // KNOWN IN-list output-name set instead — for multi-agg those names are
10792        // vendor-specific cross-products we do not enumerate, hence the
10793        // catalog-confirmation rule here.)
10794        // Slice 156 — factored into provablePivotPassthroughColsLC (shared with
10795        // the WHERE-clause admission). Computed only for a non-lineage-enabled
10796        // PIVOT: the lineage-enabled single-agg path below keys off the KNOWN
10797        // IN-list output-name set instead.
10798        Set<String> passthroughSourceColsLC = lineageEnabled
10799                ? Collections.<String>emptySet()
10800                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10801
10802        // Slice 154 — a MULTI-aggregation PIVOT over a STATIC IN-list produces one
10803        // CROSS-PRODUCT output column per (IN-value × aggregation), named
10804        // `<in_value_display_name>_<agg_alias>` (`y6_s`, `y6_a`). Slice 130 / 153
10805        // left these as NAME-only skeletons because the names are vendor-specific
10806        // and were not enumerated. Build the map `<in_value>_<agg> -> that one
10807        // aggregation's argument column ref(s)` so a projected cross-product column
10808        // carries the SPECIFIC aggregation's lineage (NOT the union of all agg
10809        // args). Unlike the slice-153 passthrough rule, this needs NO source
10810        // catalog: the IN-value names and each aggregation's argument are fully
10811        // determined by the PIVOT clause itself (so a base table without a catalog
10812        // resolves the same as a subquery source — mirroring the lineage-enabled
10813        // single-agg path, which also reads the agg arg straight from the clause).
10814        // Keys are lower-cased (Locale.ROOT) to match the project's identifier
10815        // folding. A key produced by two (IN-value, agg) pairs with DIFFERENT
10816        // sources is AMBIGUOUS (e.g. degenerate names whose `_` concatenation
10817        // collides) and is dropped so it never attaches a wrong source.
10818        Map<String, List<ColumnRef>> crossProductSourcesLC = Collections.emptyMap();
10819        Set<String> ambiguousCrossProductLC = Collections.emptySet();
10820        if (!lineageEnabled && hasStaticInList(pc)
10821                && pc.getAggregation_function_list() != null
10822                && pc.getAggregation_function_list().size() > 1) {
10823            Map<String, List<ColumnRef>> xpMap = new HashMap<>();
10824            Set<String> xpAmbiguous = new HashSet<>();
10825            TResultColumnList aggList = pc.getAggregation_function_list();
10826            for (String inVal : collectPivotOutputNames(pc)) {
10827                if (inVal == null || inVal.isEmpty()) {
10828                    continue;
10829                }
10830                for (int ai = 0; ai < aggList.size(); ai++) {
10831                    TResultColumn aggRc = aggList.getResultColumn(ai);
10832                    if (aggRc == null) {
10833                        continue;
10834                    }
10835                    String aggAlias = aggRc.getDisplayName();
10836                    if (aggAlias == null || aggAlias.isEmpty()) {
10837                        continue;
10838                    }
10839                    String key = (inVal + "_" + aggAlias).toLowerCase(Locale.ROOT);
10840                    List<ColumnRef> argRefs = collectSingleAggArgRefs(aggRc, sourceAlias);
10841                    if (xpMap.containsKey(key)) {
10842                        if (!xpMap.get(key).equals(argRefs)) {
10843                            xpAmbiguous.add(key);
10844                        }
10845                    } else {
10846                        xpMap.put(key, argRefs);
10847                    }
10848                }
10849            }
10850            crossProductSourcesLC = xpMap;
10851            ambiguousCrossProductLC = xpAmbiguous;
10852        }
10853
10854        // Slice 133 (sub-slice (b2-pivot)) — a SOLE `SELECT *` over a
10855        // lineage-enabled PIVOT is expanded from the source table's catalog
10856        // column list into the synthesised output schema (group / passthrough
10857        // columns then pivot-output columns). A `*` mixed with explicit columns
10858        // and the non-lineage-enabled (multi-agg / dynamic-IN) `*` stay deferred
10859        // via the per-column reject below.
10860        //
10861        // Slice 151 — the SOLE star is now admitted whether it is BARE (`*`) or
10862        // QUALIFIED (`p.*` / `src.*`). An admitted PIVOT SELECT has exactly ONE
10863        // relation in scope — the pivot output (isPivotSelect requires a
10864        // FROM-PIVOT with no JOIN) — so a qualified star can only mean that one
10865        // relation and is therefore equivalent to a bare `*`; expandPivotBareStar
10866        // synthesises the identical schema (it never reads the star's qualifier,
10867        // only the source columns and the pivot clause). Slice 152 dropped the
10868        // mirror gate on the UNPIVOT bare-star path below.
10869        if (lineageEnabled && rcl.size() == 1) {
10870            TResultColumn only = rcl.getResultColumn(0);
10871            if ("*".equals(only.getColumnNameOnly())) {
10872                // Slice 137 lifted the slice-136 subquery-source deferral. A bare
10873                // SELECT * over a FROM-subquery PIVOT now expands the same way as a
10874                // base-table PIVOT: expandPivotBareStar reads the source's published
10875                // columns via lookupRelationColumnNames, which the slice-137
10876                // addRelationToInScopeMap unwrap registers under the subquery alias.
10877                // No catalog is required for a subquery source — the subquery's
10878                // explicit projection IS the published schema.
10879                return expandPivotBareStar(pc, sourceTable, sourceAlias, provider,
10880                        aggArgRefs, only);
10881            }
10882        }
10883
10884        // Slice 155 — admit a SOLE bare/qualified `*` over a MULTI-aggregation
10885        // static-IN PIVOT. Slice 130 / 133 deferred this (PIVOT_STAR_NOT_SUPPORTED)
10886        // because the cross-product output names were not enumerated; slice 154
10887        // enumerated them (`<in_value>_<agg_alias>` from the IN-list display names
10888        // and each aggregation's alias), so the full output schema is now
10889        // synthesisable: the passthrough (group) columns followed by the
10890        // cross-product output columns. (A dynamic / subquery IN-list multi-agg
10891        // bare `*` keeps the deferral via the per-column reject below — its
10892        // cross-product names are unknown. A single-agg static-IN bare `*` is
10893        // handled by the lineage-enabled block above.)
10894        if (!lineageEnabled && hasStaticInList(pc)
10895                && pc.getAggregation_function_list() != null
10896                && pc.getAggregation_function_list().size() > 1
10897                && rcl.size() == 1) {
10898            TResultColumn only = rcl.getResultColumn(0);
10899            if ("*".equals(only.getColumnNameOnly())) {
10900                return expandPivotMultiAggBareStar(pc, sourceTable, sourceAlias,
10901                        provider, only);
10902            }
10903        }
10904
10905        List<OutputColumn> outs = new ArrayList<>();
10906        for (int i = 0; i < rcl.size(); i++) {
10907            TResultColumn rc = rcl.getResultColumn(i);
10908            if ("*".equals(rc.getColumnNameOnly())) {
10909                throw new SemanticIRBuildException(
10910                        Diagnostic.error(DiagnosticCode.PIVOT_STAR_NOT_SUPPORTED,
10911                        "SELECT * over a PIVOT is not supported yet "
10912                                + "(the synthesised output schema is deferred); "
10913                                + "project explicit columns", rc));
10914            }
10915            // effectiveOutputName throws RESULT_COLUMN_NO_NAME for an
10916            // unaliased non-name expression — acceptable deferral.
10917            String outName = effectiveOutputName(rc);
10918            if (!lineageEnabled) {
10919                // A non-lineage-enabled PIVOT (multi-agg, or dynamic / subquery
10920                // IN-list). A simple-column projection can be one of two resolvable
10921                // shapes; everything else (expressions) keeps the skeleton.
10922                String colName = rc.getColumnNameOnly();
10923                boolean simpleCol = colName != null && !colName.isEmpty()
10924                        && rc.getExpr() != null
10925                        && rc.getExpr().getExpressionType()
10926                                == EExpressionType.simple_object_name_t;
10927                String colNameLC = simpleCol ? colName.toLowerCase(Locale.ROOT) : null;
10928                if (simpleCol && passthroughSourceColsLC.contains(colNameLC)) {
10929                    // Slice 153 — passthrough (group) column: a simple source
10930                    // column proven NOT consumed by the PIVOT, passes through
10931                    // unchanged. (Disjoint from a cross-product name: PIVOT
10932                    // semantics forbid an IN-value naming an existing source
10933                    // column, so `<in>_<agg>` is never a source column.)
10934                    outs.add(new OutputColumn(outName, /*derived=*/ false,
10935                            /*aggregate=*/ false,
10936                            Collections.singletonList(new ColumnRef(sourceAlias, colName)),
10937                            /*windowSpec=*/ null));
10938                } else if (simpleCol && crossProductSourcesLC.containsKey(colNameLC)
10939                        && !ambiguousCrossProductLC.contains(colNameLC)) {
10940                    // Slice 154 — multi-agg cross-product pivot-output column:
10941                    // the aggregate of its SPECIFIC aggregation sliced by the FOR
10942                    // value (derived + aggregate, sourced from that one
10943                    // aggregation's argument column(s)).
10944                    outs.add(new OutputColumn(outName, /*derived=*/ true,
10945                            /*aggregate=*/ true,
10946                            crossProductSourcesLC.get(colNameLC),
10947                            /*windowSpec=*/ null));
10948                } else {
10949                    outs.add(pivotSkeletonOutput(outName));
10950                }
10951                continue;
10952            }
10953            String colOnly = rc.getColumnNameOnly();
10954            // Name-collision is a non-issue here: PIVOT semantics forbid an
10955            // IN-list value from naming a column that already exists in the
10956            // input source (see TPivotClause), so an IN-list display name can
10957            // never coincide with a real passthrough source column.
10958            if (colOnly != null && !colOnly.isEmpty() && pivotOutputNames.contains(colOnly)) {
10959                // Pivot-output column: aggregate over the value column, sliced
10960                // by the FOR value. Sources = aggregation-arg column(s).
10961                outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ true,
10962                        aggArgRefs, /*windowSpec=*/ null));
10963            } else if (colOnly != null && !colOnly.isEmpty()
10964                    && rc.getExpr() != null
10965                    && rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t) {
10966                // Passthrough (group) column: direct reference to the source
10967                // column (the underlying name, not the output alias).
10968                outs.add(new OutputColumn(outName, /*derived=*/ false, /*aggregate=*/ false,
10969                        Collections.singletonList(new ColumnRef(sourceAlias, colOnly)),
10970                        /*windowSpec=*/ null));
10971            } else {
10972                // Expression projection (or no underlying column) — keep skeleton.
10973                outs.add(pivotSkeletonOutput(outName));
10974            }
10975        }
10976        return outs;
10977    }
10978
10979    /**
10980     * Slice 133 (sub-slice (b2-pivot)) — synthesise the full output schema for a
10981     * bare {@code SELECT *} over a lineage-enabled (single-agg / static-IN)
10982     * PIVOT, using the source table's catalog column list:
10983     * <ol>
10984     *   <li><b>group / passthrough columns</b> — every catalog column that is
10985     *       NOT consumed by the PIVOT (i.e. not the FOR / pivot column(s) and not
10986     *       an aggregation-argument column), in CATALOG declaration order. Each is
10987     *       a direct source reference ({@code derived=false},
10988     *       {@code aggregate=false}, single source {@code sourceAlias.col}).</li>
10989     *   <li><b>pivot-output columns</b> — one per IN-list value
10990     *       ({@link #collectPivotOutputNames}, IN-list order). Each is the
10991     *       aggregate sliced by the FOR value ({@code derived=true},
10992     *       {@code aggregate=true}, sources = the aggregation-argument column(s)).</li>
10993     * </ol>
10994     * This is the SQL-standard {@code SELECT *} PIVOT shape (SQL Server / Oracle /
10995     * Snowflake): the implicit GROUP BY columns first, then the generated pivot
10996     * columns. (There is no dlineage {@code SELECT *} golden to match: dlineage
10997     * cannot expand {@code *} without a catalog either.)
10998     *
10999     * <p>The consumed-column subtraction folds names with
11000     * {@code toLowerCase(Locale.ROOT)} — the SAME identifier canonicalisation the
11001     * other semantic-IR catalog matchers use ({@link #expandBareStarOverUsing},
11002     * {@link #lookupRelationColumnNames}); so it is aligned with the project's
11003     * catalog name folding. (A degenerate catalog holding two columns that differ
11004     * only by case would have both excluded by a single consumed name — an
11005     * inherently ambiguous catalog, not a realistic PIVOT source.)
11006     *
11007     * <p>Throws {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED} when no catalog
11008     * column list is available (mirrors slice-90
11009     * {@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED}).
11010     */
11011    private static List<OutputColumn> expandPivotBareStar(TPivotClause pc,
11012                                                          TTable sourceTable,
11013                                                          String sourceAlias,
11014                                                          NameBindingProvider provider,
11015                                                          List<ColumnRef> aggArgRefs,
11016                                                          TResultColumn star) {
11017        List<String> cols = lookupRelationColumnNames(sourceTable, provider);
11018        if (cols == null || cols.isEmpty()) {
11019            throw new SemanticIRBuildException(
11020                    Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED,
11021                    "SELECT * over a PIVOT requires catalog metadata for source '"
11022                            + sourceAlias + "' to expand the implicit group columns; "
11023                            + "supply a Catalog via "
11024                            + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star));
11025        }
11026        // Columns consumed by the PIVOT (FOR / pivot column(s) + aggregation-arg
11027        // column(s)); these are NOT passthrough group columns. Folded to lower
11028        // case (Locale.ROOT) to match the catalog identifier canonicalisation
11029        // used by the other semantic-IR catalog matchers (expandBareStarOverUsing,
11030        // lookupRelationColumnNames). addPivotConsumedRef already filters out
11031        // null / empty column names, so getColumnName() is non-null here; the
11032        // guard below is defensive.
11033        Set<String> consumedLC = new HashSet<>();
11034        List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias));
11035        consumed.addAll(aggArgRefs);
11036        for (ColumnRef r : consumed) {
11037            String cn = (r == null) ? null : r.getColumnName();
11038            if (cn != null && !cn.isEmpty()) {
11039                consumedLC.add(cn.toLowerCase(Locale.ROOT));
11040            }
11041        }
11042        List<OutputColumn> outs = new ArrayList<>();
11043        // 1. group / passthrough columns (catalog order, minus consumed).
11044        for (String c : cols) {
11045            if (c == null || c.isEmpty()) {
11046                continue;
11047            }
11048            if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) {
11049                continue;
11050            }
11051            outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false,
11052                    Collections.singletonList(new ColumnRef(sourceAlias, c)),
11053                    /*windowSpec=*/ null));
11054        }
11055        // 2. pivot-output columns (IN-list document order — collectPivotOutputNames
11056        //    returns a LinkedHashSet whose order is the IN-list order).
11057        for (String name : collectPivotOutputNames(pc)) {
11058            outs.add(new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ true,
11059                    aggArgRefs, /*windowSpec=*/ null));
11060        }
11061        return outs;
11062    }
11063
11064    /**
11065     * Slice 155 — synthesise the full output schema for a bare {@code SELECT *}
11066     * over a MULTI-aggregation static-IN PIVOT:
11067     * <ol>
11068     *   <li><b>passthrough / group columns</b> — every published source column
11069     *       NOT consumed by the PIVOT (the FOR / pivot column(s) and EVERY
11070     *       aggregation-argument column), in catalog / published declaration
11071     *       order. Each is a direct source reference ({@code derived=false},
11072     *       {@code aggregate=false}).</li>
11073     *   <li><b>cross-product output columns</b> — one per (IN-value × aggregation)
11074     *       named {@code <in_value_display_name>_<agg_alias>}, in
11075     *       IN-value-outer / aggregation-inner order (the SQL-standard / Oracle
11076     *       column order). Each is {@code derived=true}, {@code aggregate=true},
11077     *       sourced from that ONE aggregation's argument column(s).</li>
11078     * </ol>
11079     * Like {@link #expandPivotBareStar}, the passthrough expansion needs a source
11080     * catalog (or a FROM-subquery's published columns) — otherwise
11081     * {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED}. The cross-product part,
11082     * by contrast, is fully clause-determined (mirroring slice 154's explicit
11083     * cross-product lineage), so it needs no catalog. Caller has confirmed a
11084     * static IN-list and a multi-aggregation PIVOT.
11085     */
11086    private static List<OutputColumn> expandPivotMultiAggBareStar(TPivotClause pc,
11087                                                                  TTable sourceTable,
11088                                                                  String sourceAlias,
11089                                                                  NameBindingProvider provider,
11090                                                                  TResultColumn star) {
11091        List<String> cols = lookupRelationColumnNames(sourceTable, provider);
11092        if (cols == null || cols.isEmpty()) {
11093            throw new SemanticIRBuildException(
11094                    Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED,
11095                    "SELECT * over a PIVOT requires catalog metadata for source '"
11096                            + sourceAlias + "' to expand the implicit group columns; "
11097                            + "supply a Catalog via "
11098                            + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star));
11099        }
11100        // Columns consumed by the PIVOT (FOR / pivot column(s) + EVERY
11101        // aggregation-argument column); these are NOT passthrough group columns.
11102        // Folded to lower case (Locale.ROOT) to match the catalog identifier
11103        // canonicalisation used by the other semantic-IR catalog matchers.
11104        Set<String> consumedLC = new HashSet<>();
11105        List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias));
11106        consumed.addAll(collectPivotAggArgRefs(pc, sourceAlias));
11107        for (ColumnRef r : consumed) {
11108            String cn = (r == null) ? null : r.getColumnName();
11109            if (cn != null && !cn.isEmpty()) {
11110                consumedLC.add(cn.toLowerCase(Locale.ROOT));
11111            }
11112        }
11113        List<OutputColumn> outs = new ArrayList<>();
11114        // 1. passthrough / group columns (published order, minus consumed).
11115        for (String c : cols) {
11116            if (c == null || c.isEmpty()) {
11117                continue;
11118            }
11119            if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) {
11120                continue;
11121            }
11122            outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false,
11123                    Collections.singletonList(new ColumnRef(sourceAlias, c)),
11124                    /*windowSpec=*/ null));
11125        }
11126        // 2. cross-product output columns — IN-value outer (IN-list document
11127        //    order), aggregation inner (declaration order). Each traces to its
11128        //    OWN aggregation's argument column(s), not the union of all aggs.
11129        TResultColumnList aggList = pc.getAggregation_function_list();
11130        for (String inVal : collectPivotOutputNames(pc)) {
11131            if (inVal == null || inVal.isEmpty()) {
11132                continue;
11133            }
11134            for (int ai = 0; ai < aggList.size(); ai++) {
11135                TResultColumn aggRc = aggList.getResultColumn(ai);
11136                if (aggRc == null) {
11137                    continue;
11138                }
11139                String aggAlias = aggRc.getDisplayName();
11140                if (aggAlias == null || aggAlias.isEmpty()) {
11141                    continue;
11142                }
11143                outs.add(new OutputColumn(inVal + "_" + aggAlias,
11144                        /*derived=*/ true, /*aggregate=*/ true,
11145                        collectSingleAggArgRefs(aggRc, sourceAlias),
11146                        /*windowSpec=*/ null));
11147            }
11148        }
11149        return outs;
11150    }
11151
11152    /**
11153     * Slice 129 / 130 — a NAME-only deferred PIVOT output column ({@code
11154     * derived=true}, {@code aggregate=false}, EMPTY sources). Used for the
11155     * multi-aggregation / dynamic-IN / expression cases where output lineage
11156     * cannot be attributed without risking a false source.
11157     */
11158    private static OutputColumn pivotSkeletonOutput(String name) {
11159        return new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ false,
11160                Collections.<ColumnRef>emptyList(), /*windowSpec=*/ null);
11161    }
11162
11163    // -----------------------------------------------------------------
11164    // Slice 131 — PIVOT sub-slice (c): UNPIVOT output-column lineage.
11165    //
11166    // UNPIVOT narrows several source columns into rows. The clause carries:
11167    //   getValueColumnList() — the NEW value column(s) holding the unpivoted
11168    //                          values (e.g. `orders`);
11169    //   getPivotColumnList() — the NEW FOR/name column holding the literal
11170    //                          NAMES of the unpivoted columns (e.g. `employee`);
11171    //   getUnpivotInClause().getItems() — the narrowed SOURCE columns
11172    //                          (`emp1`, `emp2`, ...), each `getColumn()` a
11173    //                          base-table column with a Phase-1 sourceTable.
11174    // The value and FOR column names cannot name an existing input column
11175    // (PIVOT semantics, TPivotClause javadoc) — so they never collide with a
11176    // real passthrough source column.
11177    //
11178    // Sub-slice (c) admits the deterministic shape: a SINGLE value column over a
11179    // simple (non-column-group) IN-list. Output-column lineage mirrors the
11180    // authoritative dlineage UNPIVOT handler — each IN-list source column feeds
11181    // BOTH the value column and the FOR column. A multi-value-column or
11182    // column-group UNPIVOT stays deferred (PIVOT_UNPIVOT_NOT_SUPPORTED).
11183    // -----------------------------------------------------------------
11184
11185    /**
11186     * Slice 131 / 132 — validate the UNPIVOT shape, deferring (reject with
11187     * {@code PIVOT_UNPIVOT_NOT_SUPPORTED}, code kept reached) the shapes whose
11188     * value↔source mapping is not deterministic.
11189     *
11190     * <p>Slice 131 admitted ONLY the single-value-column / simple-IN shape.
11191     * Slice 132 (sub-slice (c2)) generalises this to the well-formed
11192     * multi-value-column / column-group shape (Oracle / Redshift) — e.g.
11193     * {@code UNPIVOT ((s1, s2) FOR yr IN ((a1, a2) AS 'Y1', (b1, b2) AS 'Y2'))}.
11194     * The admit rule is a single width invariant: every IN-list item must have
11195     * EXACTLY one source column per value column ({@code itemWidth == valueCount}),
11196     * so each value column maps positionally to one group position.
11197     *
11198     * <p>The malformed / mixed shapes that stay deferred (and keep the code
11199     * reached): no value column; a group whose width differs from the value
11200     * count (e.g. {@code ((s1, s2) FOR yr IN ((a1, a2, a3) ...))}); a single
11201     * column item under a multi-value UNPIVOT; or a column-group item under a
11202     * single-value UNPIVOT. The harder positional mapping for those is left for
11203     * a later sub-slice.
11204     */
11205    private static void rejectUnsupportedUnpivotShape(TPivotClause pc, TTable pivotTable) {
11206        TObjectNameList valueCols = pc.getValueColumnList();
11207        if (valueCols == null || valueCols.size() < 1) {
11208            throw new SemanticIRBuildException(
11209                    Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED,
11210                    "UNPIVOT without a value column is not supported yet", pivotTable));
11211        }
11212        int valueCount = valueCols.size();
11213        TUnpivotInClause in = pc.getUnpivotInClause();
11214        if (in != null && in.getItems() != null) {
11215            for (int i = 0; i < in.getItems().size(); i++) {
11216                TUnpivotInClauseItem item = in.getItems().getElement(i);
11217                if (item == null) {
11218                    continue;
11219                }
11220                int itemWidth;
11221                if (item.getColumn() != null) {
11222                    itemWidth = 1;
11223                } else if (item.getColumnList() != null) {
11224                    itemWidth = item.getColumnList().size();
11225                } else {
11226                    itemWidth = 0;
11227                }
11228                if (itemWidth != valueCount) {
11229                    throw new SemanticIRBuildException(
11230                            Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED,
11231                            "UNPIVOT IN-list item width does not match the value-column "
11232                                    + "count (each IN-list group must have exactly "
11233                                    + valueCount + " column(s) for the positional value "
11234                                    + "mapping); this shape is not supported yet", pivotTable));
11235                }
11236            }
11237        }
11238    }
11239
11240    /**
11241     * Slice 131 / 132 — ALL columns CONSUMED by an UNPIVOT: the IN-list SOURCE
11242     * columns being narrowed (NOT the new value / FOR columns), in document
11243     * order, deduped. Built DIRECTLY against {@code sourceAlias} via the shared
11244     * {@link #addPivotConsumedRef} filter (resolver2 NOT_FOUND-in-pivot quirk,
11245     * slice 129). This is also the source list for the FOR output column (the
11246     * FOR values are the literal labels of the narrowed columns, attributed to
11247     * every source column for slice-131 / dlineage parity).
11248     *
11249     * <p>Slice 132 extends slice 131 to walk column-group items: a multi-value
11250     * UNPIVOT IN-list item is a column GROUP ({@code getColumn() == null},
11251     * {@code getColumnList()} populated), so the group columns are flattened
11252     * into this list in document order.
11253     *
11254     * <p>Note: this does NOT reuse {@link #collectPivotConsumedRefs}, which reads
11255     * {@code getPivotColumnList()} — for an UNPIVOT that is the NEW FOR column,
11256     * not a source column, and would be wrongly captured as a consumed ref.
11257     */
11258    private static List<ColumnRef> collectUnpivotInListRefs(TPivotClause pc,
11259                                                            String sourceAlias) {
11260        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11261        TUnpivotInClause in = pc.getUnpivotInClause();
11262        if (in != null && in.getItems() != null) {
11263            for (int i = 0; i < in.getItems().size(); i++) {
11264                TUnpivotInClauseItem item = in.getItems().getElement(i);
11265                if (item == null) {
11266                    continue;
11267                }
11268                if (item.getColumn() != null) {
11269                    addPivotConsumedRef(refs, item.getColumn(), sourceAlias);
11270                } else if (item.getColumnList() != null) {
11271                    TObjectNameList group = item.getColumnList();
11272                    for (int k = 0; k < group.size(); k++) {
11273                        addPivotConsumedRef(refs, group.getObjectName(k), sourceAlias);
11274                    }
11275                }
11276            }
11277        }
11278        return new ArrayList<>(refs);
11279    }
11280
11281    /**
11282     * Slice 132 — the source column ref(s) for the value column at
11283     * {@code position} (positional mapping per roadmap §13): for each IN-list
11284     * item, the group column at {@code position} (or {@code getColumn()} for a
11285     * single-column item when {@code position == 0}). Document order, deduped,
11286     * built directly against {@code sourceAlias}.
11287     *
11288     * <p>This unifies the single-value and multi-value cases: for a single-value
11289     * UNPIVOT each item is a single column and {@code position} is always 0, so
11290     * this returns ALL IN-list source columns — byte-identical to the slice-131
11291     * value-column lineage. For a multi-value UNPIVOT, value column {@code s_k}
11292     * at position {@code k} draws only from group position {@code k}
11293     * ({@code s1 <- a1, b1}; {@code s2 <- a2, b2}), which is more precise than
11294     * the authoritative dlineage handler (it feeds every source column into
11295     * every output column). The shape gate
11296     * ({@link #rejectUnsupportedUnpivotShape}) guarantees every item width
11297     * equals the value count, so {@code position} is always in range; the bounds
11298     * check is defensive.
11299     */
11300    private static List<ColumnRef> collectUnpivotValueColumnRefs(TPivotClause pc,
11301                                                                 String sourceAlias,
11302                                                                 int position) {
11303        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11304        TUnpivotInClause in = pc.getUnpivotInClause();
11305        if (in != null && in.getItems() != null) {
11306            for (int i = 0; i < in.getItems().size(); i++) {
11307                TUnpivotInClauseItem item = in.getItems().getElement(i);
11308                if (item == null) {
11309                    continue;
11310                }
11311                TObjectName col = null;
11312                if (item.getColumn() != null) {
11313                    if (position == 0) {
11314                        col = item.getColumn();
11315                    }
11316                } else if (item.getColumnList() != null
11317                        && position < item.getColumnList().size()) {
11318                    col = item.getColumnList().getObjectName(position);
11319                }
11320                addPivotConsumedRef(refs, col, sourceAlias);
11321            }
11322        }
11323        return new ArrayList<>(refs);
11324    }
11325
11326    /**
11327     * Slice 132 — map each UNPIVOT value-column NAME to its position in the
11328     * value-column list, so a projected column can be matched to its positional
11329     * source group. First occurrence wins on a (degenerate) duplicate name.
11330     */
11331    private static Map<String, Integer> unpivotValueColumnPositions(TPivotClause pc) {
11332        Map<String, Integer> positions = new HashMap<>();
11333        TObjectNameList vcl = pc.getValueColumnList();
11334        if (vcl != null) {
11335            for (int i = 0; i < vcl.size(); i++) {
11336                TObjectName n = vcl.getObjectName(i);
11337                if (n == null) {
11338                    continue;
11339                }
11340                String name = n.getColumnNameOnly();
11341                if (name != null && !name.isEmpty() && !positions.containsKey(name)) {
11342                    positions.put(name, i);
11343                }
11344            }
11345        }
11346        return positions;
11347    }
11348
11349    /** Slice 131 — the UNPIVOT FOR/name-column name, or null. */
11350    private static String unpivotForColumnName(TPivotClause pc) {
11351        TObjectNameList pcl = pc.getPivotColumnList();
11352        if (pcl != null && pcl.size() >= 1 && pcl.getObjectName(0) != null) {
11353            return pcl.getObjectName(0).getColumnNameOnly();
11354        }
11355        if (pc.getPivotColumn() != null) {
11356            return pc.getPivotColumn().getColumnNameOnly();
11357        }
11358        return null;
11359    }
11360
11361    /**
11362     * Slice 131 — build output columns for an UNPIVOT SELECT (the deterministic
11363     * single-value / simple-IN shape; multi-value / column-group already
11364     * deferred by {@link #rejectUnsupportedUnpivotShape}). Each projected result
11365     * column is matched by its underlying name ({@link TResultColumn#getColumnNameOnly()},
11366     * NOT the output alias — mirrors slice 130):
11367     * <ul>
11368     *   <li><b>value column</b> — underlying name == a value-column name. Holds
11369     *       the unpivoted values, so {@code derived=true}, {@code aggregate=false}.
11370     *       Sources are POSITIONAL (slice 132): value column at position {@code k}
11371     *       draws from group position {@code k} across all IN-list items
11372     *       ({@code s1 <- a1, b1}; {@code s2 <- a2, b2}). For a single-value
11373     *       UNPIVOT this collapses to ALL IN-list source columns (slice 131).</li>
11374     *   <li><b>FOR/name column</b> — underlying name == the FOR-column name. Holds
11375     *       the literal NAMES / labels of the unpivoted columns; attributed to ALL
11376     *       IN-list source columns (slice-131 / dlineage parity — both halves of a
11377     *       single-value UNPIVOT trace to every narrowed source column).
11378     *       {@code derived=true}, {@code aggregate=false}.</li>
11379     *   <li><b>passthrough column</b> — any other {@code simple_object_name_t}
11380     *       projection. Direct reference to the source column:
11381     *       {@code derived=false}, {@code aggregate=false}, single source
11382     *       {@code sourceAlias.colName} (underlying name, alias respected in the
11383     *       output NAME).</li>
11384     *   <li><b>expression projection</b> — kept as a NAME-only skeleton
11385     *       ({@link #pivotSkeletonOutput}); no single source column.</li>
11386     * </ul>
11387     * Value / FOR matching is exact (mirrors slice 130). No name-collision risk:
11388     * PIVOT/UNPIVOT semantics forbid the value / FOR column from naming an
11389     * existing input column, so a passthrough source column can never coincide
11390     * with the value / FOR name. All refs are built DIRECTLY against
11391     * {@code sourceAlias} (single base-table source; resolver2 NOT_FOUND quirk).
11392     */
11393    private static List<OutputColumn> buildUnpivotOutputColumns(TSelectSqlStatement select,
11394                                                                TTable pivotTable,
11395                                                                TPivotClause pc,
11396                                                                String sourceAlias,
11397                                                                TTable sourceTable,
11398                                                                NameBindingProvider provider) {
11399        TResultColumnList rcl = select.getResultColumnList();
11400        if (rcl == null || rcl.size() == 0) {
11401            throw new SemanticIRBuildException(
11402                    Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS,
11403                    "SELECT has no projected columns", select));
11404        }
11405
11406        // Slice 134 / 135 (sub-slice (b2-unpivot)) — a SOLE bare `SELECT *` over a
11407        // well-formed UNPIVOT (single-value, slice 131; or multi-value-column /
11408        // column-group, slice 132 — both already validated by the
11409        // rejectUnsupportedUnpivotShape width invariant run earlier in
11410        // buildPivotSelect) is expanded from the source table's catalog column list
11411        // into the synthesised output schema (passthrough columns then the FOR/name
11412        // column then the value column(s)). Slice 135 lifted the slice-134
11413        // valueCount==1 gate to valueCount>=1 so the multi-value-column case is
11414        // admitted too.
11415        //
11416        // Slice 152 — the SOLE star is now admitted whether it is BARE (`*`) or
11417        // QUALIFIED (`u.*` / `src.*`), mirroring slice 151 on the PIVOT path. An
11418        // admitted UNPIVOT SELECT has exactly ONE relation in scope — the unpivot
11419        // output (isPivotSelect requires a FROM-UNPIVOT with no JOIN) — so a
11420        // qualified star can only mean that one relation and is therefore
11421        // equivalent to a bare `*`; expandUnpivotBareStar synthesises the identical
11422        // schema (it never reads the star's qualifier, only the source columns and
11423        // the unpivot clause). The isBareStar gate is dropped here. A `*` mixed
11424        // with explicit columns (rcl.size() > 1) still defers via the per-column
11425        // reject below (PIVOT_STAR_NOT_SUPPORTED kept reached).
11426        TObjectNameList valueCols = pc.getValueColumnList();
11427        if (rcl.size() == 1 && valueCols != null && valueCols.size() >= 1) {
11428            TResultColumn only = rcl.getResultColumn(0);
11429            if ("*".equals(only.getColumnNameOnly())) {
11430                // Slice 137 lifted the slice-136 subquery-source deferral. A bare
11431                // SELECT * over a FROM-subquery UNPIVOT now expands the same way as
11432                // a base-table UNPIVOT: expandUnpivotBareStar reads the source's
11433                // published columns via lookupRelationColumnNames, which the
11434                // slice-137 addRelationToInScopeMap unwrap registers under the
11435                // subquery alias. No catalog is required for a subquery source —
11436                // the subquery's explicit projection IS the published schema.
11437                return expandUnpivotBareStar(pc, sourceTable, sourceAlias, provider, only);
11438            }
11439        }
11440
11441        Map<String, Integer> valuePositions = unpivotValueColumnPositions(pc);
11442        String forName = unpivotForColumnName(pc);
11443        List<ColumnRef> inListRefs = collectUnpivotInListRefs(pc, sourceAlias);
11444
11445        List<OutputColumn> outs = new ArrayList<>();
11446        for (int i = 0; i < rcl.size(); i++) {
11447            TResultColumn rc = rcl.getResultColumn(i);
11448            if ("*".equals(rc.getColumnNameOnly())) {
11449                throw new SemanticIRBuildException(
11450                        Diagnostic.error(DiagnosticCode.PIVOT_STAR_NOT_SUPPORTED,
11451                        "SELECT * over an UNPIVOT is not supported yet "
11452                                + "(the synthesised output schema is deferred); "
11453                                + "project explicit columns", rc));
11454            }
11455            String outName = effectiveOutputName(rc);
11456            String colOnly = rc.getColumnNameOnly();
11457            Integer valuePos = (colOnly == null || colOnly.isEmpty())
11458                    ? null : valuePositions.get(colOnly);
11459            if (valuePos != null) {
11460                // Value column (the unpivoted values): POSITIONAL sources — the
11461                // group column at this value column's position across every
11462                // IN-list item.
11463                outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ false,
11464                        collectUnpivotValueColumnRefs(pc, sourceAlias, valuePos),
11465                        /*windowSpec=*/ null));
11466            } else if (colOnly != null && !colOnly.isEmpty()
11467                    && colOnly.equals(forName)) {
11468                // FOR/name column (the source column NAMES / labels): traces to
11469                // every narrowed IN-list source column (dlineage parity).
11470                outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ false,
11471                        inListRefs, /*windowSpec=*/ null));
11472            } else if (colOnly != null && !colOnly.isEmpty()
11473                    && rc.getExpr() != null
11474                    && rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t) {
11475                // Passthrough column: direct reference to the source column.
11476                outs.add(new OutputColumn(outName, /*derived=*/ false, /*aggregate=*/ false,
11477                        Collections.singletonList(new ColumnRef(sourceAlias, colOnly)),
11478                        /*windowSpec=*/ null));
11479            } else {
11480                // Expression projection (or no underlying column) — keep skeleton.
11481                outs.add(pivotSkeletonOutput(outName));
11482            }
11483        }
11484        return outs;
11485    }
11486
11487    /**
11488     * Slice 134 / 135 (sub-slice (b2-unpivot)) — synthesise the full output schema
11489     * for a bare {@code SELECT *} over a well-formed UNPIVOT (single-value, slice
11490     * 131; or multi-value-column / column-group, slice 132 — slice 135 lifted the
11491     * value-count gate), using the source table's catalog column list:
11492     * <ol>
11493     *   <li><b>passthrough columns</b> — every catalog column that is NOT a
11494     *       narrowed IN-list SOURCE column ({@link #collectUnpivotInListRefs}), in
11495     *       CATALOG declaration order. Each is a direct source reference
11496     *       ({@code derived=false}, {@code aggregate=false}, single source
11497     *       {@code sourceAlias.col}).</li>
11498     *   <li><b>FOR/name column</b> — {@link #unpivotForColumnName}. Holds the
11499     *       literal NAMES of the unpivoted columns; traces to ALL IN-list source
11500     *       columns ({@code derived=true}, {@code aggregate=false}).</li>
11501     *   <li><b>value column(s)</b> — every {@code getValueColumnList()} entry, in
11502     *       declaration order. Holds the unpivoted values; the value column at
11503     *       position {@code k} draws POSITIONALLY from group position {@code k}
11504     *       across every IN-list item ({@link #collectUnpivotValueColumnRefs}, the
11505     *       slice-132 mechanism): {@code s1 <- a1, b1}; {@code s2 <- a2, b2}. For a
11506     *       single-value UNPIVOT (slice 134) the sole position 0 collapses to ALL
11507     *       IN-list source columns ({@code derived=true}, {@code aggregate=false}).</li>
11508     * </ol>
11509     * This is the Oracle / Redshift {@code SELECT *} UNPIVOT order: the passthrough
11510     * columns first, then the FOR/name column, then the value column(s). The
11511     * per-column lineage is byte-identical to the slice-131 / 132 explicit-projection
11512     * path ({@link #buildUnpivotOutputColumns}); only the names and order are
11513     * synthesised here from the catalog + clause.
11514     *
11515     * <p>The passthrough subtraction folds names with {@code toLowerCase(Locale.ROOT)}
11516     * — the same identifier canonicalisation the other semantic-IR catalog matchers
11517     * use ({@link #expandPivotBareStar}, {@link #lookupRelationColumnNames}). The
11518     * FOR/value column names are NEW (UNPIVOT semantics forbid them from colliding
11519     * with a source column), so they are never in the catalog and need no subtraction.
11520     *
11521     * <p>Throws {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED} (reused from
11522     * slice 133) when no catalog column list is available, with an
11523     * UNPIVOT-discriminated message (slice-80 message-text-discrimination contract).
11524     */
11525    private static List<OutputColumn> expandUnpivotBareStar(TPivotClause pc,
11526                                                            TTable sourceTable,
11527                                                            String sourceAlias,
11528                                                            NameBindingProvider provider,
11529                                                            TResultColumn star) {
11530        List<String> cols = lookupRelationColumnNames(sourceTable, provider);
11531        if (cols == null || cols.isEmpty()) {
11532            throw new SemanticIRBuildException(
11533                    Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED,
11534                    "SELECT * over an UNPIVOT requires catalog metadata for source '"
11535                            + sourceAlias + "' to expand the implicit passthrough columns; "
11536                            + "supply a Catalog via "
11537                            + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star));
11538        }
11539        // Columns NARROWED by the UNPIVOT (the IN-list SOURCE columns); these are
11540        // removed from the passthrough set. Folded to lower case (Locale.ROOT) to
11541        // match the catalog identifier canonicalisation used by the other
11542        // semantic-IR catalog matchers. addPivotConsumedRef already filters out
11543        // null / empty names, so getColumnName() is non-null; the guard is defensive.
11544        List<ColumnRef> inListRefs = collectUnpivotInListRefs(pc, sourceAlias);
11545        Set<String> consumedLC = new HashSet<>();
11546        for (ColumnRef r : inListRefs) {
11547            String cn = (r == null) ? null : r.getColumnName();
11548            if (cn != null && !cn.isEmpty()) {
11549                consumedLC.add(cn.toLowerCase(Locale.ROOT));
11550            }
11551        }
11552        List<OutputColumn> outs = new ArrayList<>();
11553        // 1. passthrough columns (catalog order, minus the narrowed IN-list sources).
11554        for (String c : cols) {
11555            if (c == null || c.isEmpty()) {
11556                continue;
11557            }
11558            if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) {
11559                continue;
11560            }
11561            outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false,
11562                    Collections.singletonList(new ColumnRef(sourceAlias, c)),
11563                    /*windowSpec=*/ null));
11564        }
11565        // 2. FOR/name column — the literal labels of the narrowed columns; traces
11566        //    to every IN-list source column (slice-131 / dlineage parity).
11567        String forName = unpivotForColumnName(pc);
11568        if (forName != null && !forName.isEmpty()) {
11569            outs.add(new OutputColumn(forName, /*derived=*/ true, /*aggregate=*/ false,
11570                    inListRefs, /*windowSpec=*/ null));
11571        }
11572        // 3. value column(s) — the unpivoted values, in getValueColumnList()
11573        //    declaration order. Slice 135: value column at position k draws from
11574        //    group position k across every IN-list item (collectUnpivotValueColumnRefs,
11575        //    the slice-132 positional mechanism): s1 <- a1, b1; s2 <- a2, b2. For a
11576        //    single-value UNPIVOT (slice 134) the sole position 0 collapses to all
11577        //    IN-list source columns, byte-identical to the prior behaviour.
11578        TObjectNameList valueCols = pc.getValueColumnList();
11579        if (valueCols != null) {
11580            for (int k = 0; k < valueCols.size(); k++) {
11581                TObjectName valueCol = valueCols.getObjectName(k);
11582                String valueName = (valueCol != null) ? valueCol.getColumnNameOnly() : null;
11583                if (valueName != null && !valueName.isEmpty()) {
11584                    outs.add(new OutputColumn(valueName, /*derived=*/ true, /*aggregate=*/ false,
11585                            collectUnpivotValueColumnRefs(pc, sourceAlias, k), /*windowSpec=*/ null));
11586                }
11587            }
11588        }
11589        return outs;
11590    }
11591
11592    /**
11593     * Slices 70 and 71: build per-statement row-limit metadata from
11594     * {@code TLimitClause}, {@code TTopClause}, {@code TOffsetClause},
11595     * or {@code TFetchFirstClause}. Returns {@code null} when no
11596     * row-limit clause is present. All admit / reject decisions for
11597     * single-SELECT row-limit clauses live here; the set-op outer
11598     * row-limit path is rejected separately by
11599     * {@link #rejectSetOpRowLimit} (slice 72 lifts).
11600     *
11601     * <h4>Admitted shapes</h4>
11602     * <ul>
11603     *   <li>{@link RowLimitKind#LIMIT} — {@code TLimitClause} with
11604     *       non-null {@code getRow_count()}. Offset is populated when
11605     *       {@code TLimitClause.getOffset() != null} (PG / MySQL /
11606     *       SQLite / BigQuery / Snowflake / Redshift inline
11607     *       {@code LIMIT N OFFSET M}, MySQL old-style {@code LIMIT M, N},
11608     *       Informix {@code SKIP m LIMIT n}).</li>
11609     *   <li>{@link RowLimitKind#FETCH_FIRST} — {@code TLimitClause} with
11610     *       non-null {@code getSelectFetchFirstValue()} (PG
11611     *       {@code FETCH FIRST}, Informix {@code FIRST n}). Offset is
11612     *       populated when present (PG
11613     *       {@code OFFSET m FETCH FIRST n}, Informix
11614     *       {@code SKIP m FIRST n}). Also fires for
11615     *       {@code TFetchFirstClause} with non-null
11616     *       {@code getFetchValue()} (Oracle / SQL Server
11617     *       {@code FETCH FIRST/NEXT N ROWS ONLY}) when no
11618     *       {@code TOffsetClause} is present.</li>
11619     *   <li>{@link RowLimitKind#TOP} — {@code TTopClause} with non-null
11620     *       {@code getExpr()} and neither {@code isPercent()} nor
11621     *       {@code isWithties()} set. SQL Server {@code SELECT TOP N}.</li>
11622     *   <li>{@link RowLimitKind#OFFSET_FETCH} — Oracle / SQL Server
11623     *       {@code OFFSET m ROWS [FETCH NEXT n ROWS ONLY]} routed via
11624     *       the dedicated {@code TOffsetClause} + {@code TFetchFirstClause}
11625     *       pair, and PG offset-only {@code OFFSET m} routed via
11626     *       {@code TLimitClause.getOffset()} when {@code row_count} and
11627     *       {@code selectFetchFirstValue} are both null.
11628     *       {@link RowLimit#getCount()} may be {@code null} for
11629     *       offset-only forms.</li>
11630     * </ul>
11631     *
11632     * <h4>Rejects</h4>
11633     * <ul>
11634     *   <li>{@link DiagnosticCode#ROW_LIMIT_TOP_PERCENT_NOT_SUPPORTED}
11635     *       — {@code TOP N PERCENT}. The sampling semantics differ from
11636     *       fixed-row {@code LIMIT} enough to warrant a dedicated slice.</li>
11637     *   <li>{@link DiagnosticCode#ROW_LIMIT_TOP_WITH_TIES_NOT_SUPPORTED}
11638     *       — {@code TOP N WITH TIES}. Requires modeling the ORDER BY
11639     *       tie-handling interaction; deferred.</li>
11640     *   <li>{@link DiagnosticCode#ROW_LIMIT_HIVE_LIMIT_GRAMMAR_QUIRK} —
11641     *       Hive single-argument {@code LIMIT N} parser routes the
11642     *       count through {@code TLimitClause.getOffset()} with
11643     *       {@code row_count == null}, which is indistinguishable at
11644     *       the AST level from PG offset-only {@code OFFSET m}. Pinning
11645     *       this with a vendor-specific guard prevents emitting
11646     *       semantically-wrong {@code OFFSET_FETCH} metadata for what
11647     *       the SQL author wrote as a LIMIT. A future grammar fix
11648     *       should route the count through {@code getRow_count()}; this
11649     *       guard can be removed then.</li>
11650     *   <li>{@link DiagnosticCode#ROW_LIMIT_LIMIT_NOT_SUPPORTED} —
11651     *       Vertica TIMESERIES windowing on {@code TLimitClause}
11652     *       ({@code getWindowDef() != null}). Defensive; not modeled.</li>
11653     *   <li>{@link DiagnosticCode#ROW_LIMIT_COUNT_UNRESOLVED} — the
11654     *       parser constructed a row-limit clause node but did not
11655     *       populate any count slot:
11656     *       <ul>
11657     *         <li>{@code TLimitClause} with {@code row_count},
11658     *             {@code selectFetchFirstValue}, and {@code offset} all
11659     *             null (defensive; not observed in probe runs).</li>
11660     *         <li>{@code TFetchFirstClause} with null fetchValue —
11661     *             ANSI / DB2 grammar incompleteness (the parser
11662     *             constructs the clause node but does not populate the
11663     *             count). Future grammar fix can lift this.</li>
11664     *         <li>{@code TTopClause} with null expression (defensive).</li>
11665     *       </ul></li>
11666     * </ul>
11667     */
11668    private static RowLimit buildRowLimit(TSelectSqlStatement select) {
11669        TLimitClause limit = select.getLimitClause();
11670        if (limit != null) {
11671            // Vertica TIMESERIES window on TLimitClause — defensive; rare.
11672            // Pre-empts the row_count / fff branches because the windowed
11673            // form is its own semantic surface.
11674            if (limit.getWindowDef() != null) {
11675                throw new SemanticIRBuildException(
11676                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_LIMIT_NOT_SUPPORTED,
11677                        "row-limit clause LIMIT with Vertica TIMESERIES window "
11678                                + "is not supported yet", limit));
11679            }
11680
11681            TExpression rc = limit.getRow_count();
11682            TExpression off = limit.getOffset();
11683            TExpression fff = limit.getSelectFetchFirstValue();
11684
11685            // Hive single-argument LIMIT parser quirk: the count ends
11686            // up on offset with row_count=null. Vendor-conditional
11687            // because the same AST shape is legitimate PG offset-only.
11688            if (select.dbvendor == EDbVendor.dbvhive
11689                    && rc == null && off != null && fff == null) {
11690                throw new SemanticIRBuildException(
11691                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_HIVE_LIMIT_GRAMMAR_QUIRK,
11692                        "Hive single-argument LIMIT N is currently mis-routed "
11693                                + "by the parser (count appears on TLimitClause.getOffset() "
11694                                + "with row_count=null); fix the Hive grammar to route "
11695                                + "the count through getRow_count() to lift this guard", limit));
11696            }
11697
11698            if (rc != null) {
11699                // LIMIT N with optional OFFSET M (PG/MySQL/SQLite/
11700                // BigQuery/Snowflake/Redshift inline LIMIT-OFFSET,
11701                // MySQL old-style LIMIT M,N, Informix SKIP m LIMIT n).
11702                return new RowLimit(RowLimitKind.LIMIT,
11703                        rc.toString(),
11704                        off != null ? off.toString() : null);
11705            }
11706            if (fff != null) {
11707                // FETCH FIRST via the PG/Informix routing through
11708                // TLimitClause, with optional OFFSET (PG
11709                // OFFSET m FETCH FIRST n; Informix SKIP m FIRST n).
11710                return new RowLimit(RowLimitKind.FETCH_FIRST,
11711                        fff.toString(),
11712                        off != null ? off.toString() : null);
11713            }
11714            if (off != null) {
11715                // Offset-only via TLimitClause (PG OFFSET m [ROWS]).
11716                return new RowLimit(RowLimitKind.OFFSET_FETCH,
11717                        /*count=*/ null,
11718                        off.toString());
11719            }
11720            // Defensive: TLimitClause present with all four slots null.
11721            throw new SemanticIRBuildException(
11722                    Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
11723                    "row-limit clause LIMIT is present but no count, offset, "
11724                            + "or FETCH FIRST value is populated on the parser AST", limit));
11725        }
11726
11727        TTopClause top = select.getTopClause();
11728        if (top != null) {
11729            if (top.isPercent()) {
11730                throw new SemanticIRBuildException(
11731                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_TOP_PERCENT_NOT_SUPPORTED,
11732                        "row-limit clause TOP N PERCENT is not supported yet; "
11733                                + "sampling semantics warrant a dedicated slice", top));
11734            }
11735            if (top.isWithties()) {
11736                throw new SemanticIRBuildException(
11737                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_TOP_WITH_TIES_NOT_SUPPORTED,
11738                        "row-limit clause TOP N WITH TIES is not supported yet; "
11739                                + "tie-handling semantics warrant a dedicated slice", top));
11740            }
11741            TExpression e = top.getExpr();
11742            if (e == null) {
11743                throw new SemanticIRBuildException(
11744                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
11745                        "row-limit clause TOP is present but the count expression "
11746                                + "is not populated on the parser AST", top));
11747            }
11748            return new RowLimit(RowLimitKind.TOP, e.toString(), /*offset=*/ null);
11749        }
11750
11751        TOffsetClause offClause = select.getOffsetClause();
11752        TFetchFirstClause fetch = select.getFetchFirstClause();
11753        if (offClause != null) {
11754            // Oracle / SQL Server OFFSET m ROWS [FETCH NEXT n ROWS ONLY].
11755            // The optional FETCH NEXT counterpart populates
11756            // TFetchFirstClause when present.
11757            String offsetText = offClause.getSelectOffsetValue() != null
11758                    ? offClause.getSelectOffsetValue().toString()
11759                    : null;
11760            if (offsetText == null) {
11761                throw new SemanticIRBuildException(
11762                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
11763                        "row-limit clause OFFSET is present but the offset value "
11764                                + "is not populated on the parser AST", offClause));
11765            }
11766            String countText = null;
11767            if (fetch != null && fetch.getFetchValue() != null) {
11768                countText = fetch.getFetchValue().toString();
11769            }
11770            return new RowLimit(RowLimitKind.OFFSET_FETCH, countText, offsetText);
11771        }
11772        if (fetch != null) {
11773            if (fetch.getFetchValue() != null) {
11774                // Oracle / SQL Server FETCH FIRST/NEXT N ROWS ONLY
11775                // without OFFSET (TOffsetClause was null above).
11776                return new RowLimit(RowLimitKind.FETCH_FIRST,
11777                        fetch.getFetchValue().toString(), /*offset=*/ null);
11778            }
11779            // ANSI / DB2: TFetchFirstClause is non-null but fetchValue
11780            // is null because the grammar does not pass the count into
11781            // the node initializer. Reject so the gap is visible.
11782            throw new SemanticIRBuildException(
11783                    Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
11784                    "row-limit clause FETCH FIRST is present but the count "
11785                            + "expression is not populated on the parser AST "
11786                            + "(ANSI / DB2 grammar gap)", fetch));
11787        }
11788        return null;
11789    }
11790
11791    /**
11792     * Slice 72: build the OUTER set-op statement's row-limit. Same
11793     * decision tree as {@link #buildRowLimit} for SELECT-level routing
11794     * (PG/MySQL/SQLite/BigQuery/Snowflake/Redshift via
11795     * {@code TLimitClause}, plus Hive/Vertica/ANSI-DB2 defensives),
11796     * with an additional MSSQL-only fallback that reads the OFFSET /
11797     * FETCH FIRST clauses off the outer {@code TOrderBy} node.
11798     *
11799     * <p>Empirical AST shapes (probed against the current parser):
11800     * <ul>
11801     *   <li>PG / MySQL / SQLite / BigQuery / Snowflake / Redshift route
11802     *       set-op outer LIMIT / OFFSET / FETCH FIRST onto
11803     *       {@code setOp.getLimitClause()} — handled by the primary
11804     *       {@code buildRowLimit} path.</li>
11805     *   <li>MSSQL routes set-op outer {@code OFFSET m ROWS [FETCH NEXT
11806     *       n ROWS ONLY]} EXCLUSIVELY onto
11807     *       {@code setOp.getOrderbyClause().getOffsetClause()} /
11808     *       {@code .getFetchFirstClause()} — NOT duplicated onto the
11809     *       SELECT node (opposite of single-SELECT MSSQL where slice 71
11810     *       saw duplication onto both). The TOrderBy fallback below
11811     *       handles this.</li>
11812     *   <li>Oracle drops set-op outer OFFSET / FETCH from both SELECT
11813     *       and TOrderBy slots silently; nothing for slice 72 to
11814     *       emit. A future Oracle grammar fix can lift this.</li>
11815     * </ul>
11816     *
11817     * <p>The TOrderBy fallback is vendor-gated to MSSQL to avoid
11818     * over-admitting on unprobed dialects (per codex round-1 B1).
11819     * Kind mapping mirrors {@link #buildRowLimit}'s single-SELECT
11820     * decision tree:
11821     * <ul>
11822     *   <li>{@code TOffsetClause} + {@code TFetchFirstClause} both
11823     *       populated → {@code OFFSET_FETCH/count/offset}</li>
11824     *   <li>{@code TOffsetClause} only → {@code OFFSET_FETCH/null/offset}</li>
11825     *   <li>{@code TFetchFirstClause} only → {@code FETCH_FIRST/count/null}
11826     *       (unreachable via current MSSQL grammar which requires
11827     *       OFFSET before FETCH; retained as defensive routing-shape
11828     *       parity with single-SELECT)</li>
11829     *   <li>Defensive null-value rejects mirror single-SELECT
11830     *       {@link #buildRowLimit}: a present {@code TOffsetClause}
11831     *       with a null offset value throws
11832     *       {@code ROW_LIMIT_COUNT_UNRESOLVED}; a present bare
11833     *       {@code TFetchFirstClause} (no companion OFFSET) with a
11834     *       null fetch value throws the same code. When both clauses
11835     *       are present, only the offset slot must be populated; a
11836     *       null fetch value is silently treated as offset-only
11837     *       (matches the single-SELECT
11838     *       {@code TOffsetClause + TFetchFirstClause} branch in
11839     *       {@code buildRowLimit}).</li>
11840     * </ul>
11841     */
11842    private static RowLimit buildSetOpRowLimit(TSelectSqlStatement setOp) {
11843        // Primary path: SELECT-level routing. Covers PG/MySQL/SQLite/
11844        // BigQuery/Snowflake/Redshift via TLimitClause, plus inherited
11845        // Hive / Vertica / ANSI-DB2 defensives from buildRowLimit.
11846        RowLimit fromSelect = buildRowLimit(setOp);
11847        if (fromSelect != null) {
11848            return fromSelect;
11849        }
11850        // MSSQL-only TOrderBy fallback (codex B1 vendor gate).
11851        if (setOp.dbvendor != EDbVendor.dbvmssql) {
11852            return null;
11853        }
11854        TOrderBy orderBy = setOp.getOrderbyClause();
11855        if (orderBy == null) return null;
11856        TOffsetClause oc = orderBy.getOffsetClause();
11857        TFetchFirstClause fc = orderBy.getFetchFirstClause();
11858        if (oc == null && fc == null) return null;
11859
11860        String offsetText = (oc != null && oc.getSelectOffsetValue() != null)
11861                ? oc.getSelectOffsetValue().toString() : null;
11862        String countText = (fc != null && fc.getFetchValue() != null)
11863                ? fc.getFetchValue().toString() : null;
11864
11865        if (oc != null && fc != null) {
11866            if (offsetText == null) {
11867                // Mirrors single-SELECT buildRowLimit TOffsetClause path:
11868                // a present OFFSET clause must populate its value (the
11869                // FETCH NEXT counterpart is optional; null countText is
11870                // silently treated as offset-only).
11871                throw new SemanticIRBuildException(
11872                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
11873                        "MSSQL set-op outer OFFSET clause present on TOrderBy "
11874                                + "but offset value is not populated on the parser AST", orderBy));
11875            }
11876            return new RowLimit(RowLimitKind.OFFSET_FETCH, countText, offsetText);
11877        }
11878        if (oc != null) {
11879            if (offsetText == null) {
11880                throw new SemanticIRBuildException(
11881                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
11882                        "MSSQL set-op outer OFFSET clause present on TOrderBy "
11883                                + "but offset value is not populated on the parser AST", orderBy));
11884            }
11885            return new RowLimit(RowLimitKind.OFFSET_FETCH, /*count=*/ null, offsetText);
11886        }
11887        // fc only (oc == null). Defensive: not reachable via current
11888        // MSSQL grammar which requires OFFSET before FETCH NEXT.
11889        if (countText == null) {
11890            throw new SemanticIRBuildException(
11891                    Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
11892                    "MSSQL set-op outer FETCH FIRST clause present on TOrderBy "
11893                            + "but fetch value is not populated on the parser AST", orderBy));
11894        }
11895        return new RowLimit(RowLimitKind.FETCH_FIRST, countText, /*offset=*/ null);
11896    }
11897
11898    /**
11899     * Resolve a {@link TSelectSqlStatement}'s row-filter clause to the IR's
11900     * {@code distinct} flag. Mapping:
11901     *
11902     * <ul>
11903     *   <li>no clause / {@code urfNone} / {@code urfAll}: {@code false}</li>
11904     *   <li>{@code urfDistinct}: {@code true}</li>
11905     *   <li>{@code urfUnique}: {@code true} — Oracle treats
11906     *       {@code SELECT UNIQUE} as a deprecated synonym for
11907     *       {@code SELECT DISTINCT}; both produce the same row-set.</li>
11908     *   <li>{@code urfDistinctOn}: admits (slice 73). Returns
11909     *       {@code true} for the boolean flag; the
11910     *       {@code DISTINCT ON (cols)} partition keys are collected
11911     *       separately by
11912     *       {@link #buildDistinctOnColumnRefs(TSelectSqlStatement,
11913     *       NameBindingProvider)} so the column-ref collection runs
11914     *       AFTER {@code UsingScope} is installed (matching the timing
11915     *       of {@link #buildGroupByColumnRefs} and friends).</li>
11916     *   <li>{@code urfDistinctRow}, {@code urfNormalize}: rejected
11917     *       (vendor-specific; not yet a documented IR shape).</li>
11918     *   <li>null filter on a non-null {@code TSelectDistinct}, or a
11919     *       new enum value the switch hasn't seen yet: rejected, so a
11920     *       future {@code EUniqueRowFilterType} addition fails loudly
11921     *       rather than silently classifying as {@code distinct=false}.</li>
11922     * </ul>
11923     */
11924    private static boolean resolveDistinctFlag(TSelectSqlStatement select) {
11925        TSelectDistinct sd = select.getSelectDistinct();
11926        if (sd == null) return false;
11927        EUniqueRowFilterType urf = sd.getUniqueRowFilter();
11928        if (urf == null) {
11929            throw new SemanticIRBuildException(
11930                    Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_NULL,
11931                    "SELECT row-filter is null; expected one of "
11932                            + "{none, all, distinct, unique}", select));
11933        }
11934        switch (urf) {
11935            case urfNone:
11936            case urfAll:
11937                return false;
11938            case urfDistinct:
11939            case urfUnique:        // Oracle deprecated synonym for DISTINCT
11940            case urfDistinctOn:    // slice 73: refs collected separately
11941                return true;
11942            case urfDistinctRow:
11943            case urfNormalize:
11944                throw new SemanticIRBuildException(
11945                        Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_NOT_SUPPORTED,
11946                        "SELECT row-filter " + urf + " is not supported yet", select));
11947            default:
11948                throw new SemanticIRBuildException(
11949                        Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_UNKNOWN,
11950                        "unknown SELECT row-filter " + urf, select));
11951        }
11952    }
11953
11954    /**
11955     * Slice 73: collect physical column references from a
11956     * {@code SELECT DISTINCT ON (cols)} expression list. Returns the
11957     * empty list for plain {@code DISTINCT}, {@code UNIQUE},
11958     * {@code ALL}, and the no-filter case. Only PostgreSQL and
11959     * Greenplum expose {@code urfDistinctOn} with a populated
11960     * {@link TSelectDistinct#getExpressionList()}; Oracle, MySQL, and
11961     * Redshift silently drop the {@code ON (...)} clause and parse the
11962     * SELECT as plain {@code DISTINCT}, so this helper returns
11963     * {@code []} for those vendors regardless of the surface SQL.
11964     *
11965     * <p>Mirrors {@link #buildGroupByColumnRefs}: subqueries and window
11966     * functions in the expression list are rejected BEFORE
11967     * {@link #collectColumnRefs} descends, so inner-scope refs cannot
11968     * leak into {@code distinctOnColumnRefs}. Compound expressions
11969     * ({@code a + b}, {@code CASE WHEN ...}) and aggregate arguments
11970     * ({@code COUNT(x)}) are descended into so the underlying column
11971     * refs are captured.
11972     */
11973    private static List<ColumnRef> buildDistinctOnColumnRefs(
11974            TSelectSqlStatement select, NameBindingProvider provider) {
11975        TSelectDistinct sd = select.getSelectDistinct();
11976        if (sd == null
11977                || sd.getUniqueRowFilter() != EUniqueRowFilterType.urfDistinctOn) {
11978            return new ArrayList<>();
11979        }
11980        TExpressionList el = sd.getExpressionList();
11981        if (el == null || el.size() == 0) {
11982            // PG grammar requires at least one expression after
11983            // DISTINCT ON (; this branch is defensive — surface a
11984            // clear diagnostic rather than silently emit [].
11985            throw new SemanticIRBuildException(
11986                    Diagnostic.error(DiagnosticCode.DISTINCT_ON_EMPTY_COLUMN_LIST,
11987                    "DISTINCT ON requires at least one expression but the "
11988                            + "AST exposes an empty list", sd));
11989        }
11990        // Iterate items explicitly so each per-expression reject
11991        // diagnostic points at the offending expression. Equivalent
11992        // to running containsAnySubquery / rejectWindowFunctionInScope
11993        // / collectColumnRefs on the whole list (TExpressionList
11994        // inherits TParseTreeNodeList.acceptChildren which already
11995        // iterates element children), but the loop body gives
11996        // clearer rejection sites and lets us dedup refs across
11997        // expressions in declaration order.
11998        List<ColumnRef> refs = new ArrayList<>();
11999        for (int i = 0; i < el.size(); i++) {
12000            TExpression expr = el.getExpression(i);
12001            if (containsAnySubqueryExpression(expr)) {
12002                throw new SemanticIRBuildException(
12003                        Diagnostic.error(DiagnosticCode.DISTINCT_ON_HAS_SUBQUERY_NOT_SUPPORTED,
12004                        "DISTINCT ON expression list contains a subquery; "
12005                                + "subqueries in DISTINCT ON are not supported yet", sd));
12006            }
12007            rejectWindowFunctionInScope(expr, "DISTINCT ON expression list");
12008            for (ColumnRef ref : collectColumnRefs(expr, provider)) {
12009                if (!refs.contains(ref)) refs.add(ref);
12010            }
12011        }
12012        return refs;
12013    }
12014
12015    private static boolean hasNoFromSource(TSelectSqlStatement select) {
12016        return select.joins == null || select.joins.size() == 0;
12017    }
12018
12019    private static boolean allResultColumnsAreConstantExpressions(TSelectSqlStatement select) {
12020        TResultColumnList rcl = select.getResultColumnList();
12021        if (rcl == null || rcl.size() == 0) return false;
12022        for (int i = 0; i < rcl.size(); i++) {
12023            TResultColumn rc = rcl.getResultColumn(i);
12024            if (rc == null || rc.getExpr() == null || !isConstantExpression(rc.getExpr())) {
12025                return false;
12026            }
12027        }
12028        return true;
12029    }
12030
12031    private static List<ColumnRef> buildGroupByColumnRefs(TSelectSqlStatement select, NameBindingProvider provider) {
12032        TGroupBy groupBy = select.getGroupByClause();
12033        if (groupBy == null || groupBy.getItems() == null || groupBy.getItems().size() == 0) {
12034            return new ArrayList<>();
12035        }
12036        TGroupByItemList items = groupBy.getItems();
12037        // Slice 127: flatten plain, ROLLUP, CUBE, and GROUPING SETS items
12038        // into the set of grouping expressions before guarding/collecting.
12039        // A plain visitor over {@code items} silently drops every column
12040        // inside a compound grouping element, because the AST visitor links
12041        // are broken there: {@link TGroupByItem#acceptChildren} descends
12042        // ONLY into its plain {@code expr} (not {@code rollupCube},
12043        // {@code groupingSet}, or {@code exprList}), and
12044        // {@link TRollupCube#acceptChildren} /
12045        // {@link TGroupingExpressionItem#acceptChildren} do not descend at
12046        // all. Pre-slice-127, `GROUP BY ROLLUP(a, c)` produced empty
12047        // groupByColumnRefs and a subquery/window function buried inside a
12048        // compound element bypassed the slice-61/slice-13 guards. We do NOT
12049        // patch the shared AST {@code acceptChildren} methods (that would
12050        // ripple into dlineage / resolver); the fix is localized here.
12051        List<TExpression> groupingExprs = flattenGroupByExpressions(items);
12052        // Slice 61: reject subqueries in GROUP BY before column collection
12053        // descends into them. Pre-slice-61, queries such as `SELECT 1
12054        // FROM employees GROUP BY (SELECT id FROM departments)` reached
12055        // the constant-only projection guard and failed there; with the
12056        // slice-61 lift the projection now builds and the GROUP BY
12057        // collection would leak `departments.id` into groupByColumnRefs
12058        // even though `departments` is not in {@code relations}, breaking
12059        // the IR invariant that column refs reference an in-scope
12060        // relation. Mirrors the WHERE / HAVING / ORDER BY subquery guards.
12061        // Slice 127 runs the guard over the flattened expressions so a
12062        // subquery inside ROLLUP/CUBE/GROUPING SETS is also rejected; the
12063        // anchor stays the {@code groupBy} node for contract stability.
12064        for (TExpression e : groupingExprs) {
12065            if (containsAnySubquery(e)) {
12066                throw new SemanticIRBuildException(
12067                        Diagnostic.error(DiagnosticCode.GROUP_BY_HAS_SUBQUERY_NOT_SUPPORTED,
12068                        "GROUP BY clause contains a subquery; subqueries in "
12069                                + "GROUP BY are not supported yet", groupBy));
12070            }
12071        }
12072        // Slice 13: reject window functions in GROUP BY before collection
12073        // (slice 127 runs the guard over the flattened expressions).
12074        for (TExpression e : groupingExprs) {
12075            rejectWindowFunctionInScope(e, "GROUP BY clause");
12076        }
12077        // Visitor-based collection ensures column refs in any nested
12078        // expression (e.g. GROUP BY date_trunc('day', t)) are captured.
12079        //
12080        // Slice 127: collect with the Phase-1 source-table fallback enabled.
12081        // Resolver2's Phase-2 GROUP BY scope builder (processGroupBy) only
12082        // resolves columns inside a plain {@code item.getExpr()}; columns
12083        // inside ROLLUP / CUBE never reach processColumnReferences, so they
12084        // carry {@code resolution == null}. The slice-93 fallback promotes
12085        // such refs to EXACT_MATCH when Phase 1's linkColumnToTable has set
12086        // a source table and the SQL-written qualifier (if any) is
12087        // consistent with it — exactly the Hive multi-insert situation the
12088        // facet was built for. Plain grouping columns ARE resolved by
12089        // Resolver2 ({@code resolution != null}), so the fallback is inert
12090        // for them and the plain-GROUP-BY contract is unchanged.
12091        return collectColumnRefsFromExpressions(
12092                groupingExprs, provider.withSourceTableFallback(true));
12093    }
12094
12095    /**
12096     * Flatten a {@link TGroupByItemList} into the ordered list of leaf
12097     * grouping {@link TExpression}s, descending into ROLLUP / CUBE /
12098     * GROUPING SETS compound elements that the AST visitor does not reach
12099     * (slice 127). Leaves are emitted in left-to-right document order.
12100     *
12101     * <p>Each {@link TGroupByItem} carries exactly one (mutually exclusive
12102     * per its {@code init}) of: a plain {@code expr}, an {@code exprList}
12103     * (parenthesized {@code GROUP BY (a, b)}), a {@link TRollupCube}
12104     * (ROLLUP / CUBE), or a {@link TGroupingSet} (GROUPING SETS). Per-item
12105     * descent into the compound forms uses an explicit stack (no recursion,
12106     * per the repo iterative-traversal rule) with reverse-push so leaves
12107     * pop in document order.
12108     */
12109    private static List<TExpression> flattenGroupByExpressions(TGroupByItemList items) {
12110        List<TExpression> out = new ArrayList<>();
12111        for (int i = 0; i < items.size(); i++) {
12112            TGroupByItem item = items.getGroupByItem(i);
12113            if (item == null) {
12114                continue;
12115            }
12116            flattenGroupByItem(item, out);
12117        }
12118        return out;
12119    }
12120
12121    /**
12122     * Flatten a single {@link TGroupByItem} into the ordered list of leaf
12123     * grouping {@link TExpression}s, appending to {@code out}. Extracted
12124     * from {@link #flattenGroupByExpressions} (slice 127) so slice 128's
12125     * {@link #buildGroupingElements} can collect the member columns of one
12126     * top-level grouping element independently. Descent into ROLLUP / CUBE
12127     * / GROUPING SETS uses an explicit stack (no recursion, per the repo
12128     * iterative-traversal rule) with reverse-push so leaves pop in
12129     * document order.
12130     */
12131    private static void flattenGroupByItem(TGroupByItem item, List<TExpression> out) {
12132        if (item == null) {
12133            return;
12134        }
12135        if (item.getExpr() != null) {
12136            out.add(item.getExpr());
12137        }
12138        addExpressionListItems(item.getExprList(), out);
12139        Deque<TParseTreeNode> stack = new ArrayDeque<>();
12140        if (item.getRollupCube() != null) {
12141            stack.push(item.getRollupCube());
12142        }
12143        if (item.getGroupingSet() != null) {
12144            stack.push(item.getGroupingSet());
12145        }
12146        while (!stack.isEmpty()) {
12147            TParseTreeNode node = stack.pop();
12148            if (node instanceof TRollupCube) {
12149                addExpressionListItems(((TRollupCube) node).getItems(), out);
12150            } else if (node instanceof TGroupingSet) {
12151                TGroupingSetItemList gsItems = ((TGroupingSet) node).getItems();
12152                if (gsItems != null) {
12153                    // reverse-push so set items pop in document order
12154                    for (int j = gsItems.size() - 1; j >= 0; j--) {
12155                        TGroupingSetItem gsi = gsItems.getGroupingSetItem(j);
12156                        if (gsi != null) {
12157                            stack.push(gsi);
12158                        }
12159                    }
12160                }
12161            } else if (node instanceof TGroupingSetItem) {
12162                TGroupingSetItem gsi = (TGroupingSetItem) node;
12163                // exactly one payload per item; add direct exprs, push
12164                // the compound child so its leaves emerge in order.
12165                if (gsi.getGrouping_expression() != null) {
12166                    out.add(gsi.getGrouping_expression());
12167                }
12168                if (gsi.getExpressionItem() != null) {
12169                    stack.push(gsi.getExpressionItem());
12170                }
12171                if (gsi.getRollupCubeClause() != null) {
12172                    stack.push(gsi.getRollupCubeClause());
12173                }
12174            } else if (node instanceof TGroupingExpressionItem) {
12175                TGroupingExpressionItem gei = (TGroupingExpressionItem) node;
12176                if (gei.getExpr() != null) {
12177                    out.add(gei.getExpr());
12178                }
12179                addExpressionListItems(gei.getExprList(), out);
12180            }
12181        }
12182    }
12183
12184    /**
12185     * Slice 128 — build the structured per-top-level-element view of the
12186     * {@code GROUP BY}: one {@link GroupingElement} per top-level
12187     * {@link TGroupByItem} in document order, tagged
12188     * {@code SIMPLE} / {@code ROLLUP} / {@code CUBE} / {@code GROUPING_SETS}
12189     * (via {@link #groupingElementKind}) and carrying that element's
12190     * flattened leaf member columns.
12191     *
12192     * <p>Returns an empty list when there is no {@code GROUP BY}. Runs
12193     * <i>after</i> {@link #buildGroupByColumnRefs} in the SELECT builder, so
12194     * the slice-61 subquery and slice-13 window-function guards have already
12195     * rejected illegal grouping expressions and the per-element member
12196     * collection (which reuses {@link #collectColumnRefsFromExpressions}
12197     * with the slice-93 source-table fallback, exactly as the flat path
12198     * does) cannot newly throw. Per-element collection means a column
12199     * appearing in two top-level elements (e.g.
12200     * {@code GROUP BY a, ROLLUP(a)}) is faithfully present in both — the
12201     * flat {@link #buildGroupByColumnRefs} dedups it to a single ref, which
12202     * is the intended difference between the two slots.
12203     */
12204    private static List<GroupingElement> buildGroupingElements(
12205            TSelectSqlStatement select, NameBindingProvider provider) {
12206        TGroupBy groupBy = select.getGroupByClause();
12207        if (groupBy == null || groupBy.getItems() == null || groupBy.getItems().size() == 0) {
12208            return new ArrayList<>();
12209        }
12210        TGroupByItemList items = groupBy.getItems();
12211        NameBindingProvider fallbackProvider = provider.withSourceTableFallback(true);
12212        List<GroupingElement> out = new ArrayList<>();
12213        for (int i = 0; i < items.size(); i++) {
12214            TGroupByItem item = items.getGroupByItem(i);
12215            if (item == null) {
12216                continue;
12217            }
12218            List<TExpression> exprs = new ArrayList<>();
12219            flattenGroupByItem(item, exprs);
12220            List<ColumnRef> members = collectColumnRefsFromExpressions(exprs, fallbackProvider);
12221            out.add(new GroupingElement(groupingElementKind(item), members));
12222        }
12223        return out;
12224    }
12225
12226    /**
12227     * Classify the grouping operation of a single top-level
12228     * {@link TGroupByItem} (slice 128). The authoritative signals are the
12229     * AST node-presence checks: a non-null {@code groupingSet} is
12230     * {@code GROUPING SETS}; a non-null {@code rollupCube} carries its own
12231     * {@code rollup} / {@code cube} / {@code grouping_sets} operation
12232     * (BigQuery spells {@code GROUPING SETS} as a {@link TRollupCube} with
12233     * the {@code grouping_sets} operation). The
12234     * {@link EGroupingSetType} flag is a defensive fallback for any form
12235     * that sets the flag without populating the node; {@code gsExpr} /
12236     * {@code gsList} / {@code gsEmpty} and anything unrecognized fall
12237     * through to {@code SIMPLE} (the plain {@code expr} / {@code exprList}
12238     * grouping path).
12239     */
12240    private static GroupingElement.Kind groupingElementKind(TGroupByItem item) {
12241        if (item.getGroupingSet() != null) {
12242            return GroupingElement.Kind.GROUPING_SETS;
12243        }
12244        TRollupCube rc = item.getRollupCube();
12245        if (rc != null) {
12246            switch (rc.getOperation()) {
12247                case TRollupCube.cube:
12248                    return GroupingElement.Kind.CUBE;
12249                case TRollupCube.grouping_sets:
12250                    return GroupingElement.Kind.GROUPING_SETS;
12251                case TRollupCube.rollup:
12252                default:
12253                    return GroupingElement.Kind.ROLLUP;
12254            }
12255        }
12256        EGroupingSetType gst = item.getGroupingSetType();
12257        if (gst != null) {
12258            switch (gst) {
12259                case gsRollup:
12260                    return GroupingElement.Kind.ROLLUP;
12261                case gsCube:
12262                    return GroupingElement.Kind.CUBE;
12263                case gsSets:
12264                    return GroupingElement.Kind.GROUPING_SETS;
12265                default:
12266                    break;
12267            }
12268        }
12269        return GroupingElement.Kind.SIMPLE;
12270    }
12271
12272    /** Append every {@link TExpression} of {@code list} (if any) to {@code out}. */
12273    private static void addExpressionListItems(TExpressionList list, List<TExpression> out) {
12274        if (list == null) {
12275            return;
12276        }
12277        for (int j = 0; j < list.size(); j++) {
12278            TExpression e = list.getExpression(j);
12279            if (e != null) {
12280                out.add(e);
12281            }
12282        }
12283    }
12284
12285    /**
12286     * Multi-root variant of {@link #collectColumnRefs}: collect EXACT_MATCH
12287     * physical {@link ColumnRef}s from each expression root in order, with
12288     * the same {@code nestedSelectDepth} guard, USING merged-key handling
12289     * via {@link #appendMergedOrBoundColumnRef}, {@link LinkedHashSet}
12290     * dedup, and {@code COLUMN_BINDING_NON_EXACT} rejection. Used by
12291     * {@link #buildGroupByColumnRefs} over the slice-127 flattened
12292     * ROLLUP / CUBE / GROUPING SETS grouping expressions.
12293     */
12294    private static List<ColumnRef> collectColumnRefsFromExpressions(
12295            List<TExpression> exprs, final NameBindingProvider provider) {
12296        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
12297        final List<String> rejects = new ArrayList<>();
12298        // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at
12299        // the wrong side). Such a miss is a genuine error and must stay fatal;
12300        // only all-unqualified rejects are eligible for the join-graph degrade.
12301        final boolean[] sawQualifiedReject = {false};
12302        TParseTreeVisitor visitor = new TParseTreeVisitor() {
12303            int nestedSelectDepth = 0;
12304
12305            @Override
12306            public void preVisit(TSelectSqlStatement nested) {
12307                nestedSelectDepth++;
12308            }
12309
12310            @Override
12311            public void postVisit(TSelectSqlStatement nested) {
12312                nestedSelectDepth--;
12313            }
12314
12315            @Override
12316            public void preVisit(TObjectName node) {
12317                if (nestedSelectDepth > 0) return;
12318                appendMergedOrBoundColumnRef(node, provider, refs, rejects,
12319                        sawQualifiedReject);
12320            }
12321        };
12322        for (TExpression e : exprs) {
12323            e.acceptChildren(visitor);
12324        }
12325        if (!rejects.isEmpty()) {
12326            rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]);
12327        }
12328        return new ArrayList<>(refs);
12329    }
12330
12331    /**
12332     * Collect physical column references from the {@code HAVING} clause.
12333     *
12334     * <p>HAVING is supported regardless of whether {@code GROUP BY} is
12335     * present: standard SQL allows {@code HAVING} without {@code GROUP BY}
12336     * (the whole result set is treated as a single group), and the parser
12337     * still attaches a {@link TGroupBy} node with empty
12338     * {@code getItems()} in that case. Both shapes flow through the same
12339     * collection path.
12340     *
12341     * <p>Per-shape rejections fire <i>before</i> {@link #collectColumnRefs}
12342     * so subquery / OVER children never enter the visitor and can't leak
12343     * inner-scope refs into {@code havingColumnRefs} (mirrors slice-9
12344     * ORDER BY guards):
12345     *
12346     * <ul>
12347     *   <li>Scalar subqueries ({@link EExpressionType#subquery_t}) and
12348     *       predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)},
12349     *       {@code ANY/ALL/SOME}) — checked via both expression-type and
12350     *       {@link TExpression#getSubQuery()}, deep-scanned through the
12351     *       whole HAVING expression subtree.</li>
12352     *   <li>Window functions ({@code OVER (...)}) — standard SQL forbids
12353     *       window functions in HAVING, but defense in depth: the
12354     *       deep-scan rejecter ensures PARTITION BY / OVER ORDER BY refs
12355     *       can't leak.</li>
12356     * </ul>
12357     *
12358     * <p>Aggregate functions in HAVING are <i>not</i> rejected — they're
12359     * the most common HAVING shape ({@code HAVING SUM(salary) > 1000}).
12360     * The visitor walks into the aggregate's argument list and captures
12361     * the underlying column ref ({@code salary}) the same way slice 6
12362     * does for projection-side aggregate args.
12363     */
12364    private static List<ColumnRef> buildHavingColumnRefs(TSelectSqlStatement select,
12365                                                        NameBindingProvider provider) {
12366        TGroupBy groupBy = select.getGroupByClause();
12367        if (groupBy == null) return new ArrayList<>();
12368        TExpression having = groupBy.getHavingClause();
12369        if (having == null) return new ArrayList<>();
12370        rejectHavingScalarSubquery(having);
12371        rejectHavingWindowFunction(having);
12372        return collectColumnRefs(having, provider);
12373    }
12374
12375    /**
12376     * Reject HAVING expressions that contain a subquery anywhere in the
12377     * subtree. Catches both:
12378     *
12379     * <ul>
12380     *   <li>Scalar subqueries ({@link EExpressionType#subquery_t}) —
12381     *       e.g. {@code HAVING (SELECT MAX(salary) FROM employees) > 0}.</li>
12382     *   <li>Predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)},
12383     *       {@code ANY/ALL/SOME (SELECT ...)}) — these don't appear as
12384     *       {@code subquery_t} expression nodes but carry a non-null
12385     *       {@link TExpression#getSubQuery()}, e.g.
12386     *       {@code HAVING EXISTS (SELECT 1 FROM ...)} or
12387     *       {@code HAVING d.id IN (SELECT id FROM ...)}.</li>
12388     * </ul>
12389     *
12390     * <p>Mirrors {@link #rejectOrderByScalarSubquery}: top-level fast
12391     * path + visitor deep-scan over {@link TExpression#acceptChildren}.
12392     * The deep scan is required for nested cases like
12393     * {@code HAVING flag = 1 AND EXISTS (SELECT ...)} or
12394     * {@code HAVING CASE WHEN d.id IN (SELECT ...) THEN 1 ELSE 0 END > 0}.
12395     */
12396    private static void rejectHavingScalarSubquery(TExpression having) {
12397        if (having.getExpressionType() == EExpressionType.subquery_t
12398                || having.getSubQuery() != null) {
12399            throw new SemanticIRBuildException(
12400                    Diagnostic.error(DiagnosticCode.HAVING_SUBQUERY_NOT_SUPPORTED,
12401                    "HAVING subquery '" + having + "' is not supported yet "
12402                            + "(subqueries in HAVING would leak inner column refs)", having));
12403        }
12404        final boolean[] found = {false};
12405        having.acceptChildren(new TParseTreeVisitor() {
12406            @Override
12407            public void preVisit(TExpression e) {
12408                if (found[0]) return;
12409                if (e.getExpressionType() == EExpressionType.subquery_t
12410                        || e.getSubQuery() != null) {
12411                    found[0] = true;
12412                }
12413            }
12414        });
12415        if (found[0]) {
12416            throw new SemanticIRBuildException(
12417                    Diagnostic.error(DiagnosticCode.HAVING_HAS_SUBQUERY_NOT_SUPPORTED,
12418                    "HAVING expression '" + having + "' contains a subquery "
12419                            + "(scalar, EXISTS, IN, or ANY/ALL/SOME); not supported yet", having));
12420        }
12421    }
12422
12423    /**
12424     * Reject HAVING expressions that contain a window function. Standard
12425     * SQL forbids window functions in HAVING (analytic functions are
12426     * computed after HAVING), but defense in depth: the visitor would
12427     * descend into {@code OVER (PARTITION BY ... ORDER BY ...)} and the
12428     * inner-scope refs would otherwise leak into {@code havingColumnRefs}.
12429     * Mirrors the projection-side {@link #rejectWindowFunctions} and the
12430     * ORDER BY-side {@link #rejectOrderByWindowFunction}.
12431     */
12432    private static void rejectHavingWindowFunction(TExpression having) {
12433        final boolean[] found = {false};
12434        having.acceptChildren(new TParseTreeVisitor() {
12435            @Override
12436            public void preVisit(TFunctionCall fn) {
12437                if (found[0]) return;
12438                if (fn.getWindowDef() != null) found[0] = true;
12439            }
12440        });
12441        if (!found[0] && having.getExpressionType() == EExpressionType.function_t) {
12442            TFunctionCall fn = having.getFunctionCall();
12443            if (fn != null && fn.getWindowDef() != null) found[0] = true;
12444        }
12445        if (found[0]) {
12446            throw new SemanticIRBuildException(
12447                    Diagnostic.error(DiagnosticCode.HAVING_WINDOW_FUNCTION_NOT_SUPPORTED,
12448                    "HAVING window function '" + having + "' is not supported yet "
12449                            + "(window OVER (...) refs would leak into havingColumnRefs)", having));
12450        }
12451    }
12452
12453    /**
12454     * Slice 125: collect physical column references from a {@code QUALIFY}
12455     * clause's predicate (Snowflake / BigQuery / Teradata). QUALIFY filters
12456     * rows on window-function results; like {@code WHERE} / {@code HAVING}
12457     * it is row-influence, so its refs are captured into
12458     * {@link StatementGraph#getQualifyColumnRefs()}.
12459     *
12460     * <p>Two surface forms are admitted and reduce to the SAME influencing
12461     * base columns:
12462     * <ul>
12463     *   <li>Inline window form
12464     *       ({@code QUALIFY ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) = 1})
12465     *       — the window's PARTITION BY / ORDER BY / argument refs bind as
12466     *       base columns and are collected directly; the function-name
12467     *       node and {@code *} are skipped.</li>
12468     *   <li>Projection-alias form ({@code QUALIFY rn = 1}) — the parser
12469     *       leaves {@code rn} as a bare {@code column}-typed
12470     *       {@link TObjectName} (it is NOT retyped to {@code column_alias}
12471     *       the way ORDER BY operands are), so it does not bind to a base
12472     *       column; it resolves to the matching {@link OutputColumn} and
12473     *       contributes that output's {@code getSources()} unioned with its
12474     *       window spec's partition/order refs.</li>
12475     * </ul>
12476     *
12477     * <p>Window functions are admitted (the whole point of QUALIFY) and are
12478     * NOT validated against the slice-13 projection window-function
12479     * allowlist — QUALIFY collects only the refs that influence the row
12480     * set; it does not build a {@link WindowSpec}. Subqueries (scalar,
12481     * EXISTS, IN-SELECT, ANY/ALL/SOME) are rejected before ref collection
12482     * (they would leak inner-scope refs), reusing the repurposed
12483     * {@link DiagnosticCode#QUALIFY_NOT_SUPPORTED} code.
12484     */
12485    private static List<ColumnRef> buildQualifyColumnRefs(TSelectSqlStatement select,
12486                                                          NameBindingProvider provider,
12487                                                          List<OutputColumn> outputColumns) {
12488        if (select.getQualifyClause() == null) {
12489            return new ArrayList<>();
12490        }
12491        TExpression qualify = select.getQualifyClause().getSearchConditoin();
12492        if (qualify == null) {
12493            return new ArrayList<>();
12494        }
12495        rejectQualifySubquery(qualify);
12496        rejectUnsupportedQualifyWindowFunction(qualify);
12497        return collectQualifyColumnRefs(qualify, provider, outputColumns);
12498    }
12499
12500    /**
12501     * Slice 126: validate the function NAME of any INLINE window function
12502     * inside a {@code QUALIFY} predicate against the slice-13
12503     * {@link #WINDOW_FUNCTION_NAMES} allowlist. Slice 125 admitted any
12504     * function in QUALIFY (collecting only its influencing refs); this guard
12505     * applies the same name check {@link #buildWindowOutputColumn} uses for
12506     * projection windows so an unfamiliar windowed function whose semantics
12507     * the engine does not model — {@code QUALIFY FOO(a) OVER (ORDER BY b) = 1}
12508     * — rejects with {@link DiagnosticCode#WINDOW_FUNCTION_UNSUPPORTED}
12509     * instead of silently admitting.
12510     *
12511     * <p>An INLINE window function is a {@link TFunctionCall} carrying
12512     * {@code OVER (...)} ({@code getWindowDef() != null}). This is the SAME
12513     * signal {@code buildWindowOutputColumn} keys on. Non-window functions
12514     * are deliberately left alone:
12515     * <ul>
12516     *   <li>a plain UDF / scalar call with no {@code OVER}
12517     *       ({@code QUALIFY my_udf(a) = 1}), and</li>
12518     *   <li>a non-windowed aggregate ({@code QUALIFY SUM(a) = 1})</li>
12519     * </ul>
12520     * both have {@code getWindowDef() == null} and stay admitted — slice 126
12521     * validates window-function NAMES, it does not require QUALIFY to contain
12522     * a window function (that would be a broader semantic rule outside this
12523     * slice). The projection-alias form ({@code QUALIFY rn = 1}) carries no
12524     * {@link TFunctionCall} in the QUALIFY expression (the window function
12525     * lives in the projection, already slice-13 validated), so the guard is a
12526     * no-op there.
12527     *
12528     * <p>Nested SELECTs are skipped (a window function in an inner scope is
12529     * not this statement's QUALIFY window). In practice none survive —
12530     * {@link #rejectQualifySubquery} runs first and rejects any subquery — but
12531     * the {@code nestedSelectDepth} guard mirrors
12532     * {@link #collectQualifyColumnRefs} for defense-in-depth and parity.
12533     *
12534     * <p>Reuses the existing {@link DiagnosticCode#WINDOW_FUNCTION_UNSUPPORTED}
12535     * code (already reached by {@link #buildWindowOutputColumn}) so the
12536     * DiagnosticCode count stays unchanged.
12537     */
12538    private static void rejectUnsupportedQualifyWindowFunction(TExpression qualify) {
12539        qualify.acceptChildren(new TParseTreeVisitor() {
12540            int nestedSelectDepth = 0;
12541
12542            @Override
12543            public void preVisit(TSelectSqlStatement nested) {
12544                nestedSelectDepth++;
12545            }
12546
12547            @Override
12548            public void postVisit(TSelectSqlStatement nested) {
12549                nestedSelectDepth--;
12550            }
12551
12552            @Override
12553            public void preVisit(TFunctionCall fn) {
12554                if (nestedSelectDepth > 0) return;
12555                if (fn.getWindowDef() == null) return;
12556                String fnName = fn.getFunctionName() == null
12557                        ? null : fn.getFunctionName().toString();
12558                if (fnName == null
12559                        || !WINDOW_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) {
12560                    throw new SemanticIRBuildException(
12561                            Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_UNSUPPORTED,
12562                            "QUALIFY uses unsupported window function '" + fnName
12563                                    + "'; supported names are " + WINDOW_FUNCTION_NAMES, fn));
12564                }
12565            }
12566        });
12567    }
12568
12569    /**
12570     * Reject {@code QUALIFY} expressions that contain a subquery anywhere
12571     * in the subtree — scalar ({@link EExpressionType#subquery_t}) or
12572     * predicate ({@code EXISTS}, {@code IN (SELECT ...)},
12573     * {@code ANY/ALL/SOME}, carried via {@link TExpression#getSubQuery()}).
12574     * Mirrors {@link #rejectHavingScalarSubquery}: top-level fast path plus
12575     * a visitor deep-scan. Reuses the (slice-125-repurposed)
12576     * {@link DiagnosticCode#QUALIFY_NOT_SUPPORTED} code so the
12577     * DiagnosticCode count stays unchanged and the code stays reached.
12578     */
12579    private static void rejectQualifySubquery(TExpression qualify) {
12580        boolean topLevelSubquery = qualify.getExpressionType() == EExpressionType.subquery_t
12581                || qualify.getSubQuery() != null;
12582        final boolean[] found = {topLevelSubquery};
12583        if (!found[0]) {
12584            qualify.acceptChildren(new TParseTreeVisitor() {
12585                @Override
12586                public void preVisit(TExpression e) {
12587                    if (found[0]) return;
12588                    if (e.getExpressionType() == EExpressionType.subquery_t
12589                            || e.getSubQuery() != null) {
12590                        found[0] = true;
12591                    }
12592                }
12593            });
12594        }
12595        if (found[0]) {
12596            throw new SemanticIRBuildException(
12597                    Diagnostic.error(DiagnosticCode.QUALIFY_NOT_SUPPORTED,
12598                    "QUALIFY subquery '" + qualify + "' is not supported yet "
12599                            + "(subqueries in QUALIFY would leak inner column refs)", qualify));
12600        }
12601    }
12602
12603    /**
12604     * Slice 125: QUALIFY-specific column-ref collector. Mirrors
12605     * {@link #collectColumnRefs} / {@link #appendMergedOrBoundColumnRef}
12606     * (nested-SELECT skip, UsingScope merged-key expansion, EXACT_MATCH
12607     * binding) but adds a projection-alias fallback: an unqualified
12608     * {@code column}-typed {@link TObjectName} that does not bind to a base
12609     * column but matches an {@link OutputColumn} name resolves to that
12610     * output's influencing base columns ({@code getSources()} unioned with
12611     * its window spec's partition/order refs). This captures the canonical
12612     * {@code QUALIFY rn = 1} form, where {@code rn} aliases a window
12613     * projection whose own {@code getSources()} is empty.
12614     */
12615    private static List<ColumnRef> collectQualifyColumnRefs(TExpression qualify,
12616                                                            final NameBindingProvider provider,
12617                                                            final List<OutputColumn> outputColumns) {
12618        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
12619        final List<String> rejects = new ArrayList<>();
12620        // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at
12621        // the wrong side). Such a miss is a genuine error and must stay fatal;
12622        // only all-unqualified rejects are eligible for the join-graph degrade.
12623        final boolean[] sawQualifiedReject = {false};
12624        qualify.acceptChildren(new TParseTreeVisitor() {
12625            int nestedSelectDepth = 0;
12626
12627            @Override
12628            public void preVisit(TSelectSqlStatement nested) {
12629                nestedSelectDepth++;
12630            }
12631
12632            @Override
12633            public void postVisit(TSelectSqlStatement nested) {
12634                nestedSelectDepth--;
12635            }
12636
12637            @Override
12638            public void preVisit(TObjectName node) {
12639                if (nestedSelectDepth > 0) return;
12640                if (node.getDbObjectType() != EDbObjectType.column) return;
12641                String name = node.getColumnNameOnly();
12642                if (name == null || "*".equals(name)) return;
12643                UsingScope scope = provider.getUsingScope();
12644                String qualifier = node.getTableString();
12645                boolean unqualified = qualifier == null || qualifier.isEmpty();
12646                if (unqualified && scope.has(name)) {
12647                    if (scope.isAmbiguous(name)) {
12648                        throw new SemanticIRBuildException(
12649                                Diagnostic.error(DiagnosticCode.UNQUALIFIED_COLUMN_AMBIGUOUS,
12650                                "unqualified reference to '" + name + "' is ambiguous: "
12651                                        + scope.ambiguityReason(name)
12652                                        + "; qualify with a table alias", null));
12653                    }
12654                    refs.addAll(scope.mergedSourcesFor(name));
12655                    return;
12656                }
12657                ColumnBinding binding = provider.bindColumn(node);
12658                if (binding != null && binding.getStatus() == ResolutionStatus.EXACT_MATCH) {
12659                    refs.add(new ColumnRef(binding.getRelationAlias(), binding.getColumnName()));
12660                    return;
12661                }
12662                // Base-column binding failed. Projection-alias fallback:
12663                // an unqualified name that matches an output column's name
12664                // (case-insensitive) resolves to that output's influencing
12665                // base columns. QUALIFY (post-window) can reference SELECT
12666                // aliases in Snowflake / BigQuery.
12667                if (unqualified) {
12668                    List<ColumnRef> aliasSources = resolveQualifyProjectionAlias(name, outputColumns);
12669                    if (aliasSources != null) {
12670                        refs.addAll(aliasSources);
12671                        return;
12672                    }
12673                    // Catalog-less NATURAL JOIN degrade (GSP R6): unqualified
12674                    // merged NATURAL key resolves to the left-side relation.
12675                    if (scope.hasNaturalDegrade()) {
12676                        refs.add(new ColumnRef(scope.naturalDegradeFallbackAlias(), name));
12677                        return;
12678                    }
12679                }
12680                rejects.add(node + (binding == null ? "[no binding]"
12681                        : "[" + binding.getStatus() + "]"));
12682                if (!unqualified) {
12683                    sawQualifiedReject[0] = true;
12684                }
12685            }
12686        });
12687        if (!rejects.isEmpty()) {
12688            rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]);
12689        }
12690        return new ArrayList<>(refs);
12691    }
12692
12693    /**
12694     * Slice 125: resolve an unqualified QUALIFY reference name to the
12695     * influencing base columns of the matching projection output column.
12696     * The influencing columns are the output's
12697     * {@link OutputColumn#getSources()} unioned with its window spec's
12698     * {@link WindowSpec#getPartitionRefs()} and
12699     * {@link WindowSpec#getOrderRefs()} — a window output's own
12700     * {@code getSources()} is empty, so the partition/order refs are where
12701     * the row-influencing base columns live.
12702     *
12703     * <p>Return contract (the caller distinguishes the two non-throwing
12704     * cases by {@code null} vs empty):
12705     * <ul>
12706     *   <li>{@code null} — no output column matches by name; the caller
12707     *       records a {@code COLUMN_BINDING_NON_EXACT} reject.</li>
12708     *   <li>non-empty list — the matched output's influencing base
12709     *       columns.</li>
12710     *   <li><b>empty list</b> — an output matched but it has no influencing
12711     *       base columns (a constant projection such as {@code SELECT 1 AS x}
12712     *       referenced by {@code QUALIFY x = 1}, or any sourceless /
12713     *       window-less projection). This is an admitted, deliberate
12714     *       boundary: a QUALIFY predicate over a column-free output has no
12715     *       column dependency, exactly like a constant {@code WHERE 1 = 1}
12716     *       contributes no filter refs. It is NOT treated as a reject — the
12717     *       ref resolves successfully and simply contributes no base-column
12718     *       dependency.</li>
12719     * </ul>
12720     *
12721     * <p>Match strategy: case-insensitive ({@link Locale#ROOT}), FIRST
12722     * (leftmost) match — identical to {@link #tryResolveOrderByProjectionAlias}.
12723     * Duplicate projection aliases ({@code SELECT a AS rn, b AS rn ...
12724     * QUALIFY rn = 1}) therefore resolve to the leftmost projection rather
12725     * than rejecting as ambiguous; this is the documented slice-69 ORDER BY
12726     * boundary applied to QUALIFY for cross-clause consistency.
12727     *
12728     * <p>Resolution order in the caller is base-column-first: a name that
12729     * binds {@code EXACT_MATCH} to a base column is emitted directly and
12730     * never reaches this fallback, which fires only for an UNQUALIFIED ref
12731     * whose base binding failed. A qualified ref ({@code QUALIFY e.rn}) is
12732     * never alias-resolved here (qualifying a projection alias is invalid
12733     * SQL); it binds through the provider like any other qualified ref.
12734     */
12735    private static List<ColumnRef> resolveQualifyProjectionAlias(String name,
12736                                                                 List<OutputColumn> outputColumns) {
12737        String key = name.toLowerCase(Locale.ROOT);
12738        for (OutputColumn oc : outputColumns) {
12739            String outName = oc.getName();
12740            if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) {
12741                LinkedHashSet<ColumnRef> influencing = new LinkedHashSet<>(oc.getSources());
12742                WindowSpec ws = oc.getWindowSpec();
12743                if (ws != null) {
12744                    influencing.addAll(ws.getPartitionRefs());
12745                    influencing.addAll(ws.getOrderRefs());
12746                }
12747                return new ArrayList<>(influencing);
12748            }
12749        }
12750        return null;
12751    }
12752
12753    /**
12754     * Collect physical column references from {@code ORDER BY} sort keys.
12755     *
12756     * <p>Per-item validation rejects shapes that would otherwise vanish
12757     * silently into an empty ref list, leak inner-scope refs, or
12758     * misrepresent presentation as a dependency:
12759     *
12760     * <ul>
12761     *   <li>Ordinal references ({@code ORDER BY 1}) — the sort key is a
12762     *       {@link EExpressionType#simple_constant_t}; its meaning is
12763     *       "first projected column" which depends on the SELECT list,
12764     *       not on a base column. A future slice can model output-position
12765     *       references explicitly.</li>
12766     *   <li>Constant sort keys other than ordinals ({@code ORDER BY 'x'},
12767     *       and the compound {@code ORDER BY (1)} / {@code ORDER BY 1+0}
12768     *       caught by the generic no-physical-column-refs check).</li>
12769     *   <li>Projection-alias references ({@code ORDER BY x} where
12770     *       {@code x} is a SELECT alias) — {@link TOrderByItem#doParse}
12771     *       retypes the operand to {@link TObjectName#ttobjColumnAlias},
12772     *       which lowers {@link TObjectName#getDbObjectType()} to
12773     *       {@link EDbObjectType#column_alias}. Without explicit
12774     *       rejection the visitor would skip it and the IR would lose
12775     *       the dependency entirely. The deep-scan version of this
12776     *       check catches alias nodes nested inside expressions.</li>
12777     *   <li>Subqueries in sort keys — scalar
12778     *       ({@link EExpressionType#subquery_t}) and predicate
12779     *       ({@code EXISTS}, {@code IN (SELECT ...)}, {@code ANY/ALL/SOME})
12780     *       — would otherwise leak inner-scope column refs into the
12781     *       outer statement's {@code orderByColumnRefs}.</li>
12782     *   <li>Window functions in sort keys ({@code ORDER BY ROW_NUMBER()
12783     *       OVER (...)}) — the OVER clause descends through the visitor
12784     *       and would leak its PARTITION BY / ORDER BY refs.</li>
12785     * </ul>
12786     *
12787     * <p>Sub-clauses that change row-set semantics are also rejected
12788     * here: Oracle {@code ORDER SIBLINGS BY} (hierarchical, not yet
12789     * modelled), Teradata {@code RESET WHEN} (window-style restart),
12790     * and the {@link TOrderBy}-level {@code FETCH FIRST}/{@code OFFSET}
12791     * defensive guards (in fresh parses the SELECT-level row-limit
12792     * guards in {@link #rejectUnsupportedShape} fire first because
12793     * {@code TSelectSqlNode.setOrderbyClause()} copies in-clause OFFSET/
12794     * FETCH onto the SELECT node).
12795     *
12796     * <p>For everything else (qualified column refs, expressions like
12797     * {@code UPPER(name)}), {@link #collectColumnRefs} runs over each
12798     * sort key and aggregates the physical column refs. A per-item
12799     * empty-refs check catches anything that slipped past the explicit
12800     * shape rejections (e.g. {@code ORDER BY (1)},
12801     * {@code ORDER BY 1 + 0}). Sort direction ({@code ASC}/{@code DESC})
12802     * and null placement ({@code NULLS FIRST}/{@code NULLS LAST}) are
12803     * presentation metadata and are not modelled.
12804     */
12805    private static List<ColumnRef> buildOrderByColumnRefs(TSelectSqlStatement select,
12806                                                          NameBindingProvider provider,
12807                                                          List<OutputColumn> outputColumns) {
12808        TOrderBy orderBy = select.getOrderbyClause();
12809        if (orderBy == null) {
12810            return new ArrayList<>();
12811        }
12812        if (orderBy.isSiblings()) {
12813            throw new SemanticIRBuildException(
12814                    Diagnostic.error(DiagnosticCode.ORDER_SIBLINGS_BY_NOT_SUPPORTED,
12815                    "ORDER SIBLINGS BY is not supported yet "
12816                            + "(Oracle hierarchical ordering)", orderBy));
12817        }
12818        if (orderBy.getResetWhenCondition() != null) {
12819            throw new SemanticIRBuildException(
12820                    Diagnostic.error(DiagnosticCode.ORDER_BY_RESET_WHEN_NOT_SUPPORTED,
12821                    "ORDER BY ... RESET WHEN is not supported yet "
12822                            + "(Teradata window-style restart)", orderBy));
12823        }
12824        // Slice 71: the in-clause OFFSET/FETCH on TOrderBy is no longer
12825        // rejected. MSSQL parsers duplicate OFFSET/FETCH onto BOTH the
12826        // SELECT node AND the TOrderBy node; slice 71 admits at the
12827        // SELECT level via buildRowLimit, so the TOrderBy duplicates
12828        // are simply ignored here. Oracle parsers populate only the
12829        // SELECT-level fields, so the TOrderBy fields are typically
12830        // null there.
12831        TOrderByItemList items = orderBy.getItems();
12832        if (items == null || items.size() == 0) {
12833            return new ArrayList<>();
12834        }
12835        // Validate + collect per item so a sort key contributing zero
12836        // column refs (e.g. constant arithmetic, parenthesised constant)
12837        // is rejected with an item-specific message instead of silently
12838        // disappearing.
12839        LinkedHashSet<ColumnRef> all = new LinkedHashSet<>();
12840        for (int i = 0; i < items.size(); i++) {
12841            TOrderByItem item = items.getOrderByItem(i);
12842            if (item == null) continue;
12843            TExpression sortKey = item.getSortKey();
12844            if (sortKey == null) continue;
12845            // Slice 68: positive-integer ordinals admit. The helper:
12846            //   - returns null when sortKey is not a positive-integer
12847            //     literal (caller falls through to the existing
12848            //     constant / alias / subquery / window rejecters and the
12849            //     standard ref collection);
12850            //   - returns the matching output column's sources list when
12851            //     sortKey IS a positive-integer literal in range;
12852            //   - throws ORDER_BY_ORDINAL_OUT_OF_RANGE when the ordinal
12853            //     is 0 or exceeds the output column count.
12854            // The sourceless-output case (e.g. SELECT 1 FROM t ORDER BY 1
12855            // or SELECT COUNT(*) FROM t ORDER BY 1) returns an empty
12856            // list and falls through to the per-item empty-refs guard
12857            // below, mirroring the existing ORDER BY COUNT(*) /
12858            // ORDER BY 1 + 0 rejection.
12859            List<ColumnRef> ordinalSources = tryResolveOrderByOrdinal(sortKey, outputColumns);
12860            if (ordinalSources != null) {
12861                if (ordinalSources.isEmpty()) {
12862                    throw new SemanticIRBuildException(
12863                            Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS,
12864                            "ORDER BY ordinal '" + sortKey
12865                                    + "' resolves to output column with no physical column references "
12866                                    + "(constant or sourceless aggregate output)", sortKey));
12867                }
12868                all.addAll(ordinalSources);
12869                continue;
12870            }
12871            // Slice 69: top-level projection-alias references admit. The
12872            // helper returns null for non-alias shapes; an empty list
12873            // (alias of a constant / sourceless aggregate) falls through
12874            // to ORDER_BY_NO_PHYSICAL_COLUMN_REFS, mirroring the slice-68
12875            // sourceless-ordinal handling. Deep-scan alias references
12876            // (e.g. ORDER BY UPPER(<alias>)) are still caught by
12877            // rejectOrderByAliasReference below.
12878            List<ColumnRef> aliasSources = tryResolveOrderByProjectionAlias(sortKey, outputColumns);
12879            if (aliasSources != null) {
12880                if (aliasSources.isEmpty()) {
12881                    throw new SemanticIRBuildException(
12882                            Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS,
12883                            "ORDER BY projection alias '" + sortKey
12884                                    + "' resolves to output column with no physical column references "
12885                                    + "(constant or sourceless aggregate output)", sortKey));
12886                }
12887                all.addAll(aliasSources);
12888                continue;
12889            }
12890            // Slice 68: non-ordinal constants stay rejected. The original
12891            // helper is preserved for the set-op outer path (which keeps
12892            // its ordinal/constant rejection until slice 72).
12893            rejectOrderByNonOrdinalConstant(sortKey);
12894            // Slice 69: top-level bare alias references are consumed by
12895            // tryResolveOrderByProjectionAlias above; this helper now only
12896            // catches DEEP alias references nested inside compound
12897            // expressions (e.g. ORDER BY UPPER(<alias>)).
12898            rejectOrderByAliasReference(sortKey);
12899            // Reject scalar subqueries and window functions BEFORE
12900            // collecting refs. The visitor descends into both, so without
12901            // these guards `ORDER BY (SELECT MAX(salary) FROM employees)`
12902            // and `ORDER BY ROW_NUMBER() OVER (ORDER BY salary)` would
12903            // leak inner refs into orderByColumnRefs as if the outer
12904            // statement physically depended on them.
12905            rejectOrderByScalarSubquery(sortKey);
12906            rejectOrderByWindowFunction(sortKey);
12907            List<ColumnRef> itemRefs = collectColumnRefs(item, provider);
12908            if (itemRefs.isEmpty()) {
12909                // Anything else that produces no physical column refs:
12910                // ORDER BY (1), ORDER BY 1+0, ORDER BY NULL, ORDER BY
12911                // CASE WHEN 1=1 THEN 'a' END, etc. Reject so the IR
12912                // doesn't silently emit empty refs.
12913                throw new SemanticIRBuildException(
12914                        Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS,
12915                        "ORDER BY sort key '" + sortKey + "' has no physical column references "
12916                                + "(constant or non-column expressions are not supported yet)", sortKey));
12917            }
12918            all.addAll(itemRefs);
12919        }
12920        return new ArrayList<>(all);
12921    }
12922
12923    /**
12924     * Slice 68: resolve a positive-integer ORDER BY ordinal to the matching
12925     * output column's sources. Returns:
12926     *
12927     * <ul>
12928     *   <li>{@code null} if {@code sortKey} is not a positive-integer
12929     *       literal (caller continues with the constant / alias / subquery
12930     *       / window rejecters and the standard ref collection);</li>
12931     *   <li>a {@link List} of {@link ColumnRef}s — the source list of the
12932     *       output column at position {@code v - 1} (1-based ordinals);</li>
12933     *   <li>throws {@link SemanticIRBuildException} with
12934     *       {@code ORDER_BY_ORDINAL_OUT_OF_RANGE} when {@code v} is 0 or
12935     *       exceeds the output column count.</li>
12936     * </ul>
12937     *
12938     * <p>{@code sortKey.getExpressionType() == simple_constant_t} for bare
12939     * positive integers; negative integers parse as a {@code unary_minus_t}
12940     * over a {@code simple_constant_t} and are not handled here. Compound
12941     * constant expressions ({@code ORDER BY 1 + 0}, {@code ORDER BY (1)})
12942     * are {@code arithmetic_*_t} / {@code parenthesis_t} respectively and
12943     * fall through to the per-item empty-refs guard.
12944     *
12945     * <p>The empty-list case (output column resolved with
12946     * {@link OutputColumn#getSources()} empty — constant projections,
12947     * {@code COUNT(*)}, sourceless aggregates) is returned to the caller
12948     * which fires {@code ORDER_BY_NO_PHYSICAL_COLUMN_REFS}. Slice 68
12949     * boundary.
12950     *
12951     * <p>Sort direction (ASC/DESC) and null placement (NULLS FIRST/LAST)
12952     * are presentation metadata on {@link TOrderByItem}, not on the sort
12953     * key expression; this helper doesn't inspect them (slice 9 decision).
12954     */
12955    private static List<ColumnRef> tryResolveOrderByOrdinal(TExpression sortKey,
12956                                                             List<OutputColumn> outputs) {
12957        if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) {
12958            return null;
12959        }
12960        String txt = sortKey.toString();
12961        if (txt == null || !txt.matches("\\d+")) {
12962            return null;
12963        }
12964        long v;
12965        try {
12966            v = Long.parseLong(txt);
12967        } catch (NumberFormatException e) {
12968            // Very-long-digit text overflows long; definitely out of range.
12969            throw new SemanticIRBuildException(
12970                    Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_OUT_OF_RANGE,
12971                    "ORDER BY ordinal '" + sortKey + "' is out of range "
12972                            + "(must be between 1 and " + outputs.size() + ")", sortKey));
12973        }
12974        if (v < 1 || v > outputs.size()) {
12975            throw new SemanticIRBuildException(
12976                    Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_OUT_OF_RANGE,
12977                    "ORDER BY ordinal '" + sortKey + "' is out of range "
12978                            + "(must be between 1 and " + outputs.size() + ")", sortKey));
12979        }
12980        return outputs.get((int) v - 1).getSources();
12981    }
12982
12983    /**
12984     * Slice 69: resolve a top-level bare projection-alias ORDER BY sort
12985     * key to the matching output column's sources. Returns:
12986     *
12987     * <ul>
12988     *   <li>{@code null} if {@code sortKey} is not a top-level bare
12989     *       {@code simple_object_name_t} whose object operand has
12990     *       {@code dbObjectType == EDbObjectType.column_alias} (caller
12991     *       continues with {@link #rejectOrderByAliasReference} for the
12992     *       deep-scan case and the standard column-ref collection);</li>
12993     *   <li>a {@link List} of {@link ColumnRef}s — the matching output's
12994     *       source list (which may be empty when the aliased projection
12995     *       is a constant or sourceless aggregate; the caller fires
12996     *       {@code ORDER_BY_NO_PHYSICAL_COLUMN_REFS} in that case);</li>
12997     *   <li>throws {@link SemanticIRBuildException} with
12998     *       {@code ORDER_BY_PROJECTION_ALIAS_NOT_SUPPORTED} when the
12999     *       parser retyped the operand to {@code column_alias} but no
13000     *       matching output exists by case-insensitive name (defensive;
13001     *       theoretically unreachable for parsable SQL).</li>
13002     * </ul>
13003     *
13004     * <p>Match strategy: case-insensitive ({@link Locale#ROOT}) on
13005     * {@link OutputColumn#getName()}, returning the FIRST match. This
13006     * mirrors the set-op outer alias matcher in
13007     * {@link #processSetOpOrderByObjectName} (which uses the identical
13008     * {@code toLowerCase(Locale.ROOT)} pattern and {@code break}s on
13009     * first match) and follows MySQL / PostgreSQL ORDER BY alias
13010     * resolution semantics. Duplicate aliases (e.g. {@code SELECT a AS x,
13011     * b AS x FROM t ORDER BY x}) resolve to the leftmost matching
13012     * projection; this is the documented slice-69 boundary.
13013     *
13014     * <p>The set-op outer alias path
13015     * ({@link #buildSetOpOuterOrderByColumnRefs} →
13016     * {@link #processSetOpOrderByObjectName}) was already admitted by
13017     * slice 21 and is independent of this helper.
13018     *
13019     * <p>Deep-scan alias references inside compound expressions
13020     * ({@code ORDER BY UPPER(<alias>)}) are NOT handled here — the
13021     * parser only retypes the top-level operand to {@code column_alias};
13022     * inside nested expressions the alias may or may not be retyped by
13023     * resolver2 depending on schema heuristics. The slice-9
13024     * {@code orderByNestedAliasReferenceIsHandledSafely} contract is
13025     * preserved: deep alias refs are caught by
13026     * {@link #rejectOrderByAliasReference} with
13027     * {@code ORDER_BY_UNSUPPORTED_SORT_KEY_SHAPE} or by a binding
13028     * failure.
13029     */
13030    private static List<ColumnRef> tryResolveOrderByProjectionAlias(
13031            TExpression sortKey, List<OutputColumn> outputs) {
13032        if (sortKey.getExpressionType() != EExpressionType.simple_object_name_t) {
13033            return null;
13034        }
13035        TObjectName op = sortKey.getObjectOperand();
13036        if (op == null || op.getDbObjectType() != EDbObjectType.column_alias) {
13037            return null;
13038        }
13039        String name = op.toString();
13040        if (name == null || name.isEmpty()) {
13041            return null;
13042        }
13043        String key = name.toLowerCase(Locale.ROOT);
13044        for (OutputColumn oc : outputs) {
13045            String outName = oc.getName();
13046            if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) {
13047                return oc.getSources();
13048            }
13049        }
13050        throw new SemanticIRBuildException(
13051                Diagnostic.error(DiagnosticCode.ORDER_BY_PROJECTION_ALIAS_NOT_SUPPORTED,
13052                "ORDER BY projection alias '" + sortKey
13053                        + "' does not match any output column "
13054                        + "(defensive — parser retyped to column_alias "
13055                        + "but no output by that name)", sortKey));
13056    }
13057
13058    /**
13059     * Slice 68: reject ORDER BY sort keys that are constants but NOT
13060     * positive-integer ordinals. The positive-integer ordinal case is
13061     * admitted separately by {@link #tryResolveOrderByOrdinal} which maps
13062     * the ordinal to the matching output column's sources. This helper
13063     * handles the remaining constant shapes ({@code ORDER BY 'x'},
13064     * {@code ORDER BY 3.14}) — none of which reference an output position
13065     * and so contribute no column dependency.
13066     *
13067     * <p>The set-op outer ORDER BY path
13068     * ({@link #buildSetOpOuterOrderByColumnRefs}) keeps the original
13069     * {@link #rejectOrderByOrdinalOrConstant} helper so ordinals at that
13070     * scope stay rejected (slice 68 lifts only the single-SELECT case;
13071     * slice 72 will lift set-op outer).
13072     */
13073    private static void rejectOrderByNonOrdinalConstant(TExpression sortKey) {
13074        if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) {
13075            return;
13076        }
13077        String txt = sortKey.toString();
13078        boolean looksOrdinal = txt != null && txt.matches("\\d+");
13079        if (looksOrdinal) {
13080            // Admitted by tryResolveOrderByOrdinal; this helper is a no-op
13081            // for positive-integer ordinals.
13082            return;
13083        }
13084        throw new SemanticIRBuildException(
13085                Diagnostic.error(DiagnosticCode.ORDER_BY_CONSTANT_NOT_SUPPORTED,
13086                "ORDER BY constant '" + sortKey + "' is not supported yet "
13087                        + "(constant sort keys add no column dependency)", sortKey));
13088    }
13089
13090    /**
13091     * Reject ORDER BY sort keys that contain a subquery anywhere in the
13092     * subtree. Catches both:
13093     *
13094     * <ul>
13095     *   <li>Scalar subqueries ({@link EExpressionType#subquery_t}) —
13096     *       e.g. {@code ORDER BY (SELECT MAX(salary) FROM employees)}.</li>
13097     *   <li>Predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)},
13098     *       {@code ANY/ALL/SOME (SELECT ...)}) — these don't appear as a
13099     *       {@code subquery_t} expression but carry a non-null
13100     *       {@link TExpression#getSubQuery()}, e.g.
13101     *       {@code ORDER BY CASE WHEN EXISTS (SELECT 1 FROM t WHERE ...)
13102     *       THEN 0 ELSE 1 END}.</li>
13103     * </ul>
13104     *
13105     * <p>The visitor descends into the subquery body, so without an
13106     * explicit reject the inner-scope refs would leak into the outer
13107     * statement's {@code orderByColumnRefs}. The same restriction is
13108     * applied to scalar subqueries in projection (see
13109     * {@link #buildOutputColumns}).
13110     */
13111    private static void rejectOrderByScalarSubquery(TExpression sortKey) {
13112        // Top-level fast path: scalar-subquery message for the common case.
13113        if (sortKey.getExpressionType() == EExpressionType.subquery_t
13114                || sortKey.getSubQuery() != null) {
13115            throw new SemanticIRBuildException(
13116                    Diagnostic.error(DiagnosticCode.ORDER_BY_SUBQUERY_NOT_SUPPORTED,
13117                    "ORDER BY subquery '" + sortKey + "' is not supported yet "
13118                            + "(subqueries in sort keys would leak inner column refs)", sortKey));
13119        }
13120        // Deep scan: any nested expression that owns a subquery (scalar,
13121        // EXISTS, IN (SELECT ...), ANY/ALL/SOME) makes the sort key
13122        // out of scope.
13123        final boolean[] found = {false};
13124        sortKey.acceptChildren(new TParseTreeVisitor() {
13125            @Override
13126            public void preVisit(TExpression e) {
13127                if (found[0]) return;
13128                if (e.getExpressionType() == EExpressionType.subquery_t
13129                        || e.getSubQuery() != null) {
13130                    found[0] = true;
13131                }
13132            }
13133        });
13134        if (found[0]) {
13135            throw new SemanticIRBuildException(
13136                    Diagnostic.error(DiagnosticCode.ORDER_BY_HAS_SUBQUERY_NOT_SUPPORTED,
13137                    "ORDER BY sort key '" + sortKey + "' contains a subquery "
13138                            + "(scalar, EXISTS, IN, or ANY/ALL/SOME); not supported yet", sortKey));
13139        }
13140    }
13141
13142    /**
13143     * Reject ORDER BY sort keys that contain a window function. Window
13144     * functions descend through {@link TFunctionCall#acceptChildren()} so
13145     * their PARTITION BY / ORDER BY column refs would otherwise leak into
13146     * the outer statement's {@code orderByColumnRefs}. Mirrors the
13147     * projection-side {@link #rejectWindowFunctions}, but wired through
13148     * the ORDER BY item-walk instead of the result-column list.
13149     */
13150    private static void rejectOrderByWindowFunction(TExpression sortKey) {
13151        final boolean[] found = {false};
13152        sortKey.acceptChildren(new TParseTreeVisitor() {
13153            @Override
13154            public void preVisit(TFunctionCall fn) {
13155                if (found[0]) return;
13156                if (fn.getWindowDef() != null) found[0] = true;
13157            }
13158        });
13159        if (!found[0] && sortKey.getExpressionType() == EExpressionType.function_t) {
13160            TFunctionCall fn = sortKey.getFunctionCall();
13161            if (fn != null && fn.getWindowDef() != null) found[0] = true;
13162        }
13163        if (found[0]) {
13164            throw new SemanticIRBuildException(
13165                    Diagnostic.error(DiagnosticCode.ORDER_BY_WINDOW_FUNCTION_NOT_SUPPORTED,
13166                    "ORDER BY window function '" + sortKey + "' is not supported yet "
13167                            + "(window OVER (...) refs would leak into orderByColumnRefs)", sortKey));
13168        }
13169    }
13170
13171    /**
13172     * Reject ORDER BY sort keys that are bare constants. Splits the
13173     * message between integer ordinals (which reference the SELECT
13174     * position) and other constants (which add no column dependency). The
13175     * generic no-physical-column-refs check in
13176     * {@link #buildOrderByColumnRefs} catches compound cases like
13177     * {@code ORDER BY (1)} or {@code ORDER BY 1 + 0}.
13178     *
13179     * <p><b>Slice 68:</b> the single-SELECT call site no longer uses this
13180     * helper because positive-integer ordinals now resolve to the matching
13181     * output column's sources (see {@link #tryResolveOrderByOrdinal}).
13182     * This helper remains for {@link #buildSetOpOuterOrderByColumnRefs},
13183     * where ordinal lifting is deferred to slice 72 (set-op outer
13184     * ORDER BY needs output-position references against the set-op output
13185     * row type, not the single-SELECT output column list).
13186     */
13187    private static void rejectOrderByOrdinalOrConstant(TExpression sortKey) {
13188        if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) {
13189            return;
13190        }
13191        String txt = sortKey.toString();
13192        boolean looksOrdinal = txt != null && txt.matches("\\d+");
13193        if (looksOrdinal) {
13194            throw new SemanticIRBuildException(
13195                    Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_NOT_SUPPORTED,
13196                    "ORDER BY ordinal '" + sortKey + "' is not supported yet "
13197                            + "(reference the column or expression directly)", sortKey));
13198        }
13199        throw new SemanticIRBuildException(
13200                Diagnostic.error(DiagnosticCode.ORDER_BY_CONSTANT_NOT_SUPPORTED,
13201                "ORDER BY constant '" + sortKey + "' is not supported yet "
13202                        + "(constant sort keys add no column dependency)", sortKey));
13203    }
13204
13205    /**
13206     * Reject ORDER BY sort keys that contain a projection-alias reference
13207     * NESTED inside a compound expression (e.g.
13208     * {@code ORDER BY UPPER(<alias>)}). The visitor in
13209     * {@link #collectColumnRefs} skips column-alias nodes, so without an
13210     * explicit reject the IR would emit no column refs for them.
13211     *
13212     * <p><b>Slice 69:</b> the top-level bare-alias case (e.g.
13213     * {@code ORDER BY <alias>}) is now consumed by
13214     * {@link #tryResolveOrderByProjectionAlias} BEFORE this helper runs.
13215     * Only the deep-scan branch remains here; the top-level fast-path
13216     * was removed because it became unreachable.
13217     *
13218     * <p>{@link TOrderByItem#doParse} only retypes the top-level operand
13219     * to {@link TObjectName#ttobjColumnAlias} → dbObjectType
13220     * {@link EDbObjectType#column_alias}; inside nested expressions the
13221     * alias may or may not be retyped by resolver2 depending on schema
13222     * heuristics. Slice 9's
13223     * {@code orderByNestedAliasReferenceIsHandledSafely} documents the
13224     * three acceptable outcomes for deep aliases (reject by binding
13225     * failure, reject by this deep scan, or accept with a real column
13226     * dependency captured).
13227     */
13228    private static void rejectOrderByAliasReference(TExpression sortKey) {
13229        // Deep scan: an alias node nested inside an expression
13230        // (e.g. ORDER BY UPPER(x) where x is an alias) would otherwise be
13231        // silently dropped by the column-only visitor. The top-level
13232        // bare-alias case is consumed earlier by
13233        // tryResolveOrderByProjectionAlias (slice 69 lift).
13234        final boolean[] foundAlias = {false};
13235        final String[] aliasName = {null};
13236        sortKey.acceptChildren(new TParseTreeVisitor() {
13237            @Override
13238            public void preVisit(TObjectName node) {
13239                if (foundAlias[0]) return;
13240                if (node.getDbObjectType() == EDbObjectType.column_alias) {
13241                    foundAlias[0] = true;
13242                    aliasName[0] = node.toString();
13243                }
13244            }
13245        });
13246        if (foundAlias[0]) {
13247            throw new SemanticIRBuildException(
13248                    Diagnostic.error(DiagnosticCode.ORDER_BY_UNSUPPORTED_SORT_KEY_SHAPE,
13249                    "ORDER BY sort key '" + sortKey
13250                            + "' contains a projection alias reference '"
13251                            + aliasName[0] + "'; not supported yet "
13252                            + "(reference the underlying column directly)", sortKey));
13253        }
13254    }
13255
13256    /**
13257     * Reject SELECT shapes outside current builder scope. The
13258     * {@code skipCteListCheck} flag is true only for the outer SELECT of a
13259     * WITH-bearing query whose CTEs were already extracted by
13260     * {@link #build}; nested WITH inside a CTE body is still rejected.
13261     */
13262    private static void rejectUnsupportedShape(TSelectSqlStatement select, boolean skipCteListCheck) {
13263        // Slice 12: top-level set-ops and CTE-body set-ops are dispatched
13264        // by build() to buildSetOpProgram BEFORE buildSelectStatement is
13265        // called. This rejection still fires when buildSelectStatement
13266        // is called from a recursive context (FROM-subquery / scalar-body
13267        // extraction) where the inner SELECT happens to be a set-op —
13268        // those nested cases remain out of scope.
13269        if (select.getSetOperatorType() != null && select.getSetOperatorType() != ESetOperatorType.none) {
13270            throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SET_OPERATION_NOT_SUPPORTED_IN_CONTEXT, "set operations (UNION/INTERSECT/MINUS) are not supported in this context yet", select));
13271        }
13272        if (!skipCteListCheck && select.getCteList() != null && select.getCteList().size() > 0) {
13273            throw new SemanticIRBuildException(
13274                    Diagnostic.error(DiagnosticCode.NESTED_WITH_NOT_SUPPORTED,
13275                    "nested WITH/CTE inside a CTE body or subquery is not supported yet", select));
13276        }
13277        // DISTINCT / UNIQUE / ALL handling is done in resolveDistinctFlag()
13278        // (called from buildSelectStatement). Only rejected row-filter shapes
13279        // bubble up as a SemanticIRBuildException; the rest become the
13280        // StatementGraph.distinct flag.
13281        // Slice 6 lifted GROUP BY; slice 10 lifted HAVING. The HAVING
13282        // expression itself (and the per-shape rejections for subqueries
13283        // and window functions inside it) are handled in
13284        // buildHavingColumnRefs so the rejection messages can mention the
13285        // specific shape.
13286        // Slices 70 and 71: all single-SELECT row-limit admit/reject
13287        // decisions live in buildRowLimit(select). rejectUnsupportedShape
13288        // no longer carries any row-limit logic. Set-op outer row-limits
13289        // remain handled by rejectSetOpRowLimit (slice 72 lifts).
13290        // Slice 125 lifted the slice-13 blanket QUALIFY reject. The
13291        // QUALIFY clause is now admitted and its filter refs collected in
13292        // buildQualifyColumnRefs (called from buildSelectStatementImpl,
13293        // which has the outputColumns needed for projection-alias
13294        // resolution). Subqueries in QUALIFY still reject there, reusing
13295        // the (repurposed) QUALIFY_NOT_SUPPORTED code.
13296        // ORDER BY itself is lifted in slice 9; see buildOrderByColumnRefs.
13297        // Vendor- and clause-level guards are checked there so the rejection
13298        // message can mention the specific sub-clause.
13299    }
13300
13301    /**
13302     * Walk the FROM clause: each top-level {@link TJoin} contributes its
13303     * base table; each chained {@link TJoinItem} contributes one more base
13304     * table plus the column refs found in its ON-condition expression.
13305     * Comma-separated FROM lists (multiple top-level TJoins) and
13306     * single-source SELECTs both reduce to the same loop.
13307     */
13308    private static List<RelationSource> buildRelations(TSelectSqlStatement select,
13309                                                       NameBindingProvider provider,
13310                                                       List<ColumnRef> joinRefsOut,
13311                                                       boolean allowFromSubqueries) {
13312        return buildRelations(select, provider, joinRefsOut, allowFromSubqueries,
13313                /*allowJoinOnPredicateSubqueries=*/ false,
13314                /*stmtsForExtraction=*/ null,
13315                /*lineageForExtraction=*/ null,
13316                /*cteMapForExtraction=*/ null);
13317    }
13318
13319    /**
13320     * Slice-23/24 overload of {@link #buildRelations}. When
13321     * {@code allowJoinOnPredicateSubqueries} is {@code true} (outer-SELECT
13322     * call site only), uncorrelated EXISTS subqueries inside JOIN ON
13323     * predicates are extracted as their own {@code <predicate_subquery_<i>>}
13324     * StatementGraphs appended to {@code stmtsForExtraction}. The extracted
13325     * subtrees are then skipped by the JOIN-ON window-function rejecter and
13326     * the JOIN-ON ref collector so their inner refs do not leak into outer
13327     * {@code joinColumnRefs}.
13328     *
13329     * <p>Slice 24: {@code cteMapForExtraction} carries outer's
13330     * CTE-name-to-statement-index map so the extracted predicate body can
13331     * emit STATEMENT_OUTPUT → STATEMENT_OUTPUT edges into outer-visible CTE
13332     * bodies via {@link #emitLineageForStatement}. Non-outer call sites
13333     * (where {@code allowJoinOnPredicateSubqueries=false}) pass {@code null}.
13334     */
13335    private static List<RelationSource> buildRelations(TSelectSqlStatement select,
13336                                                       NameBindingProvider provider,
13337                                                       List<ColumnRef> joinRefsOut,
13338                                                       boolean allowFromSubqueries,
13339                                                       boolean allowJoinOnPredicateSubqueries,
13340                                                       List<StatementGraph> stmtsForExtraction,
13341                                                       List<LineageEdge> lineageForExtraction,
13342                                                       Map<String, Integer> cteMapForExtraction) {
13343        if (select.joins == null || select.joins.size() == 0) {
13344            throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SELECT_NO_FROM_SOURCE, "SELECT must have at least one FROM source", select));
13345        }
13346        // Slice 62: comma-separated FROM lists (e.g. `FROM a, b`)
13347        // parse as multiple top-level TJoin elements. We admit them at
13348        // outer / CTE-body / FROM-subquery-body call sites (where
13349        // {@code allowFromSubqueries=true}) and build them as an ordered
13350        // cross-product relation graph with empty {@code joinColumnRefs}
13351        // (WHERE-side predicates feed {@code filterColumnRefs} as
13352        // usual). Synthetic body contexts (scalar / set-op-branch /
13353        // set-op-CTE / predicate) call this method with
13354        // {@code allowFromSubqueries=false} and stay rejected — that
13355        // is exactly the discriminator we need. Predicate bodies also
13356        // hit an earlier shape-specific reject inside
13357        // {@link #preflightExistsInnerShape}.
13358        if (!allowFromSubqueries && select.joins.size() > 1) {
13359            throw new SemanticIRBuildException(
13360                    Diagnostic.error(DiagnosticCode.COMMA_FROM_IN_BODY_NOT_SUPPORTED,
13361                    "comma-separated FROM list (implicit cross join) is not supported "
13362                            + "inside scalar / set-op-branch / set-op-CTE / predicate body "
13363                            + "contexts yet; use explicit JOIN ... ON", select));
13364        }
13365        // Slice 63: explicit CROSS JOIN admits at outer / CTE-body /
13366        // FROM-subquery-body call sites (allowFromSubqueries=true) but
13367        // stays rejected inside synthetic body contexts (scalar /
13368        // set-op-branch / set-op-CTE / predicate) because the body's
13369        // shape contract (single column for scalar; column-count parity
13370        // for set-op branches; constant or single column-ref for
13371        // predicates) cannot host a cross-product relation graph
13372        // safely. Predicate bodies also hit an earlier shape-specific
13373        // reject inside {@link #preflightExistsInnerShape} so the
13374        // user-visible diagnostic mentions EXISTS / IN-SELECT context.
13375        if (!allowFromSubqueries) {
13376            for (TJoin join : select.joins) {
13377                TJoinItemList items = join.getJoinItems();
13378                if (items == null) continue;
13379                for (int i = 0; i < items.size(); i++) {
13380                    TJoinItem item = items.getJoinItem(i);
13381                    if (item == null) continue;
13382                    if (item.getJoinType() == EJoinType.cross) {
13383                        throw new SemanticIRBuildException(
13384                                Diagnostic.error(DiagnosticCode.CROSS_JOIN_IN_BODY_NOT_SUPPORTED,
13385                                "CROSS JOIN is not supported inside scalar / "
13386                                        + "set-op-branch / set-op-CTE / predicate "
13387                                        + "body contexts yet; rewrite as INNER "
13388                                        + "JOIN ... ON in the body", item));
13389                    }
13390                    // Slice 64: USING admitted at outer / CTE-body /
13391                    // FROM-subquery-body call sites but rejected inside
13392                    // synthetic body contexts. The body's shape contract
13393                    // (single column for scalar, column-count parity for
13394                    // set-op branches, constant/column-ref for predicate
13395                    // bodies) cannot host the merged-key semantics safely.
13396                    if (item.getUsingColumns() != null
13397                            && item.getUsingColumns().size() > 0) {
13398                        throw new SemanticIRBuildException(
13399                                Diagnostic.error(DiagnosticCode.USING_IN_BODY_NOT_SUPPORTED,
13400                                "JOIN ... USING (...) is not supported inside "
13401                                        + "scalar / set-op-branch / set-op-CTE / "
13402                                        + "predicate body contexts yet; rewrite "
13403                                        + "as JOIN ... ON in the body", item));
13404                    }
13405                    // Slice 66: NATURAL JOIN inside synthetic body
13406                    // contexts is rejected with a tuned diagnostic.
13407                    // Predicate bodies hit preflightExistsInnerShape
13408                    // first which emits an EXISTS-tuned message; this
13409                    // reject fires for scalar / set-op-branch /
13410                    // set-op-CTE bodies (and as defense-in-depth for
13411                    // predicate bodies if the preflight didn't catch
13412                    // a vendor-specific variant).
13413                    if (isNaturalJoinType(item.getJoinType())) {
13414                        throw new SemanticIRBuildException(
13415                                Diagnostic.error(DiagnosticCode.NATURAL_IN_BODY_NOT_SUPPORTED,
13416                                "NATURAL JOIN is not supported inside "
13417                                        + "scalar / set-op-branch / set-op-CTE / "
13418                                        + "predicate body contexts yet; rewrite "
13419                                        + "as JOIN ... ON in the body", item));
13420                    }
13421                }
13422            }
13423        }
13424        List<RelationSource> relations = new ArrayList<>();
13425        // Join-graph COLUMN_BINDING_NON_EXACT degrade (GSP R6) for the JOIN ON
13426        // path. An unqualified ON-predicate operand that cannot be bound to
13427        // exactly one side (e.g. {@code JOIN cities ON city = name}) is a
13428        // which-side ambiguity, not a genuine error, WHEN the FROM is
13429        // catalog-less for at least one endpoint — exactly the case the
13430        // post-buildRelations SELECT/WHERE anchor already tolerates. The ON
13431        // refs are collected DURING this method (before that anchor is
13432        // installed), so compute the catalog-less eligibility here and hand the
13433        // ON collector an anchored provider. Every {@code collectJoinOnRefs}
13434        // call runs inside the join-item loop, where ≥2 endpoints are already
13435        // bound (driver + right), so the structural {@code relations.size() >= 2}
13436        // gate is satisfied by construction; only the catalog-less check
13437        // remains. A fully-cataloged FROM stays fatal (the catalog adjudicates).
13438        //
13439        // Whole-FROM scope is intentional and mirrors the post-buildRelations
13440        // SELECT/WHERE anchor (which gates on the same
13441        // {@code !fromRelationColumnsFullyKnown(select, provider)} for the whole
13442        // statement): a partially-cataloged FROM (one endpoint catalog-less)
13443        // degrades an unqualified ON miss because that operand MIGHT live on the
13444        // unknown side — exactly as an unqualified SELECT/WHERE column does. A
13445        // genuine typo over a fully-cataloged FROM still stays fatal.
13446        boolean joinGraphAnchorEligible = !fromRelationColumnsFullyKnown(select, provider);
13447        // Slice 179 (R4): stable FROM-order instance ordinal, assigned in the
13448        // SAME driver-then-join-items traversal order as buildJoinGraph, so
13449        // RelationSource.instanceId and JoinEndpoint.instanceId align by
13450        // construction (not by alias). Held in a one-element array so the
13451        // recursive appendJoinRelations helper can advance it across a nested
13452        // parenthesized <joined_table> sub-tree.
13453        int[] relInstanceId = {0};
13454        for (TJoin join : select.joins) {
13455            // Slice 66: per top-level TJoin LeftOutputState. Seeded with the
13456            // top-left table's catalog; updated as JoinItems walk left-to-right.
13457            // NATURAL JoinItems consume this state (catalog intersection) and
13458            // update it. Reset between top-level TJoins so comma-FROM groups
13459            // stay independent.
13460            LeftOutputState leftState = new LeftOutputState();
13461            appendJoinRelations(join, provider, allowFromSubqueries,
13462                    allowJoinOnPredicateSubqueries, stmtsForExtraction,
13463                    lineageForExtraction, cteMapForExtraction,
13464                    relations, joinRefsOut, relInstanceId, leftState,
13465                    joinGraphAnchorEligible);
13466        }
13467        rejectDuplicateAliases(relations);
13468        return relations;
13469    }
13470
13471    /**
13472     * Append the relations of one FROM-clause {@link TJoin} (its driver operand
13473     * plus each join item) to {@code relations}, populate {@code joinRefsOut}
13474     * with the join-condition column refs, advance {@code relInstanceId[0]} in
13475     * FROM order, and update {@code leftState} (the running row type used for
13476     * NATURAL key inference).
13477     *
13478     * <p>Recurses into a parenthesized / nested {@code <joined_table>} operand —
13479     * a {@link TJoin} reached via {@link TJoin#getJoin()} (driver) or
13480     * {@link TJoinItem#getJoin()} (right operand) when the corresponding
13481     * {@code getTable()} is null. So {@code FROM a JOIN (b JOIN c ON …) ON …}
13482     * collects all three relations, emits the inner join's refs, then attaches
13483     * the outer join's ON over the accumulated endpoints. The same
13484     * {@code leftState} is threaded through the recursion — both
13485     * {@code seedLeftOutput} and {@code appendRightToLeftOutput} only ADD
13486     * columns — so a NATURAL join that follows still sees the accumulated row
13487     * type. {@code FROM_SOURCE_NO_TABLE} / {@code JOIN_ITEM_NO_TABLE} stay fatal
13488     * only for an operand that is genuinely neither a table nor a nested join.
13489     */
13490    private static void appendJoinRelations(
13491            TJoin join,
13492            NameBindingProvider provider,
13493            boolean allowFromSubqueries,
13494            boolean allowJoinOnPredicateSubqueries,
13495            List<StatementGraph> stmtsForExtraction,
13496            List<LineageEdge> lineageForExtraction,
13497            Map<String, Integer> cteMapForExtraction,
13498            List<RelationSource> relations,
13499            List<ColumnRef> joinRefsOut,
13500            int[] relInstanceId,
13501            LeftOutputState leftState,
13502            boolean joinGraphAnchorEligible) {
13503        // ---- driver operand ----
13504        TTable leftTable = join.getTable();
13505        if (leftTable != null) {
13506            relations.add(buildRelation(leftTable, provider, allowFromSubqueries, relInstanceId[0]++));
13507            seedLeftOutput(leftState, leftTable, provider);
13508        } else if (join.getJoin() != null) {
13509            // Parenthesized joined-table as the driver operand — recurse,
13510            // threading the same leftState so it accumulates the sub-tree's
13511            // columns for any NATURAL join that follows at this level.
13512            appendJoinRelations(join.getJoin(), provider, allowFromSubqueries,
13513                    allowJoinOnPredicateSubqueries, stmtsForExtraction,
13514                    lineageForExtraction, cteMapForExtraction,
13515                    relations, joinRefsOut, relInstanceId, leftState,
13516                    joinGraphAnchorEligible);
13517        } else {
13518            throw new SemanticIRBuildException(Diagnostic.error(
13519                    DiagnosticCode.FROM_SOURCE_NO_TABLE, "FROM source has no table", join));
13520        }
13521
13522        TJoinItemList items = join.getJoinItems();
13523        if (items == null) return;
13524        for (int i = 0; i < items.size(); i++) {
13525            TJoinItem item = items.getJoinItem(i);
13526            rejectUnsupportedJoinShape(item);
13527            TTable rightTable = item.getTable();
13528            if (rightTable == null) {
13529                if (item.getJoin() != null) {
13530                    // NATURAL / USING merged-key inference needs a single right
13531                    // TTable to key against; over a parenthesized joined-table
13532                    // operand (a combined row type) it is not supported yet.
13533                    // Fail explicitly rather than silently dropping the merged-key
13534                    // refs in buildRelations while buildJoinGraph still emits a
13535                    // NATURAL/USING entity (codex P2). Plain ON / CROSS over a
13536                    // nested operand ARE supported.
13537                    if (isNaturalJoinType(item.getJoinType())
13538                            || (item.getUsingColumns() != null
13539                                    && item.getUsingColumns().size() > 0)) {
13540                        throw new SemanticIRBuildException(Diagnostic.error(
13541                                DiagnosticCode.NESTED_JOIN_MERGED_KEY_NOT_SUPPORTED,
13542                                "NATURAL / USING join over a parenthesized joined-table "
13543                                        + "operand is not supported yet; rewrite with an "
13544                                        + "explicit ON condition", item));
13545                    }
13546                    // Parenthesized joined-table as the right operand — recurse to
13547                    // collect its relations + inner join refs, then attach this
13548                    // outer join's ON over the accumulated endpoints. Use a FRESH
13549                    // row-state for the sub-tree so its inner NATURAL inference
13550                    // sees only its own columns (codex P1), then merge that row
13551                    // type into this level's leftState so a NATURAL join that
13552                    // FOLLOWS at this level still sees the accumulated columns.
13553                    LeftOutputState subState = new LeftOutputState();
13554                    appendJoinRelations(item.getJoin(), provider, allowFromSubqueries,
13555                            allowJoinOnPredicateSubqueries, stmtsForExtraction,
13556                            lineageForExtraction, cteMapForExtraction,
13557                            relations, joinRefsOut, relInstanceId, subState,
13558                            joinGraphAnchorEligible);
13559                    mergeLeftOutputState(leftState, subState);
13560                    collectJoinOnRefs(item.getOnCondition(), provider,
13561                            allowJoinOnPredicateSubqueries, stmtsForExtraction,
13562                            lineageForExtraction, cteMapForExtraction, joinRefsOut,
13563                            joinGraphAnchorEligible);
13564                    continue;
13565                }
13566                throw new SemanticIRBuildException(Diagnostic.error(
13567                        DiagnosticCode.JOIN_ITEM_NO_TABLE, "JOIN item has no table", item));
13568            }
13569            // Slice 17/18: subqueries on a JOIN side are extracted as their own
13570            // statements by extractFromSubqueriesAsStatements before
13571            // buildRelations runs (when allowFromSubqueries=true). Scalar /
13572            // set-op-branch / set-op-CTE-body builds pass allowFromSubqueries=
13573            // false; buildRelation rejects there.
13574            relations.add(buildRelation(rightTable, provider, allowFromSubqueries, relInstanceId[0]++));
13575            // Slice 66: NATURAL admission via catalog inference. When catalog
13576            // metadata is missing on either side we DEGRADE: skip the merged
13577            // refs, record a NATURAL_CATALOG_REQUIRED warning, and append the
13578            // right's columns to the running state.
13579            if (isNaturalJoinType(item.getJoinType())) {
13580                NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider);
13581                if (r.kind != NaturalKeyResult.Kind.SUCCESS) {
13582                    recordNaturalDegradeWarning(r, item);
13583                    appendRightToLeftOutput(leftState, rightTable, provider);
13584                    continue;
13585                }
13586                emitMergedJoinRefs(JoinKind.NATURAL, r.keys, join, items, i,
13587                        rightTable, provider, joinRefsOut);
13588                mergeRightIntoLeftOutput(leftState, rightTable, provider, r.keys);
13589                continue;
13590            }
13591            // Slice 64: USING and ON are mutually exclusive (enforced by
13592            // rejectUnsupportedJoinShape). Populate per-key joinColumnRefs from
13593            // USING here, then skip the onCond branch.
13594            TObjectNameList usingCols = item.getUsingColumns();
13595            if (usingCols != null && usingCols.size() > 0) {
13596                populateUsingJoinRefs(join, items, i, rightTable,
13597                        usingCols, provider, joinRefsOut);
13598                // Slice 66: USING JoinItems also merge right into the
13599                // LeftOutputState so subsequent NATURAL JoinItems see the
13600                // accumulated row type (merged USING keys at their slots).
13601                List<String> usingKeyNames = new ArrayList<>(usingCols.size());
13602                for (int k = 0; k < usingCols.size(); k++) {
13603                    TObjectName usingKey = usingCols.getObjectName(k);
13604                    if (usingKey == null) continue;
13605                    String keyName = usingKey.getColumnNameOnly();
13606                    if (keyName != null && !keyName.isEmpty()) {
13607                        usingKeyNames.add(keyName);
13608                    }
13609                }
13610                mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames);
13611                continue;
13612            }
13613            // Slice 66: ON / CROSS JoinItem — append right's catalog columns to
13614            // the running LeftOutputState; NATURAL JoinItems that follow see it.
13615            appendRightToLeftOutput(leftState, rightTable, provider);
13616            collectJoinOnRefs(item.getOnCondition(), provider,
13617                    allowJoinOnPredicateSubqueries, stmtsForExtraction,
13618                    lineageForExtraction, cteMapForExtraction, joinRefsOut,
13619                    joinGraphAnchorEligible);
13620        }
13621    }
13622
13623    /**
13624     * Collect the column refs of a JOIN item's {@code ON} expression into
13625     * {@code joinRefsOut}, applying the same predicate-subquery extraction and
13626     * window-function / subquery rejections as the inline ON path. No-op when
13627     * {@code onCond} is null (CROSS / APPLY items carry no ON). Shared by the
13628     * flat and nested ({@link #appendJoinRelations}) traversal paths.
13629     */
13630    private static void collectJoinOnRefs(TExpression onCond, NameBindingProvider provider,
13631            boolean allowJoinOnPredicateSubqueries,
13632            List<StatementGraph> stmtsForExtraction,
13633            List<LineageEdge> lineageForExtraction,
13634            Map<String, Integer> cteMapForExtraction,
13635            List<ColumnRef> joinRefsOut,
13636            boolean joinGraphAnchorEligible) {
13637        if (onCond == null) {
13638            return;
13639        }
13640        // Join-graph COLUMN_BINDING_NON_EXACT degrade (GSP R6): the anchor wraps
13641        // ONLY the direct ON-condition ref binding below. Predicate-subquery
13642        // EXTRACTION keeps the un-anchored {@code provider} — each extracted
13643        // body is built via a nested entry that resets the anchor anyway, and
13644        // its inner binding does not enjoy the ON path's ≥2-bound-endpoint
13645        // invariant. The anchor only changes rejectNonExactBindings: an
13646        // unqualified ON operand that can't be placed on one side (a which-side
13647        // ambiguity over a catalog-less join) degrades to a warning instead of
13648        // aborting the whole analysis.
13649        NameBindingProvider refProvider = joinGraphAnchorEligible
13650                ? provider.withJoinStructureAnchor(true) : provider;
13651        if (allowJoinOnPredicateSubqueries) {
13652            // Slice 23/24/25: outer-SELECT JOIN ON path — extract uncorrelated
13653            // predicate-subquery wrappers as their own <predicate_subquery_<i>>
13654            // StatementGraphs and skip their subtrees during the window guard and
13655            // ref collection. The leak guard rejects everything that is NOT an
13656            // extracted wrapper.
13657            Set<TExpression> extractedRoots =
13658                    extractUncorrelatedPredicateSubqueriesFromJoinOn(onCond, provider,
13659                            stmtsForExtraction, lineageForExtraction, cteMapForExtraction);
13660            rejectAnyRemainingSubqueriesInJoinOn(onCond, extractedRoots);
13661            rejectWindowFunctionInScopeSkipping(onCond, "JOIN ON condition", extractedRoots);
13662            joinRefsOut.addAll(collectColumnRefsSkipping(onCond, refProvider, extractedRoots));
13663        } else {
13664            // Slice 13/17: reject window functions and predicate subqueries in
13665            // JOIN ON before collectColumnRefs descends (every non-outer call
13666            // site: FROM-subquery body, CTE body, scalar body, set-op branch).
13667            rejectWindowFunctionInScope(onCond, "JOIN ON condition");
13668            rejectSubqueriesInJoinOn(onCond);
13669            joinRefsOut.addAll(collectColumnRefs(onCond, refProvider));
13670        }
13671    }
13672
13673    /**
13674     * Slice 17: reject predicate subqueries (EXISTS, IN-SELECT,
13675     * scalar-subquery comparisons, etc.) inside a JOIN ON expression.
13676     * Without this guard, slice-17's expanded JOIN surface (relation
13677     * subqueries on either side) would let predicate subqueries slip
13678     * past {@code collectColumnRefs} and produce incomplete IR. Applies
13679     * to every {@code buildRelations} call site; the slice-11
13680     * {@link #rejectSubqueriesInScalarBodyClauses} and slice-17
13681     * {@link #rejectSubqueriesInFromSubqueryBodyClauses} fire BEFORE
13682     * the recursive {@code buildSelectStatement}, so their context-
13683     * specific messages preempt this one.
13684     */
13685    private static void rejectSubqueriesInJoinOn(TExpression onCond) {
13686        if (containsAnySubqueryExpression(onCond)) {
13687            throw new SemanticIRBuildException(
13688                    Diagnostic.error(DiagnosticCode.JOIN_ON_TOP_LEVEL_SUBQUERY_NOT_SUPPORTED,
13689                    "subquery in a top-level JOIN ON predicate is not supported yet", onCond));
13690        }
13691    }
13692
13693    // ====================================================================
13694    // Slice 23: uncorrelated EXISTS subqueries in top-level outer-SELECT
13695    // JOIN ON.
13696    //
13697    // Approach: walk the JOIN-ON expression looking for `exists_t` nodes
13698    // (and `not_t(exists_t(...))` for NOT EXISTS); validate each as
13699    // uncorrelated with a constant-only inner projection; build the inner
13700    // SELECT as its own `<predicate_subquery_<i>>` StatementGraph; record
13701    // the extracted `exists_t` root in a Set so the JOIN-ON window guard
13702    // and ref collector can skip its subtree. Predicate bodies are
13703    // unreachable from outer (no relation, no lineage edge) so they
13704    // contribute zero canonical edges — matching dlineage's behaviour for
13705    // EXISTS-in-JOIN-ON shapes that project a constant (the inner-shape
13706    // preflight enforces constant-only projection so this invariant
13707    // holds).
13708    //
13709    // Process: codex round 1 + round 2 plan reviews; v3 plan locked.
13710    // See roadmap §14.25 (slice-23 entry).
13711    // ====================================================================
13712
13713    /**
13714     * True iff {@code e} is the root of an EXISTS predicate that slice 23
13715     * may extract: either an {@code exists_t} expression, or a
13716     * {@code logical_not_t} whose <b>right</b> operand is {@code exists_t}.
13717     * NOT EXISTS unwraps to its inner {@code exists_t}.
13718     *
13719     * <p>Note: the GSP parser puts the operand of {@code logical_not_t} in
13720     * {@link TExpression#getRightOperand()}, not {@code getLeftOperand()}
13721     * (verified across Oracle / PostgreSQL / MSSQL / MySQL / BigQuery).
13722     * The root fast-path for {@code NOT EXISTS} is therefore "dead" in the
13723     * sense that the descendant walker on the wrapping {@code logical_not_t}
13724     * already visits the child {@code exists_t} — we still keep it here so
13725     * the symmetry between root EXISTS and root NOT EXISTS is explicit.
13726     *
13727     * <p>Slice 25 (kept as a dedicated helper for slice-23/24 callers and
13728     * for clarity): the slice-25 generalisation lives in
13729     * {@link #unwrapToInnerExtractableSubquery(TExpression)} which
13730     * recognises four wrapper shapes — including the two EXISTS shapes
13731     * here.
13732     */
13733    private static boolean isExistsRoot(TExpression e) {
13734        if (e == null) return false;
13735        if (e.getExpressionType() == EExpressionType.exists_t) return true;
13736        if (e.getExpressionType() == EExpressionType.logical_not_t
13737                && e.getRightOperand() != null
13738                && e.getRightOperand().getExpressionType() == EExpressionType.exists_t) {
13739            return true;
13740        }
13741        return false;
13742    }
13743
13744    /** Return the actual {@code exists_t} node — unwrap a {@code logical_not_t} parent if present. */
13745    private static TExpression unwrapExistsRoot(TExpression e) {
13746        if (e.getExpressionType() == EExpressionType.exists_t) return e;
13747        return e.getRightOperand();
13748    }
13749
13750    /**
13751     * Slice 25 / Slice 26: pure shape-recogniser for the predicate-
13752     * subquery wrappers admitted in TOP-LEVEL JOIN ON. Returns the inner
13753     * extractable node ({@code subquery_t} or {@code exists_t}) for the
13754     * wrapper shapes; null otherwise. Pure — performs NO validation and
13755     * throws NO exceptions.
13756     *
13757     * <p>Recognised wrappers:
13758     * <ul>
13759     *   <li>{@code exists_t} (slice-23 EXISTS) — returns {@code e}.</li>
13760     *   <li>{@code logical_not_t} with rightOperand {@code exists_t}
13761     *       (slice-23 NOT EXISTS) — returns the inner exists_t.</li>
13762     *   <li>{@code in_t} with rightOperand {@code subquery_t}
13763     *       (slice 25 IN-SELECT / NOT IN-SELECT) — returns the
13764     *       rightOperand. LHS-subquery {@code in_t} returns null
13765     *       (slice 26 boundary: dlineage's {@code fdr clause="on"}
13766     *       sources omit the outer column for IN-LHS, so admitting on
13767     *       the IR side would manufacture canonical-model divergence).</li>
13768     *   <li>{@code simple_comparison_t} (slice 25 + slice 26 scalar
13769     *       comparison) — returns the operand on whichever side is a
13770     *       {@code subquery_t}. RHS-subquery (slice 25) and LHS-
13771     *       subquery (slice 26) are both admitted; both-sides subquery
13772     *       returns null and falls through to {@link #findSubqueryOnLeftWrapper}'s
13773     *       new "both subqueries" rejection branch.</li>
13774     *   <li>{@code group_comparison_t} with rightOperand
13775     *       {@code subquery_t} AND non-null {@code getQuantifier()}
13776     *       (slice 25 ANY/ALL/SOME) — returns the rightOperand.
13777     *       LHS-subquery {@code group_comparison_t} returns null
13778     *       (slice 26 boundary: borderline grammar; not probed).</li>
13779     * </ul>
13780     *
13781     * <p>For null returns, the wrapper either is not a recognised shape
13782     * (falls through to the slice-23 generic remaining-subquery rejection
13783     * in {@link #rejectAnyRemainingSubqueriesInJoinOn}) OR has the right
13784     * outer shape but the LHS / RHS positioning is unsupported (subquery
13785     * on left side of IN/quantifier, both sides subquery for cmp, tuple
13786     * LHS / RHS, expression LHS / RHS). The walker validates the
13787     * non-subquery side via {@link #isAdmittedOuterLhsShape} or
13788     * {@link #isAdmittedOuterRhsShape} and throws a slice-25 / slice-26
13789     * tuned message before calling this helper for extraction.
13790     *
13791     * <p>The slice-23/24 EXISTS callers ({@code isExistsRoot} and
13792     * {@code unwrapExistsRoot}) remain in place — both are simple
13793     * boolean / unwrap helpers; this method consolidates the slice-25 /
13794     * slice-26 shape decision in one place.
13795     */
13796    private static TExpression unwrapToInnerExtractableSubquery(TExpression e) {
13797        if (e == null) return null;
13798        EExpressionType t = e.getExpressionType();
13799        if (t == EExpressionType.exists_t) return e;
13800        if (t == EExpressionType.logical_not_t
13801                && e.getRightOperand() != null
13802                && e.getRightOperand().getExpressionType() == EExpressionType.exists_t) {
13803            return e.getRightOperand();
13804        }
13805        TExpression l = e.getLeftOperand();
13806        TExpression r = e.getRightOperand();
13807        boolean lhsIsSubq = l != null && l.getExpressionType() == EExpressionType.subquery_t;
13808        boolean rhsIsSubq = r != null && r.getExpressionType() == EExpressionType.subquery_t;
13809        if (t == EExpressionType.in_t) {
13810            return rhsIsSubq ? r : null;
13811        }
13812        if (t == EExpressionType.simple_comparison_t) {
13813            // Slice 26: admit subquery on either single side. Both sides
13814            // → null (rejected via findSubqueryOnLeftWrapper's new
13815            // "both subqueries" branch — see isSubqueryOnLeftOfWrapper).
13816            if (lhsIsSubq && rhsIsSubq) return null;
13817            if (rhsIsSubq) return r;
13818            if (lhsIsSubq) return l;
13819            return null;
13820        }
13821        if (t == EExpressionType.group_comparison_t
13822                && e.getQuantifier() != null) {
13823            return rhsIsSubq ? r : null;
13824        }
13825        return null;
13826    }
13827
13828    /**
13829     * Slice 25: admitted LHS shapes for non-EXISTS predicate-subquery
13830     * wrappers ({@code in_t} / {@code simple_comparison_t} /
13831     * {@code group_comparison_t}) when the subquery is on the RHS.
13832     *
13833     * <p>Admits ONLY {@link EExpressionType#simple_object_name_t} —
13834     * a single column reference, qualified or unqualified. Rejects:
13835     * tuple expressions ({@code (a, b) IN (...)}), parenthesized
13836     * wrapping ({@code (e.col) IN (...)}), arithmetic
13837     * ({@code e.col + 1 IN (...)}), function calls
13838     * ({@code UPPER(e.col) IN (...)}), scalar subqueries on LHS, and
13839     * any other non-column shape.
13840     *
13841     * <p>Slice-25 boundary; future slice may admit parenthesized
13842     * column refs (slice 26+).
13843     */
13844    private static boolean isAdmittedOuterLhsShape(TExpression lhs) {
13845        return lhs != null
13846                && lhs.getExpressionType() == EExpressionType.simple_object_name_t;
13847    }
13848
13849    /**
13850     * Slice 26: admitted RHS shapes for {@code simple_comparison_t}
13851     * with subquery on the LHS. Mirror of
13852     * {@link #isAdmittedOuterLhsShape}: admits ONLY
13853     * {@link EExpressionType#simple_object_name_t} — a single column
13854     * reference, qualified or unqualified. Rejects tuple, parenthesized,
13855     * arithmetic, function-call, and subquery (the "both subqueries"
13856     * shape is rejected separately via
13857     * {@link #isSubqueryOnLeftOfWrapper}'s new
13858     * {@code simple_comparison_t} both-sides branch).
13859     *
13860     * <p>Slice-26 boundary: only {@code simple_comparison_t} reaches
13861     * this helper (the walker dispatches on which side is the
13862     * subquery). {@code in_t} / {@code group_comparison_t} with LHS
13863     * subquery return null from
13864     * {@link #unwrapToInnerExtractableSubquery} so they never reach
13865     * here.
13866     */
13867    private static boolean isAdmittedOuterRhsShape(TExpression rhs) {
13868        return rhs != null
13869                && rhs.getExpressionType() == EExpressionType.simple_object_name_t;
13870    }
13871
13872    /**
13873     * Slice 25 (impl-review M1-fix): true iff {@code e} is a
13874     * {@code logical_not_t} wrapping a slice-25 IN / scalar-cmp /
13875     * ANY-ALL-SOME wrapper (i.e. NOT applied to an admitted slice-25
13876     * shape that ISN'T an EXISTS). The descendant walker would
13877     * otherwise traverse INTO this {@code logical_not_t} and find the
13878     * child wrapper, accidentally admitting
13879     * {@code NOT (e.col IN (SELECT ...))} which is NOT a slice-25
13880     * recognised shape ({@code unwrapToInnerExtractableSubquery}
13881     * matches {@code logical_not_t} only when the inner is
13882     * {@code exists_t}).
13883     *
13884     * <p>This helper is consulted by the extraction walker BEFORE it
13885     * descends into the children of a {@code logical_not_t}, so the
13886     * rejection happens at the wrapper level with a tuned message
13887     * pointing at the slice-25 boundary.
13888     */
13889    private static boolean isLogicalNotOverNonExistsWrapper(TExpression e) {
13890        if (e == null) return false;
13891        if (e.getExpressionType() != EExpressionType.logical_not_t) return false;
13892        TExpression r = e.getRightOperand();
13893        if (r == null) return false;
13894        // Strip parenthesis_t chain. The Oracle parser wraps
13895        // `NOT (e.col IN (SELECT...))` as
13896        // logical_not_t → parenthesis_t → in_t, so the immediate
13897        // right child is parenthesis_t. Descend through any chain of
13898        // parens to find the actual subject. Note:
13899        // {@code parenthesis_t} stores its child on
13900        // {@link TExpression#getLeftOperand()} (mirroring
13901        // {@link #isConstantExpression}'s descent).
13902        TExpression subject = r;
13903        while (subject != null
13904                && subject.getExpressionType() == EExpressionType.parenthesis_t) {
13905            subject = subject.getLeftOperand();
13906        }
13907        if (subject == null) return false;
13908        if (subject.getExpressionType() == EExpressionType.exists_t) return false;
13909        // Either an in_t / simple_comparison_t / group_comparison_t
13910        // with subquery RHS, or any of those types directly.
13911        return unwrapToInnerExtractableSubquery(subject) != null;
13912    }
13913
13914    /**
13915     * Slice 25 / Slice 26: build a tuned outer-shape rejection message
13916     * for a non-EXISTS predicate-subquery wrapper. Called from the
13917     * extraction walker when {@link #unwrapToInnerExtractableSubquery}
13918     * returns non-null for an in_t / simple_comparison_t /
13919     * group_comparison_t but the non-subquery side is not admitted by
13920     * {@link #isAdmittedOuterLhsShape} (slice 25 — subquery on RHS) or
13921     * {@link #isAdmittedOuterRhsShape} (slice 26 — subquery on LHS).
13922     *
13923     * <p>{@code isLhsSubquery} indicates which side of the wrapper
13924     * carries the subquery: {@code true} = subquery on LHS (slice 26
13925     * path; we validate the wrapper's RHS), {@code false} = subquery
13926     * on RHS (slice 25 path; we validate the wrapper's LHS).
13927     *
13928     * <p>Uses the slice-25 outer-shape error prefix
13929     * "predicate subquery in JOIN ON:" so end users distinguish
13930     * outer-shape failures from the slice-23/24 inner-shape failures
13931     * (which keep the "EXISTS in JOIN ON:" prefix).
13932     */
13933    private static String buildOuterShapeRejectionMessage(TExpression wrapper,
13934                                                          boolean isLhsSubquery,
13935                                                          PredicateClauseContext ctx) {
13936        EExpressionType t = wrapper.getExpressionType();
13937        String shapeLabel;
13938        if (t == EExpressionType.in_t) shapeLabel = "IN";
13939        else if (t == EExpressionType.simple_comparison_t) shapeLabel = "comparison";
13940        else if (t == EExpressionType.group_comparison_t) shapeLabel = "ANY/ALL/SOME";
13941        else shapeLabel = String.valueOf(t);
13942        // Validate the side that does NOT carry the subquery.
13943        TExpression nonSubquerySide = isLhsSubquery
13944                ? wrapper.getRightOperand()
13945                : wrapper.getLeftOperand();
13946        String sideLabel = isLhsSubquery ? "RHS" : "LHS";
13947        EExpressionType sideType = nonSubquerySide == null
13948                ? null : nonSubquerySide.getExpressionType();
13949        String detail;
13950        if (nonSubquerySide == null) {
13951            detail = "missing " + sideLabel;
13952        } else if (sideType == EExpressionType.list_t) {
13953            detail = "tuple " + sideLabel;
13954        } else if (sideType == EExpressionType.parenthesis_t) {
13955            detail = "parenthesized " + sideLabel;
13956        } else if (sideType == EExpressionType.simple_object_name_t) {
13957            // Defensive: should not be reached when the corresponding
13958            // admitted-shape helper returns true.
13959            detail = "unexpected admitted " + sideLabel + " shape";
13960        } else {
13961            detail = "expression " + sideLabel + " (" + sideType + ")";
13962        }
13963        String boundary = isLhsSubquery ? "slice 26 boundary" : "slice 25 boundary";
13964        return "predicate subquery in " + ctx.clauseLabel + ": " + shapeLabel
13965                + " wrapper has unsupported " + sideLabel + " shape ("
13966                + detail + "); only a single column reference "
13967                + "(simple_object_name_t) is admitted on the "
13968                + sideLabel
13969                + " of a comparison / IN / ANY-ALL-SOME "
13970                + "predicate subquery when the other side is a "
13971                + "subquery (" + boundary + ")";
13972    }
13973
13974    /**
13975     * Slice 25 (rename of slice-23
13976     * {@code extractUncorrelatedExistsFromJoinOn}): walk the JOIN-ON
13977     * expression, extract every uncorrelated predicate-subquery wrapper
13978     * (EXISTS / NOT EXISTS / IN-SELECT / NOT IN-SELECT / scalar
13979     * comparison subquery / ANY-ALL-SOME) as its own
13980     * {@code <predicate_subquery_<i>>} StatementGraph, and return the
13981     * set of extracted inner nodes (the {@code exists_t} or
13982     * {@code subquery_t}, NOT the wrapping {@code in_t} /
13983     * {@code simple_comparison_t} / {@code group_comparison_t} /
13984     * {@code logical_not_t}) keyed on identity.
13985     *
13986     * <p>The set is consumed by the JOIN-ON window-function guard, the
13987     * JOIN-ON ref collector, and the slice-17 remaining-subquery
13988     * rejecter — each of those skips INTO / PAST these subtrees so
13989     * inner refs do not leak into outer joinColumnRefs. Critically,
13990     * the wrapper itself (e.g. an {@code in_t} whose RHS is the
13991     * {@code subquery_t}) is NOT in the set — this lets the LHS column
13992     * reference (e.g. {@code e.dept_id} in
13993     * {@code e.dept_id IN (SELECT ...)}) be collected normally into
13994     * outer's {@code joinColumnRefs}.
13995     *
13996     * <p>The walker handles BOTH the root-position case (the entire ON
13997     * IS one of the four wrappers, which {@code acceptChildren} would
13998     * not visit as a node) AND descendant positions (e.g.
13999     * {@code e.id = d.id AND e.dept_id IN (SELECT ...)}). Multiple
14000     * wrappers in one ON, multiple ON across multiple JOINs, and mixed
14001     * EXISTS / IN / cmp / ANY-ALL combinations are all handled.
14002     *
14003     * <p>Slice 25 / Slice 26 outer-shape validation: for non-EXISTS
14004     * wrappers, the side opposite the subquery must be a single
14005     * {@code simple_object_name_t} column ref. Slice 25 admits subquery
14006     * on RHS only and validates LHS via
14007     * {@link #isAdmittedOuterLhsShape}. Slice 26 lifts {@code
14008     * simple_comparison_t} to also admit subquery on LHS and validates
14009     * RHS via {@link #isAdmittedOuterRhsShape}. Tuple / parenthesized /
14010     * expression / function-call shapes on the validated side throw
14011     * {@link SemanticIRBuildException} with a tuned message via
14012     * {@link #buildOuterShapeRejectionMessage}. The EXISTS branch has
14013     * no outer-shape gate (slice-23 carryover).
14014     *
14015     * <p>Snapshot/rollback wrapper at the outer-SELECT call site
14016     * ({@link #build}) catches a partial extraction (e.g. third
14017     * wrapper rejected after first two extracted) and truncates
14018     * {@code stmts}/{@code lineage} back to the snapshot.
14019     */
14020    /**
14021     * Slice 110 — context bag threading clause-specific
14022     * {@link DiagnosticCode}s and a clause-label into the slice-23+
14023     * predicate-subquery extraction pipeline so the same walker code can
14024     * power JOIN-ON (slice 23–33+) and UPDATE WHERE (slice 110) without
14025     * code duplication.
14026     *
14027     * <p>Two static instances exist:
14028     * <ul>
14029     *   <li>{@link #JOIN_ON} — preserves slice-23+ JOIN-ON behavior
14030     *       byte-for-byte (same codes, same "JOIN ON" labels).</li>
14031     *   <li>{@link #UPDATE_WHERE} — slice 110 UPDATE WHERE call site
14032     *       (parallel {@code UPDATE_WHERE_*} codes; "UPDATE WHERE clause"
14033     *       label).</li>
14034     * </ul>
14035     *
14036     * <p>Codes per clause are intentionally parallel (slice-80
14037     * granular-codes contract: each semantic reject reason gets its own
14038     * stable API code rather than an umbrella code with discriminating
14039     * message text).
14040     */
14041    private static final class PredicateClauseContext {
14042        /** Used as the "in &lt;label&gt;" piece of every diagnostic message. */
14043        final String clauseLabel;
14044        final DiagnosticCode existsBodyMissing;
14045        final DiagnosticCode existsInnerRelationUnknown;
14046        final DiagnosticCode existsCorrelatedUnknownOuterAlias;
14047        final DiagnosticCode predicateNotNot;
14048        final DiagnosticCode outerShapeRejected;
14049        final DiagnosticCode scalarComparisonBothSides;
14050        final DiagnosticCode predicateSubqueryOnLeft;
14051        final DiagnosticCode genericSubqueryNotSupported;
14052
14053        private PredicateClauseContext(String clauseLabel,
14054                                       DiagnosticCode existsBodyMissing,
14055                                       DiagnosticCode existsInnerRelationUnknown,
14056                                       DiagnosticCode existsCorrelatedUnknownOuterAlias,
14057                                       DiagnosticCode predicateNotNot,
14058                                       DiagnosticCode outerShapeRejected,
14059                                       DiagnosticCode scalarComparisonBothSides,
14060                                       DiagnosticCode predicateSubqueryOnLeft,
14061                                       DiagnosticCode genericSubqueryNotSupported) {
14062            this.clauseLabel = clauseLabel;
14063            this.existsBodyMissing = existsBodyMissing;
14064            this.existsInnerRelationUnknown = existsInnerRelationUnknown;
14065            this.existsCorrelatedUnknownOuterAlias = existsCorrelatedUnknownOuterAlias;
14066            this.predicateNotNot = predicateNotNot;
14067            this.outerShapeRejected = outerShapeRejected;
14068            this.scalarComparisonBothSides = scalarComparisonBothSides;
14069            this.predicateSubqueryOnLeft = predicateSubqueryOnLeft;
14070            this.genericSubqueryNotSupported = genericSubqueryNotSupported;
14071        }
14072
14073        static final PredicateClauseContext JOIN_ON = new PredicateClauseContext(
14074                "JOIN ON",
14075                DiagnosticCode.JOIN_ON_EXISTS_BODY_MISSING,
14076                DiagnosticCode.JOIN_ON_EXISTS_INNER_RELATION_UNKNOWN,
14077                DiagnosticCode.JOIN_ON_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14078                DiagnosticCode.JOIN_ON_PREDICATE_NOT_NOT_SUPPORTED,
14079                DiagnosticCode.JOIN_ON_OUTER_SHAPE_REJECTED,
14080                DiagnosticCode.JOIN_ON_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14081                DiagnosticCode.JOIN_ON_PREDICATE_NOT_LIFTABLE,
14082                DiagnosticCode.JOIN_ON_PREDICATE_GENERIC_NOT_SUPPORTED);
14083
14084        static final PredicateClauseContext UPDATE_WHERE = new PredicateClauseContext(
14085                "UPDATE WHERE clause",
14086                DiagnosticCode.UPDATE_WHERE_EXISTS_BODY_MISSING,
14087                DiagnosticCode.UPDATE_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14088                DiagnosticCode.UPDATE_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14089                DiagnosticCode.UPDATE_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14090                DiagnosticCode.UPDATE_WHERE_OUTER_SHAPE_REJECTED,
14091                DiagnosticCode.UPDATE_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14092                DiagnosticCode.UPDATE_WHERE_PREDICATE_NOT_LIFTABLE,
14093                DiagnosticCode.UPDATE_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14094
14095        static final PredicateClauseContext DELETE_WHERE = new PredicateClauseContext(
14096                "DELETE WHERE clause",
14097                DiagnosticCode.DELETE_WHERE_EXISTS_BODY_MISSING,
14098                DiagnosticCode.DELETE_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14099                DiagnosticCode.DELETE_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14100                DiagnosticCode.DELETE_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14101                DiagnosticCode.DELETE_WHERE_OUTER_SHAPE_REJECTED,
14102                DiagnosticCode.DELETE_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14103                DiagnosticCode.DELETE_WHERE_PREDICATE_NOT_LIFTABLE,
14104                DiagnosticCode.DELETE_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14105
14106        static final PredicateClauseContext SELECT_WHERE = new PredicateClauseContext(
14107                "SELECT WHERE clause",
14108                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14109                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14110                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14111                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14112                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14113                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14114                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14115                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14116
14117        /**
14118         * Slice 113 — uncorrelated WHERE-side predicate subqueries on
14119         * set-op branches (UNION / INTERSECT / EXCEPT / MINUS branches).
14120         * Reuses every {@link DiagnosticCode} from {@link #SELECT_WHERE}
14121         * because a branch IS a SELECT — the shape rejects are
14122         * semantically identical to top-level SELECT WHERE. Only the
14123         * {@code clauseLabel} differs so diagnostic messages distinguish
14124         * the nested context (helpful when a multi-branch query reports
14125         * a reject and the user needs to know which branch). Keeping the
14126         * codes shared frees consumers from a new code-family migration
14127         * and preserves the enum count at 279.
14128         */
14129        static final PredicateClauseContext SET_OP_BRANCH_WHERE = new PredicateClauseContext(
14130                "set-op branch WHERE clause",
14131                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14132                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14133                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14134                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14135                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14136                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14137                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14138                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14139
14140        /**
14141         * Slice 114 — uncorrelated WHERE-side predicate subqueries
14142         * inside a non-set-op CTE body (the SELECT body of a single CTE
14143         * in a WITH list on SELECT / MERGE / UPDATE / DELETE). Reuses
14144         * every {@link DiagnosticCode} from {@link #SELECT_WHERE}
14145         * because a CTE body IS a SELECT — the shape rejects are
14146         * semantically identical to top-level SELECT WHERE. Only the
14147         * {@code clauseLabel} differs so a reject in a
14148         * {@code WITH cte AS (SELECT ... WHERE NOT (...))} shape can
14149         * identify the CTE-body host context in the diagnostic message.
14150         * Keeping the codes shared (slice 113 precedent) preserves the
14151         * enum count at 279 and frees consumers from another
14152         * code-family migration.
14153         */
14154        static final PredicateClauseContext CTE_BODY_WHERE = new PredicateClauseContext(
14155                "CTE body WHERE clause",
14156                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14157                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14158                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14159                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14160                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14161                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14162                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14163                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14164
14165        /**
14166         * Slice 120 — uncorrelated WHERE-side predicate subqueries inside
14167         * a FROM-subquery body (the inner SELECT of a {@code FROM (...)}
14168         * derived table). Reuses every {@link DiagnosticCode} from
14169         * {@link #SELECT_WHERE} (slice 113/114/116 precedent) because a
14170         * FROM-subquery body IS a SELECT — the shape rejects are
14171         * semantically identical to top-level SELECT WHERE. Only the
14172         * {@code clauseLabel} differs so a reject inside a
14173         * {@code FROM (SELECT ... WHERE NOT (...)) sub} shape can identify
14174         * the FROM-subquery host context in the diagnostic message. The
14175         * FROM-subquery body builder {@code processDirectSubqueryTable} is
14176         * shared by the SELECT, UPDATE (slice 83), and DELETE (slice 84)
14177         * FROM-subquery extractors, so this single context lifts all three.
14178         * Keeping the codes shared preserves the enum count at 279 and
14179         * frees consumers from another code-family migration.
14180         */
14181        static final PredicateClauseContext FROM_SUBQUERY_BODY_WHERE = new PredicateClauseContext(
14182                "FROM-subquery body WHERE clause",
14183                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14184                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14185                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14186                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14187                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14188                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14189                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14190                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14191
14192        /**
14193         * Slice 121 — uncorrelated WHERE-side predicate subqueries inside
14194         * a scalar projection subquery body (the inner SELECT of a
14195         * {@code SELECT (SELECT ...) AS m FROM ...} scalar projection).
14196         * Reuses every {@link DiagnosticCode} from {@link #SELECT_WHERE}
14197         * (slice 113/114/120 precedent) because a scalar body IS a SELECT —
14198         * the shape rejects are semantically identical to top-level SELECT
14199         * WHERE. Only the {@code clauseLabel} differs so a reject inside a
14200         * {@code (SELECT ... WHERE NOT (...)) AS m} shape can identify the
14201         * scalar-body host context in the diagnostic message. The lift is
14202         * applied at the shared projection scalar-body build in
14203         * {@code extractScalarSubqueriesAsStatementsInternal}, covering both
14204         * the recursive (outer / CTE-body) and non-recursive (set-op-branch)
14205         * scalar contexts. The UPDATE SET-RHS scalar path keeps the slice-11
14206         * WHERE reject ({@code SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE}).
14207         * Keeping the codes shared preserves the enum count at 279 and
14208         * frees consumers from another code-family migration.
14209         */
14210        static final PredicateClauseContext SCALAR_BODY_WHERE = new PredicateClauseContext(
14211                "scalar subquery body WHERE clause",
14212                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14213                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14214                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14215                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14216                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14217                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14218                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14219                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14220
14221        /**
14222         * Slice 116 — uncorrelated WHERE-side predicate subqueries on
14223         * MERGE per-WHEN action WHEREs ({@code TMergeUpdateClause.updateWhereClause},
14224         * {@code TMergeUpdateClause.deleteWhereClause},
14225         * {@code TMergeInsertClause.insertWhereClause}). Reuses every
14226         * {@link DiagnosticCode} from {@link #SELECT_WHERE} (slice 113/114
14227         * precedent) because a MERGE-action WHERE predicate IS a SELECT
14228         * WHERE in shape — the shape rejects are semantically identical to
14229         * top-level SELECT WHERE. Only the {@code clauseLabel} differs so a
14230         * reject inside a MERGE WHEN can identify the host context in the
14231         * diagnostic message. Keeping the codes shared preserves the enum
14232         * count at 279 and frees consumers from another code-family
14233         * migration.
14234         */
14235        static final PredicateClauseContext MERGE_WHEN_WHERE = new PredicateClauseContext(
14236                "MERGE WHEN action WHERE clause",
14237                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14238                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14239                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14240                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14241                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14242                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14243                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14244                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14245    }
14246
14247    /**
14248     * Slice 110 — preserved entry-point alias for the JOIN-ON walker.
14249     * Delegates to {@link #extractUncorrelatedPredicateSubqueriesFromClause}
14250     * with {@link PredicateClauseContext#JOIN_ON} so existing JOIN-ON
14251     * callers (single site in {@code buildRelations}) need no change and
14252     * the slice-23+ diagnostic byte-shape is preserved exactly.
14253     */
14254    private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromJoinOn(
14255            TExpression onCond,
14256            final NameBindingProvider provider,
14257            final List<StatementGraph> stmts,
14258            final List<LineageEdge> lineage,
14259            final Map<String, Integer> cteMapForExtraction) {
14260        return extractUncorrelatedPredicateSubqueriesFromClause(
14261                onCond, provider, stmts, lineage, cteMapForExtraction,
14262                PredicateClauseContext.JOIN_ON,
14263                /*correlationScope=*/ null);
14264    }
14265
14266    /**
14267     * Slice 118 — overload preserved for the slice-110 / 111 / 112 / 113 /
14268     * 114 / 116 call sites that don't admit correlation. Delegates to the
14269     * 8-arg form with {@code correlationScope=null}.
14270     */
14271    private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause(
14272            TExpression onCond,
14273            final NameBindingProvider provider,
14274            final List<StatementGraph> stmts,
14275            final List<LineageEdge> lineage,
14276            final Map<String, Integer> cteMapForExtraction,
14277            final PredicateClauseContext ctx) {
14278        return extractUncorrelatedPredicateSubqueriesFromClause(onCond,
14279                provider, stmts, lineage, cteMapForExtraction, ctx,
14280                /*correlationScope=*/ null, /*semiFactsOut=*/ null,
14281                /*outerRelationAliases=*/ Collections.<String>emptySet());
14282    }
14283
14284    /**
14285     * R8 — delegating overload preserving the pre-R8 7-arg signature
14286     * (correlationScope, no semi-join collector). MERGE / UPDATE / DELETE
14287     * predicate-WHERE callers route here; semi-join facts are collected
14288     * only on the SELECT WHERE path (which calls the 8-arg overload with a
14289     * non-null {@code semiFactsOut}).
14290     */
14291    private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause(
14292            TExpression onCond,
14293            final NameBindingProvider provider,
14294            final List<StatementGraph> stmts,
14295            final List<LineageEdge> lineage,
14296            final Map<String, Integer> cteMapForExtraction,
14297            final PredicateClauseContext ctx,
14298            final EnclosingScope correlationScope) {
14299        return extractUncorrelatedPredicateSubqueriesFromClause(onCond,
14300                provider, stmts, lineage, cteMapForExtraction, ctx,
14301                correlationScope, /*semiFactsOut=*/ null,
14302                /*outerRelationAliases=*/ Collections.<String>emptySet());
14303    }
14304
14305    /**
14306     * Slice 118 — same as the 7-arg overload but threads an optional
14307     * {@code correlationScope} (target + USING source + outer CTEs) into
14308     * {@link #extractOnePredicateSubqueryBody}. When non-null, the inner
14309     * predicate-body build uses tolerant outer binding and the post-build
14310     * correlation walk PROMOTES outer-aliased refs into synthesised
14311     * OUTER_REFERENCE relations instead of rejecting them. The FILTER and
14312     * WITHIN GROUP correlation walks remain active (codex round-1 Q2
14313     * BLOCKING fix) so refs hidden inside FILTER subtrees or PG
14314     * {@code fn.withinGroup.orderBy} continue to reject.
14315     *
14316     * <p>All non-MERGE callers pass {@code correlationScope=null} and
14317     * therefore see byte-identical behaviour. Only
14318     * {@link #collectMergeActionWhere} passes a non-null scope (built once
14319     * per MERGE in {@code buildMerge} via
14320     * {@link #buildMergeEnclosingScope}).
14321     */
14322    private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause(
14323            TExpression onCond,
14324            final NameBindingProvider provider,
14325            final List<StatementGraph> stmts,
14326            final List<LineageEdge> lineage,
14327            final Map<String, Integer> cteMapForExtraction,
14328            final PredicateClauseContext ctx,
14329            final EnclosingScope correlationScope,
14330            final List<SemiJoinFact> semiFactsOut,
14331            final Set<String> outerRelationAliases) {
14332        // Defensive null assertions — the extraction path can only be
14333        // reached from buildRelations when
14334        // allowJoinOnPredicateSubqueries=true (outer-SELECT only), which
14335        // guarantees stmts/lineage/cteMap are non-null. Failing here means
14336        // a future refactor wired a non-outer call site through the slice-25
14337        // path without supplying the required state.
14338        if (onCond == null) {
14339            return Collections.newSetFromMap(new java.util.IdentityHashMap<TExpression, Boolean>());
14340        }
14341        if (stmts == null || lineage == null || cteMapForExtraction == null) {
14342            throw new IllegalStateException(
14343                    "extractUncorrelatedPredicateSubqueriesFromClause("
14344                            + ctx.clauseLabel + ") activated without required state — "
14345                            + "stmts=" + (stmts == null ? "null" : "ok")
14346                            + " lineage=" + (lineage == null ? "null" : "ok")
14347                            + " cteMap=" + (cteMapForExtraction == null ? "null" : "ok")
14348                            + "; caller misconfiguration");
14349        }
14350        final Set<TExpression> extractedRoots =
14351                Collections.newSetFromMap(new java.util.IdentityHashMap<TExpression, Boolean>());
14352        // Slice 25 (impl-review M1-fix): explicit reject for
14353        // {@code logical_not_t} over a slice-25 IN / scalar-cmp /
14354        // ANY-ALL-SOME wrapper at the root. The slice-23/24
14355        // {@code logical_not_t} over {@code exists_t} (NOT EXISTS)
14356        // remains admitted by unwrapToInnerExtractableSubquery.
14357        if (isLogicalNotOverNonExistsWrapper(onCond)) {
14358            throw new SemanticIRBuildException(
14359                    Diagnostic.error(ctx.predicateNotNot,
14360                    "predicate subquery in " + ctx.clauseLabel + ": NOT applied to "
14361                            + "a non-EXISTS predicate subquery wrapper "
14362                            + "(" + onCond.getRightOperand().getExpressionType()
14363                            + ") is not supported yet — the slice-25 boundary "
14364                            + "admits NOT only over EXISTS; "
14365                            + "rewrite e.g. NOT (a IN (SELECT ...)) as "
14366                            + "a NOT IN (SELECT ...)", onCond));
14367        }
14368        // Root fast path: acceptChildren never visits the root node, so
14369        // a clause whose entire expression IS a wrapper would be missed
14370        // by the descendant walker.
14371        TExpression rootExtractable = unwrapToInnerExtractableSubquery(onCond);
14372        if (rootExtractable != null) {
14373            // M1-fix + slice-26 dual-side: validate the non-subquery
14374            // side of non-EXISTS wrappers BEFORE extracting (so partial
14375            // extraction never lands). Slice 25 carryover: subquery on
14376            // RHS → validate LHS via isAdmittedOuterLhsShape.
14377            // Slice 26 NEW: subquery on LHS (simple_comparison_t only)
14378            // → validate RHS via isAdmittedOuterRhsShape.
14379            if (onCond.getExpressionType() != EExpressionType.exists_t
14380                    && onCond.getExpressionType() != EExpressionType.logical_not_t) {
14381                boolean isLhsSubquery = (rootExtractable == onCond.getLeftOperand());
14382                boolean nonSubquerySideOk = isLhsSubquery
14383                        ? isAdmittedOuterRhsShape(onCond.getRightOperand())
14384                        : isAdmittedOuterLhsShape(onCond.getLeftOperand());
14385                if (!nonSubquerySideOk) {
14386                    throw new SemanticIRBuildException(
14387                            Diagnostic.error(ctx.outerShapeRejected,
14388                            buildOuterShapeRejectionMessage(onCond, isLhsSubquery, ctx),
14389                            onCond));
14390                }
14391            }
14392            extractOnePredicateSubqueryBody(onCond, rootExtractable, provider, stmts, lineage,
14393                    cteMapForExtraction, ctx, correlationScope, semiFactsOut,
14394                    outerRelationAliases);
14395            extractedRoots.add(rootExtractable);
14396        }
14397        // Descendant walk: find every wrapper at any depth. Skip into
14398        // already-extracted subtrees (so we don't re-enter the body
14399        // looking for nested wrappers — covered by the inner-shape
14400        // preflight's "no nested predicate subqueries in body"
14401        // rejection).
14402        onCond.acceptChildren(new TParseTreeVisitor() {
14403            // Track depth into already-extracted roots and into wrapper
14404            // subtrees we've extracted. preVisit increments on the
14405            // wrapper (the parent that contained the inner extractable);
14406            // postVisit decrements on either the inner extractable
14407            // (extractedRoots.contains) or the wrapper
14408            // (unwrapToInnerExtractableSubquery != null). The
14409            // {@code skipDepth > 0} guard prevents the second
14410            // decrement from going negative when both apply (e.g. NOT
14411            // EXISTS — both the logical_not_t wrapper and the inner
14412            // exists_t fire).
14413            int skipDepth = 0;
14414
14415            @Override
14416            public void preVisit(TExpression e) {
14417                if (skipDepth > 0) return;
14418                if (extractedRoots.contains(e)) {
14419                    // Already-extracted inner being re-visited shouldn't
14420                    // happen in normal traversal but defensive guard
14421                    // avoids double-extraction if it ever did.
14422                    skipDepth++;
14423                    return;
14424                }
14425                // Slice 25 (impl-review M1-fix): explicit reject for
14426                // {@code logical_not_t} over a slice-25 wrapper at any
14427                // depth. Without this, the visitor would descend into
14428                // the wrapper child and silently extract — admitting
14429                // a shape (`NOT (a IN (SELECT ...))`) that the
14430                // slice-25 boundary does NOT admit.
14431                if (isLogicalNotOverNonExistsWrapper(e)) {
14432                    throw new SemanticIRBuildException(
14433                            Diagnostic.error(ctx.predicateNotNot,
14434                            "predicate subquery in " + ctx.clauseLabel + ": NOT applied to "
14435                                    + "a non-EXISTS predicate subquery wrapper "
14436                                    + "(" + e.getRightOperand().getExpressionType()
14437                                    + ") is not supported yet — the slice-25 "
14438                                    + "boundary admits NOT only over EXISTS; "
14439                                    + "rewrite e.g. NOT (a IN (SELECT ...)) as "
14440                                    + "a NOT IN (SELECT ...)", e));
14441                }
14442                TExpression toExtract = unwrapToInnerExtractableSubquery(e);
14443                if (toExtract != null) {
14444                    // M1-fix + slice-26 dual-side: validate the non-
14445                    // subquery side BEFORE extracting (so partial
14446                    // extraction never lands). The slice-23/24
14447                    // NOT-EXISTS path uses logical_not_t, which has no
14448                    // outer-shape gate.
14449                    if (e.getExpressionType() != EExpressionType.exists_t
14450                            && e.getExpressionType() != EExpressionType.logical_not_t) {
14451                        boolean isLhsSubquery = (toExtract == e.getLeftOperand());
14452                        boolean nonSubquerySideOk = isLhsSubquery
14453                                ? isAdmittedOuterRhsShape(e.getRightOperand())
14454                                : isAdmittedOuterLhsShape(e.getLeftOperand());
14455                        if (!nonSubquerySideOk) {
14456                            throw new SemanticIRBuildException(
14457                                    Diagnostic.error(ctx.outerShapeRejected,
14458                                    buildOuterShapeRejectionMessage(e, isLhsSubquery, ctx),
14459                                    e));
14460                        }
14461                    }
14462                    if (extractedRoots.contains(toExtract)) return;
14463                    extractOnePredicateSubqueryBody(e, toExtract, provider, stmts, lineage,
14464                            cteMapForExtraction, ctx, correlationScope, semiFactsOut,
14465                            outerRelationAliases);
14466                    extractedRoots.add(toExtract);
14467                    skipDepth++;
14468                }
14469            }
14470
14471            @Override
14472            public void postVisit(TExpression e) {
14473                // M2-fix: decrement on EITHER the extracted inner
14474                // (extractedRoots.contains) OR the wrapper
14475                // (unwrapToInnerExtractableSubquery != null). The
14476                // {@code skipDepth > 0} guard prevents going negative
14477                // when both apply.
14478                if (skipDepth > 0
14479                        && (extractedRoots.contains(e)
14480                                || unwrapToInnerExtractableSubquery(e) != null)) {
14481                    skipDepth--;
14482                }
14483            }
14484        });
14485        return extractedRoots;
14486    }
14487
14488    /**
14489     * R8 — a predicate-derived semi-join discovered during WHERE
14490     * predicate-subquery extraction. Carries the polarity (semi vs
14491     * anti-semi), the lifted inner block's statement index + label, the
14492     * optional outer relation alias (for the left endpoint), the
14493     * correlated outer↔inner column conditions (may be empty), and the
14494     * wrapper's source span / verbatim text. Converted to a
14495     * {@link JoinEntity} at JoinGraph-assembly time (which has the outer
14496     * relation list to resolve the left endpoint's qualified name).
14497     */
14498    private static final class SemiJoinFact {
14499        final SemanticJoinType polarity;
14500        final int innerStatementIndex;
14501        final String innerLabel;
14502        // Distinct outer relation aliases referenced by the correlation
14503        // (insertion order). Empty for an uncorrelated subquery. When a
14504        // single alias, the left endpoint is that relation; when several,
14505        // the left endpoint is the accumulated outer rowset (codex R8).
14506        final List<String> outerAliases;
14507        final List<Predicate> conditions; // never null; may be empty
14508        final SourceSpan span;            // nullable
14509        final String conditionText;       // nullable
14510
14511        SemiJoinFact(SemanticJoinType polarity, int innerStatementIndex,
14512                     String innerLabel, List<String> outerAliases,
14513                     List<Predicate> conditions, SourceSpan span,
14514                     String conditionText) {
14515            this.polarity = polarity;
14516            this.innerStatementIndex = innerStatementIndex;
14517            this.innerLabel = innerLabel;
14518            this.outerAliases = outerAliases == null
14519                    ? Collections.<String>emptyList() : outerAliases;
14520            this.conditions = conditions == null
14521                    ? Collections.<Predicate>emptyList() : conditions;
14522            this.span = span;
14523            this.conditionText = conditionText;
14524        }
14525    }
14526
14527    /**
14528     * R8 — semi-join polarity for a predicate wrapper. {@code EXISTS} /
14529     * {@code IN} → {@link SemanticJoinType#SEMI}; {@code NOT EXISTS}
14530     * (logical_not over exists) / {@code NOT IN} ({@code in_t} with a NOT
14531     * token) → {@link SemanticJoinType#ANTI_SEMI}. Returns {@code null}
14532     * for wrappers that are not modelled as semi-joins (scalar comparison,
14533     * ANY/ALL/SOME) so the caller emits no semi-join fact for them.
14534     */
14535    private static SemanticJoinType semiJoinPolarity(TExpression wrapper) {
14536        if (wrapper == null) return null;
14537        EExpressionType t = wrapper.getExpressionType();
14538        if (t == EExpressionType.logical_not_t) {
14539            // NOT EXISTS — the slice-25 boundary admits NOT only over EXISTS.
14540            TExpression inner = wrapper.getRightOperand();
14541            if (inner != null && inner.getExpressionType() == EExpressionType.exists_t) {
14542                return SemanticJoinType.ANTI_SEMI;
14543            }
14544            return null;
14545        }
14546        if (t == EExpressionType.exists_t) {
14547            return SemanticJoinType.SEMI;
14548        }
14549        if (t == EExpressionType.in_t) {
14550            return wrapper.getNotToken() != null
14551                    ? SemanticJoinType.ANTI_SEMI : SemanticJoinType.SEMI;
14552        }
14553        return null;
14554    }
14555
14556    /**
14557     * R8 — build the {@link SemiJoinFact} for an extracted predicate
14558     * subquery, or {@code null} when the wrapper is not a semi-join shape
14559     * ({@link #semiJoinPolarity} null) or no collector is requested.
14560     *
14561     * <p>Correlation pairing:
14562     * <ul>
14563     *   <li>{@code IN}: outer column = the non-subquery side of the
14564     *       {@code in_t}; inner column = the subquery's first output
14565     *       column source.</li>
14566     *   <li>{@code EXISTS} / {@code NOT EXISTS}: outer↔inner pairs are
14567     *       lifted from equi-conjuncts in the inner WHERE that compare an
14568     *       inner-local column to an outer (non-local) column.</li>
14569     * </ul>
14570     */
14571    private static SemiJoinFact buildSemiJoinFact(TExpression wrapper,
14572                                                  TExpression extractableNode,
14573                                                  TSelectSqlStatement inner,
14574                                                  StatementGraph innerStmt,
14575                                                  int innerIndex, String innerLabel,
14576                                                  Set<String> innerLocalAliases,
14577                                                  NameBindingProvider provider) {
14578        SemanticJoinType polarity = semiJoinPolarity(wrapper);
14579        if (polarity == null) return null;
14580        List<Predicate> conditions = new ArrayList<>();
14581        // Distinct outer aliases, insertion-ordered (codex R8: drives the
14582        // single-relation-vs-joined-rowset left-endpoint choice).
14583        java.util.LinkedHashSet<String> outerAliases = new java.util.LinkedHashSet<>();
14584        if (wrapper.getExpressionType() == EExpressionType.in_t) {
14585            // Membership equality: outer side (the non-subquery operand) =
14586            // the subquery's first output column. Both must be bare columns.
14587            boolean lhsIsSubquery = (extractableNode == wrapper.getLeftOperand());
14588            TExpression outerSide = lhsIsSubquery
14589                    ? wrapper.getRightOperand() : wrapper.getLeftOperand();
14590            ColumnRef outerCol = bareColumnRefOf(outerSide, provider);
14591            // codex R8 [P2 round 2]: the inner membership column must ALSO
14592            // be a bare column. `a.id IN (SELECT b.id + 1 FROM b)` projects
14593            // a compound expression — emitting `a.id = b.id` would be a
14594            // fabricated equality, so omit the membership pair there.
14595            ColumnRef innerCol = isFirstProjectionBareColumn(inner)
14596                    ? firstOutputColumnRefOf(innerStmt) : null;
14597            // codex R8 [P2 round 3]: the inner membership column must be a
14598            // genuine inner-local column. `a.id IN (SELECT a.id FROM t2 b)`
14599            // projects an OUTER column — emitting `a.id = a.id` would
14600            // misrepresent the predicate and not connect the right endpoint.
14601            if (outerCol != null && innerCol != null
14602                    && isLocalAlias(innerCol, innerLocalAliases)) {
14603                addOuterAlias(outerAliases, outerCol);
14604                conditions.add(equiPredicate(outerCol, innerCol,
14605                        SourceSpan.of(wrapper)));
14606            }
14607            // codex R8 [P1]: a correlated IN (e.g. `a.id IN (SELECT b.id
14608            // FROM b WHERE b.k = a.k)`) also carries correlation in the
14609            // inner WHERE — fold those pairs in too so they are not lost.
14610            for (ColumnRef[] pair : extractCorrelationPairs(inner,
14611                    innerLocalAliases, provider)) {
14612                addOuterAlias(outerAliases, pair[0]);
14613                conditions.add(equiPredicate(pair[0], pair[1], null));
14614            }
14615        } else {
14616            // EXISTS / NOT EXISTS — correlation lives in the inner WHERE.
14617            for (ColumnRef[] pair : extractCorrelationPairs(inner,
14618                    innerLocalAliases, provider)) {
14619                addOuterAlias(outerAliases, pair[0]);
14620                conditions.add(equiPredicate(pair[0], pair[1], null));
14621            }
14622        }
14623        return new SemiJoinFact(polarity, innerIndex, innerLabel,
14624                new ArrayList<>(outerAliases),
14625                conditions, SourceSpan.of(wrapper), verbatimText(wrapper));
14626    }
14627
14628    private static void addOuterAlias(java.util.LinkedHashSet<String> aliases,
14629                                      ColumnRef outerCol) {
14630        if (outerCol != null && outerCol.getRelationAlias() != null
14631                && !outerCol.getRelationAlias().isEmpty()) {
14632            aliases.add(outerCol.getRelationAlias());
14633        }
14634    }
14635
14636    /**
14637     * R8 — the resolved {@link ColumnRef} for an expression that is a
14638     * <em>bare, qualified column reference</em> ({@code simple_object_name_t}
14639     * with a relation alias), or {@code null} otherwise. Strict by design
14640     * (codex R8 review): a compound operand such as {@code b.k + 1} or
14641     * {@code lower(a.n)} returns {@code null} so we never synthesise a
14642     * misleading EQUI column predicate from a side that is not a plain
14643     * column (the pair is omitted instead).
14644     */
14645    private static ColumnRef bareColumnRefOf(TExpression expr,
14646                                             NameBindingProvider provider) {
14647        if (expr == null
14648                || expr.getExpressionType() != EExpressionType.simple_object_name_t) {
14649            return null;
14650        }
14651        try {
14652            List<ColumnRef> refs = collectColumnRefs(expr, provider);
14653            for (ColumnRef r : refs) {
14654                if (r != null && r.getRelationAlias() != null
14655                        && !r.getRelationAlias().isEmpty()) {
14656                    return r;
14657                }
14658            }
14659        } catch (RuntimeException ignore) {
14660            // Tolerant: an un-extractable column ref just yields no column.
14661        }
14662        return null;
14663    }
14664
14665    /**
14666     * R8 — true iff the inner SELECT's first projection is a bare column
14667     * reference ({@code simple_object_name_t}). Guards the IN membership
14668     * equality so a compound projection ({@code SELECT b.id + 1 ...}) does
14669     * not produce a fabricated bare-column equality.
14670     */
14671    private static boolean isFirstProjectionBareColumn(TSelectSqlStatement inner) {
14672        if (inner == null || inner.getResultColumnList() == null
14673                || inner.getResultColumnList().size() == 0) {
14674            return false;
14675        }
14676        TResultColumn rc = inner.getResultColumnList().getResultColumn(0);
14677        if (rc == null || rc.getExpr() == null) {
14678            return false;
14679        }
14680        return rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t;
14681    }
14682
14683    /** R8 — the first source {@link ColumnRef} of a block's first output column. */
14684    private static ColumnRef firstOutputColumnRefOf(StatementGraph innerStmt) {
14685        if (innerStmt == null) return null;
14686        List<OutputColumn> outs = innerStmt.getOutputColumns();
14687        if (outs == null || outs.isEmpty()) return null;
14688        List<ColumnRef> sources = outs.get(0).getSources();
14689        if (sources == null || sources.isEmpty()) return null;
14690        return sources.get(0);
14691    }
14692
14693    /** R8 — synthesise an EQUI {@link Predicate} from two column refs. */
14694    private static Predicate equiPredicate(ColumnRef left, ColumnRef right,
14695                                           SourceSpan span) {
14696        return new Predicate(PredicateKind.EQUI, "=",
14697                PredicateOperand.column(left, null),
14698                PredicateOperand.column(right, null), span);
14699    }
14700
14701    /**
14702     * R8 — lift outer↔inner correlation pairs from a correlated EXISTS
14703     * inner WHERE. Walks the top-level AND conjuncts iteratively (the
14704     * left-leaning binary tree of {@code logical_and_t} — never recurse,
14705     * per the project StackOverflow guard) and, for each {@code =}
14706     * comparison whose two sides are one inner-local column and one
14707     * outer (non-local) column, emits the pair {@code [outerCol, innerCol]}.
14708     * Returns an empty list for an uncorrelated EXISTS.
14709     */
14710    private static List<ColumnRef[]> extractCorrelationPairs(
14711            TSelectSqlStatement inner, Set<String> innerLocalAliases,
14712            NameBindingProvider provider) {
14713        List<ColumnRef[]> out = new ArrayList<>();
14714        if (inner == null || inner.getWhereClause() == null
14715                || inner.getWhereClause().getCondition() == null) {
14716            return out;
14717        }
14718        // Iterative DFS over AND-conjuncts (left-leaning tree).
14719        Deque<TExpression> stack = new ArrayDeque<>();
14720        stack.push(inner.getWhereClause().getCondition());
14721        int guard = 0;
14722        while (!stack.isEmpty() && guard++ < 100000) {
14723            TExpression e = stack.pop();
14724            if (e == null) continue;
14725            if (e.getExpressionType() == EExpressionType.logical_and_t) {
14726                if (e.getRightOperand() != null) stack.push(e.getRightOperand());
14727                if (e.getLeftOperand() != null) stack.push(e.getLeftOperand());
14728                continue;
14729            }
14730            if (e.getExpressionType() != EExpressionType.simple_comparison_t) {
14731                continue;
14732            }
14733            String op = e.getOperatorToken() != null
14734                    ? e.getOperatorToken().toString() : null;
14735            if (op == null || !"=".equals(op)) continue;
14736            ColumnRef l = bareColumnRefOf(e.getLeftOperand(), provider);
14737            ColumnRef r = bareColumnRefOf(e.getRightOperand(), provider);
14738            if (l == null || r == null) continue;
14739            boolean lLocal = isLocalAlias(l, innerLocalAliases);
14740            boolean rLocal = isLocalAlias(r, innerLocalAliases);
14741            // Exactly one side local → the other is the outer correlation.
14742            if (lLocal && !rLocal) {
14743                out.add(new ColumnRef[]{ r, l }); // [outer, inner]
14744            } else if (rLocal && !lLocal) {
14745                out.add(new ColumnRef[]{ l, r });
14746            }
14747        }
14748        return out;
14749    }
14750
14751    private static boolean isLocalAlias(ColumnRef ref, Set<String> innerLocalAliases) {
14752        return ref.getRelationAlias() != null
14753                && innerLocalAliases.contains(
14754                        ref.getRelationAlias().toLowerCase(Locale.ROOT));
14755    }
14756
14757    /**
14758     * R8 — true iff {@code alias} names a relation in the host SELECT's FROM
14759     * list ({@code outerRelationAliases}, lower-cased). Gates the
14760     * correlated-EXISTS degrade so only a genuine outer-relation correlation
14761     * degrades; an alias that is neither inner-local nor a known outer
14762     * relation (a typo) still rejects.
14763     */
14764    private static boolean isKnownOuterAlias(String alias,
14765                                             Set<String> outerRelationAliases) {
14766        return alias != null && outerRelationAliases != null
14767                && outerRelationAliases.contains(alias.toLowerCase(Locale.ROOT));
14768    }
14769
14770    /**
14771     * Slice 25 (rename of slice-23 {@code extractOneExistsBody}):
14772     * extract a single predicate-subquery body's inner SELECT as its
14773     * own {@code <predicate_subquery_<i>>} StatementGraph. Runs the
14774     * inner-shape preflight before recursive build, then post-build
14775     * correlation check.
14776     *
14777     * <p>{@code extractableNode} is either an {@code exists_t}
14778     * (slice-23 EXISTS / slice-24 column-bearing EXISTS) or a
14779     * {@code subquery_t} (slice-25 IN-SELECT / scalar comparison /
14780     * ANY-ALL-SOME). Both expose the inner SELECT via
14781     * {@link TExpression#getSubQuery()}.
14782     *
14783     * <p>R8 — {@code wrapper} is the predicate wrapper containing
14784     * {@code extractableNode} ({@code exists_t} / {@code logical_not_t}
14785     * over exists / {@code in_t} / comparison). When {@code semiFactsOut}
14786     * is non-null and the wrapper is an EXISTS/IN shape, a
14787     * {@link SemiJoinFact} is appended for the JoinGraph. When
14788     * {@code correlationScope} is null a correlated EXISTS now DEGRADES
14789     * (records the correlation, does not throw) instead of failing with
14790     * {@code *_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS} (R8 degrade).
14791     */
14792    private static int extractOnePredicateSubqueryBody(TExpression wrapper,
14793                                                        TExpression extractableNode,
14794                                                        NameBindingProvider provider,
14795                                                        List<StatementGraph> stmts,
14796                                                        List<LineageEdge> lineage,
14797                                                        Map<String, Integer> cteMapForExtraction,
14798                                                        PredicateClauseContext ctx,
14799                                                        EnclosingScope correlationScope,
14800                                                        List<SemiJoinFact> semiFactsOut,
14801                                                        Set<String> outerRelationAliases) {
14802        TSelectSqlStatement inner = extractableNode.getSubQuery();
14803        if (inner == null) {
14804            // Degenerate node with no subquery; defensive.
14805            throw new SemanticIRBuildException(
14806                    Diagnostic.error(ctx.existsBodyMissing,
14807                    "EXISTS in " + ctx.clauseLabel + ": subquery body is missing", null));
14808        }
14809        // (a–g) Inner-shape preflight (slice-23 boundary; slice 24 widens
14810        // (e) to admit single column-ref projection in addition to constant).
14811        preflightExistsInnerShape(inner);
14812
14813        // Slice 118 — when an enclosing correlation scope is supplied
14814        // (MERGE per-WHEN action WHERE only), decorate `provider` with
14815        // tolerant outer binding so the inner build admits qualified refs
14816        // to outer aliases (target / USING source / outer CTEs) as
14817        // synthetic EXACT_MATCH bindings instead of rejecting them as
14818        // COLUMN_BINDING_NON_EXACT. Mirrors the slice-117 pattern for
14819        // UPDATE SET-RHS correlated scalars. Computed BEFORE
14820        // buildSelectStatementImpl so the inner build's bindColumn calls
14821        // see the tolerant fallback already populated (codex round-5
14822        // ordering fix from slice 117). Qualifiers IN the inner's local
14823        // FROM aliases still strict-reject so real typos (`o.bad_col`
14824        // where `o` IS the inner FROM alias) still surface as
14825        // COLUMN_BINDING_NON_EXACT.
14826        final NameBindingProvider effectiveProvider;
14827        if (correlationScope != null) {
14828            Set<String> innerLocalAliasesForTolerant =
14829                    precomputeInnerLocalAliases(inner);
14830            effectiveProvider = innerLocalAliasesForTolerant.isEmpty()
14831                    ? provider
14832                    : provider.withTolerantOuterBinding(
14833                            innerLocalAliasesForTolerant);
14834        } else {
14835            effectiveProvider = provider;
14836        }
14837
14838        // Build the inner SELECT as its own StatementGraph. SAME provider
14839        // as outer (codex round-1 MUST 3 — outer CTEs remain visible).
14840        // Slice 118: tolerant-decorated provider when correlationScope
14841        // != null (MERGE per-WHEN action WHERE only); same provider as
14842        // before otherwise.
14843        // hasOuterCteListAlreadyProcessed=false (codex round-2 SHOULD 1 —
14844        // generic nested-WITH guard remains active as belt-and-braces).
14845        // allowFromSubqueries=false (no FROM-subqueries in inner body for
14846        // slice 23). isPredicateBody=true: for constant-only inner emits one
14847        // synthetic OutputColumn (slice-23 path); for column-ref inner the
14848        // §4.1.2 short-circuit falls through to the normal column-ref path
14849        // (slice-24 widening).
14850        String predName = PREDICATE_BODY_PREFIX + stmts.size() + ">";
14851        StatementGraph innerStmt;
14852        if (isPivotSelect(inner)) {
14853            // Slice 141: a nested PIVOT / UNPIVOT in a predicate subquery body
14854            // (`WHERE k IN (SELECT ... PIVOT(...))` / EXISTS / scalar-comparison
14855            // / ANY-ALL-SOME). The slice-129 pivot router in
14856            // {@link #buildSelectStatementImpl} is gated to the OUTER SELECT
14857            // context (`name == null && !isPredicateBody`); the predicate body
14858            // carries `isPredicateBody=true` and the synthetic
14859            // `<predicate_subquery_N>` name, so the gate misses and the body
14860            // would otherwise fall to the normal path which can't bind a
14861            // `pivoted_table` (rejects with the misleading
14862            // TABLE_BINDING_UNRESOLVED "null(piviot_table)"). Route it to
14863            // {@link #buildPivotSelect} with the synthetic predicate-body name
14864            // so the body becomes a proper pivot StatementGraph.
14865            //
14866            // PIVOT extra-clause (WHERE/GROUP BY/etc.) / chained / malformed-
14867            // UNPIVOT / bare-`*`-without-catalog rejects stay deferred via
14868            // {@code buildPivotSelect}'s own guards. A bare `SELECT *` over a
14869            // pivot in a predicate body is rejected EARLIER by
14870            // {@link #preflightExistsInnerShape}'s slice-23 / 24 single-column
14871            // contract — the preflight {@code "*"} check fires before this
14872            // routing site is reached. PIVOT-then-JOIN stays on the normal
14873            // path because {@code isPivotSelect} requires
14874            // `items.size() == 0`. A subquery-source pivot in a predicate body
14875            // is rejected by the slice-24 belt-and-braces relation-kind walk
14876            // BELOW (admits TABLE / CTE only) via the existing
14877            // {@code ctx.existsInnerRelationUnknown} code.
14878            //
14879            // Direct analogue of slice 138 ({@link #processDirectSubqueryTable}
14880            // FROM-subquery body), slice 139 (the four CTE walkers), and
14881            // slice 140 ({@link #buildSetOpProgramInternal} set-op branch).
14882            // This is the SIXTH (and final) SemanticIRBuilder routing site
14883            // that previously missed the pivot router.
14884            //
14885            // Slice-23 contract preserved: the predicate body remains
14886            // UNREACHABLE from outer (no outer relation points at it; no
14887            // STATEMENT_OUTPUT edge from outer to the body). The body's own
14888            // {@link #emitLineageForStatement} call BELOW emits the pivot
14889            // output -> source edges (TABLE_COLUMN for base-table source,
14890            // STATEMENT_OUTPUT for CTE source when the body sits inside an
14891            // outer-WITH). For correlation purposes (MERGE per-WHEN action
14892            // WHERE — slice 118), the pivot body's outputs / consumed-refs are
14893            // built directly against the LOCAL source alias by
14894            // {@code buildPivotSelect}; any outer-alias ref inside the inner
14895            // would surface via the post-build correlation walk below (the
14896            // walk traverses `innerStmt`'s clause-ref slots, which for a pivot
14897            // body are exactly the pivot's local TABLE / CTE relation).
14898            innerStmt = buildPivotSelect(inner, effectiveProvider, predName);
14899        } else {
14900            innerStmt = buildSelectStatementImpl(inner, effectiveProvider, predName,
14901                /*hasOuterCteListAlreadyProcessed=*/ false,
14902                /*allowFromSubqueries=*/ false,
14903                /*allowScalarProjectionSubqueries=*/ false,
14904                /*allowWindowProjection=*/ false,
14905                /*allowJoinOnPredicateSubqueries=*/ false,
14906                /*stmtsForExtraction=*/ null,
14907                /*lineageForExtraction=*/ null,
14908                /*cteMapForExtraction=*/ null,
14909                /*isPredicateBody=*/ true,
14910                /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE,
14911                /*allowWherePredicateSubqueries=*/ false);
14912        }
14913
14914        // Slice 24 (codex impl-review SHOULD 1): defensive relation-kind
14915        // walk. The preflight rejects FROM-subqueries; the post-build
14916        // correlation check below rejects OUTER_REFERENCE relations
14917        // (synthesised by promoteCorrelatedRefsToOuterReference for
14918        // outer refs we don't see). Belt-and-braces: the predicate body
14919        // must contain ONLY TABLE or CTE-bound relations. SUBQUERY /
14920        // OUTER_REFERENCE / UNION leaking through here would mean the
14921        // emitLineageForStatement call below routes through code paths
14922        // (e.g. the SUBQUERY-alias map) that we deliberately pass empty,
14923        // producing a SemanticIRBuildException about an unregistered
14924        // alias. Failing fast here surfaces the architectural violation
14925        // with a slice-24-tuned message instead.
14926        for (RelationSource r : innerStmt.getRelations()) {
14927            RelationKind kind = r.getBinding().getKind();
14928            if (kind != RelationKind.TABLE && kind != RelationKind.CTE) {
14929                throw new SemanticIRBuildException(
14930                        Diagnostic.error(ctx.existsInnerRelationUnknown,
14931                        "EXISTS in " + ctx.clauseLabel + ": inner SELECT relation '"
14932                                + r.getAlias() + "' has unsupported binding kind "
14933                                + kind + "; only TABLE or CTE relations are admitted "
14934                                + "(slice 24 boundary)", null));
14935            }
14936        }
14937        // Post-build correlation check (codex round-1 MUST 2 + round-2 SHOULD 2).
14938        // Use the existing collectAllInnerRefs helper so clause coverage
14939        // stays in sync with promoteCorrelatedRefsToOuterReference.
14940        // Slice 24: collectAllInnerRefs includes OutputColumn.sources, so
14941        // a column-ref projection like `EXISTS (SELECT e.id FROM x)` where
14942        // `e` is the OUTER's alias trips the same correlation rejection —
14943        // no extra slice-24 code needed.
14944        //
14945        // Slice 118 — when correlationScope != null (MERGE per-WHEN action
14946        // WHERE only), instead of REJECTING outer-aliased refs we PROMOTE
14947        // them into synthesised OUTER_REFERENCE relations via
14948        // promoteCorrelatedRefsToOuterReference. Mirrors the slice-14 /
14949        // slice-117 pattern. Unknown outer aliases (not in target / USING
14950        // source / outer CTEs) still throw SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS
14951        // (the promoter's existing boundary; diagnostic message says
14952        // "scalar subquery" — acceptable cosmetic limitation, slice 117
14953        // precedent). The slice-118 lift covers refs landing in
14954        // collectAllInnerRefs clauses (output sources, filter, join,
14955        // groupBy, having, orderBy, distinctOn); the FILTER and WITHIN
14956        // GROUP walks BELOW remain active so outer-aliased refs hidden
14957        // inside FILTER subtrees or PG fn.withinGroup.orderBy still
14958        // reject with SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS
14959        // (codex round-1 Q2 BLOCKING preserved this boundary).
14960        Set<String> innerLocalAliases = new HashSet<>();
14961        for (RelationSource r : innerStmt.getRelations()) {
14962            innerLocalAliases.add(r.getAlias().toLowerCase(Locale.ROOT));
14963        }
14964        // R8 — DEGRADE GATE. A catalog-less correlated EXISTS / IN in a
14965        // top-level SELECT WHERE now analyzes (the correlation is surfaced
14966        // on the SEMI / ANTI_SEMI join entity) instead of failing with
14967        // *_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS. The degrade is narrowly
14968        // scoped: only SELECT_WHERE, only non-MERGE (correlationScope ==
14969        // null), only genuine EXISTS/IN semi-join shapes. JOIN-ON, scalar
14970        // comparison, ANY/ALL, FROM-subquery / scalar / set-op-branch /
14971        // CTE bodies, UPDATE/DELETE WHERE, and MERGE all keep the prior
14972        // correlation reject.
14973        boolean degradeCorrelated = correlationScope == null
14974                && ctx == PredicateClauseContext.SELECT_WHERE
14975                && semiJoinPolarity(wrapper) != null;
14976        if (correlationScope != null) {
14977            // Pass a descriptive "outerAlias" so the promoter's diagnostic
14978            // messages identify the MERGE predicate-body host context if
14979            // promotion fails on an unknown alias.
14980            innerStmt = promoteCorrelatedRefsToOuterReference(
14981                    innerStmt,
14982                    "<merge predicate subquery " + (stmts.size()) + ">",
14983                    correlationScope);
14984        } else {
14985            for (ColumnRef ref : collectAllInnerRefs(innerStmt)) {
14986                if (!innerLocalAliases.contains(ref.getRelationAlias().toLowerCase(Locale.ROOT))) {
14987                    if (degradeCorrelated
14988                            && isKnownOuterAlias(ref.getRelationAlias(), outerRelationAliases)) {
14989                        // A genuine correlation to an outer FROM relation:
14990                        // the ref stays in the inner block's filterColumnRefs
14991                        // and the outer↔inner pairing is surfaced on the
14992                        // semi-join entity via buildSemiJoinFact. No throw.
14993                        // codex R8 [P1 round 2]: an alias that is NEITHER
14994                        // inner-local NOR a known outer relation (a typo like
14995                        // `z`) still rejects below.
14996                        continue;
14997                    }
14998                    throw new SemanticIRBuildException(
14999                            Diagnostic.error(ctx.existsCorrelatedUnknownOuterAlias,
15000                            "EXISTS in " + ctx.clauseLabel + ": correlated reference to outer alias '"
15001                                    + ref.getRelationAlias()
15002                                    + "' is not supported yet (slice 23 accepts uncorrelated EXISTS only)", null));
15003                }
15004            }
15005        }
15006        // Slice 28: projection-only FILTER-aware correlation walk. The
15007        // slice-28 source-skip in buildOutputColumns removes column refs
15008        // inside FILTER (WHERE ...) subtrees from OutputColumn.sources, so
15009        // a correlated FILTER ref in the inner projection (e.g.
15010        // `EXISTS (SELECT SUM(x.s) FILTER (WHERE e.region='EU') FROM x)`
15011        // where `e` is the outer alias) would slip past the loop above.
15012        // Existing collectAllInnerRefs continues to cover correlated
15013        // FILTER refs landing in inner WHERE / HAVING / GROUP BY / ORDER BY
15014        // / JOIN-ON because those clauses still collect via plain
15015        // collectColumnRefs which descends into FILTER subtrees.
15016        TResultColumnList rclForFilterWalk = inner.getResultColumnList();
15017        if (rclForFilterWalk != null) {
15018            for (int rci = 0; rci < rclForFilterWalk.size(); rci++) {
15019                TResultColumn rc = rclForFilterWalk.getResultColumn(rci);
15020                Set<TExpression> filterClauses = collectFilterClauses(rc);
15021                for (TExpression fclause : filterClauses) {
15022                    // Slice 118 — use effectiveProvider so under
15023                    // correlationScope != null, outer-aliased refs come
15024                    // back as synthetic EXACT_MATCH ColumnRefs (rather
15025                    // than throwing on tolerant fallback being absent).
15026                    // The alias-membership rejection below then fires
15027                    // for outer-aliased FILTER-inner refs, preserving
15028                    // the slice-118 boundary (codex round-1 Q2 BLOCKING
15029                    // fix: FILTER-inner correlation still rejects).
15030                    for (ColumnRef ref : collectColumnRefs(fclause, effectiveProvider)) {
15031                        boolean nonLocal = !innerLocalAliases.contains(
15032                                ref.getRelationAlias().toLowerCase(Locale.ROOT));
15033                        boolean degradeThisRef = degradeCorrelated
15034                                && isKnownOuterAlias(ref.getRelationAlias(),
15035                                        outerRelationAliases);
15036                        if (nonLocal && !degradeThisRef) {
15037                            // R8: the SELECT_WHERE semi-join degrade skips a
15038                            // genuine outer-relation correlation here; every
15039                            // other context (and unknown aliases) still reject.
15040                            throw new SemanticIRBuildException(
15041                                    Diagnostic.error(ctx.existsCorrelatedUnknownOuterAlias,
15042                                    "EXISTS in " + ctx.clauseLabel + ": correlated reference to outer alias '"
15043                                            + ref.getRelationAlias()
15044                                            + "' inside FILTER (WHERE ...) is not supported yet", null));
15045                        }
15046                    }
15047                }
15048            }
15049        }
15050        // Slice 30: projection-only direct WITHIN GROUP ORDER BY correlation
15051        // walk. PostgreSQL attaches WITHIN GROUP to fn.withinGroup directly,
15052        // and TFunctionCall.acceptChildren does NOT descend into that field —
15053        // so collectColumnRefs (and therefore collectAllInnerRefs above) is
15054        // blind to outer references inside `fn.withinGroup.orderBy`. A
15055        // correlated reference like
15056        // `mode() WITHIN GROUP (ORDER BY e.region)` (where `e` is the
15057        // outer alias) would slip past the slice-23 correlation loop above.
15058        // Catch it explicitly with a per-result-column WG ORDER BY scan.
15059        // Mirrors the slice-28 FILTER walk pattern. Also closes the same
15060        // correlation gap retroactively for slice-29-admitted aggregates
15061        // (LISTAGG / STRING_AGG / GROUP_CONCAT / ARRAY_AGG WITHIN GROUP),
15062        // see Slice30Test.pgCorrelatedListaggWithinGroupOrderByNowAlsoRejected.
15063        //
15064        // IMPORTANT: this walk uses a qualifier-only collector
15065        // ({@link #collectQualifierAliases}) instead of
15066        // {@link #collectColumnRefs}. Resolver2 also doesn't attach
15067        // ResolutionResult to TObjectName nodes inside PG's direct
15068        // fn.withinGroup field (it shares the AST asymmetry that lets
15069        // slice 29 admit these without a source-skip). Going through
15070        // {@code collectColumnRefs} → {@code provider.bindColumn} would
15071        // throw {@code non-exact column bindings} on legitimate
15072        // non-correlated refs (status=NOT_FOUND because Resolver2 skipped
15073        // them). The qualifier-only collector reads the qualifier alias
15074        // straight off the TObjectName, which matches slice-23's
15075        // correlation invariant: only qualified refs that name an outer
15076        // alias are caught — unqualified refs remain a documented
15077        // schema-less limitation.
15078        TResultColumnList rclForWgWalk = inner.getResultColumnList();
15079        if (rclForWgWalk != null) {
15080            for (int rci = 0; rci < rclForWgWalk.size(); rci++) {
15081                TResultColumn rc = rclForWgWalk.getResultColumn(rci);
15082                Set<TOrderBy> wgOrderBys = collectDirectWithinGroupOrderBys(rc);
15083                for (TOrderBy wgOrderBy : wgOrderBys) {
15084                    for (String alias : collectQualifierAliases(wgOrderBy)) {
15085                        boolean nonLocal = !innerLocalAliases.contains(
15086                                alias.toLowerCase(Locale.ROOT));
15087                        boolean degradeThisRef = degradeCorrelated
15088                                && isKnownOuterAlias(alias, outerRelationAliases);
15089                        if (nonLocal && !degradeThisRef) {
15090                            // R8: the SELECT_WHERE semi-join degrade skips a
15091                            // genuine outer-relation correlation here.
15092                            throw new SemanticIRBuildException(
15093                                    Diagnostic.error(ctx.existsCorrelatedUnknownOuterAlias,
15094                                    "EXISTS in " + ctx.clauseLabel + ": correlated reference to outer alias '"
15095                                            + alias
15096                                            + "' inside WITHIN GROUP (ORDER BY ...) is not supported yet", null));
15097                        }
15098                    }
15099                }
15100            }
15101        }
15102        int idx = stmts.size();
15103        stmts.add(innerStmt);
15104        // Slice 24: emit lineage edges for the predicate body. For
15105        // constant-only inner (slice-23 carryover), the synthetic
15106        // OutputColumn has empty sources and emitLineageForStatement
15107        // emits zero edges — no shape change for slice 23. For
15108        // column-ref inner (slice 24), the real OutputColumn carries
15109        // one ColumnRef source pointing at the inner's local relation;
15110        // emitLineageForStatement emits a STATEMENT_OUTPUT → TABLE_COLUMN
15111        // edge (TABLE-bound inner) or STATEMENT_OUTPUT → STATEMENT_OUTPUT
15112        // edge (CTE-bound inner) that the projector's slice-24 pass uses
15113        // to resolve the JOIN canonical edge.
15114        //
15115        // SUBQUERY map is empty: inner-shape preflight rejects FROM-subqueries.
15116        // ScalarInfo map is empty: inner-shape preflight rejects scalar
15117        // projections (and column-ref projection is single-source, not a
15118        // scalar-subquery extraction).
15119        // Slice 118 — pass the enclosing scope's flattened SUBQUERY-alias
15120        // map under correlation mode so OUTER_REFERENCE-of-SUBQUERY refs
15121        // resolve to the enclosing MERGE's USING-subquery statement index
15122        // for cross-stmt lineage emission (mirrors slice 117 UPDATE-side
15123        // emit dispatch).
15124        Map<String, Integer> subqueryAliasMap = (correlationScope != null)
15125                ? correlationScope.flattenSubqueryAliasToIndex()
15126                : Collections.<String, Integer>emptyMap();
15127        emitLineageForStatement(innerStmt, idx, lineage,
15128                cteMapForExtraction,
15129                subqueryAliasMap,
15130                Collections.<Integer, ScalarInfo>emptyMap());
15131        // The predicate body remains UNREACHABLE from outer: no relation
15132        // in outer points at it, and no STATEMENT_OUTPUT lineage edge has
15133        // it as its `to`. Inner WHERE / inner JOIN refs of the predicate
15134        // body therefore cannot enter outer's row-influence walker. The
15135        // slice-24 projector pass iterates predicate bodies directly via
15136        // `isPredicateSubquerySyntheticName` to emit JOIN canonical edges
15137        // from their OutputColumn sources only (slice-24 §4.2.1).
15138        //
15139        // R8 — record the semi-join fact (EXISTS/IN → SEMI, NOT EXISTS/NOT
15140        // IN → ANTI_SEMI) so JoinGraph assembly can emit a JoinEntity
15141        // linking the outer relation to this lifted subquery block.
15142        if (semiFactsOut != null) {
15143            SemiJoinFact fact = buildSemiJoinFact(wrapper, extractableNode, inner,
15144                    innerStmt, idx, innerStmt.getName(), innerLocalAliases,
15145                    effectiveProvider);
15146            if (fact != null) {
15147                semiFactsOut.add(fact);
15148            }
15149        }
15150        return idx;
15151    }
15152
15153    /**
15154     * Slice 23: inner-shape preflight for an extracted EXISTS body. See
15155     * roadmap §14.25 (slice-23 plan §4.4) for the full reasoning.
15156     */
15157    private static void preflightExistsInnerShape(TSelectSqlStatement inner) {
15158        // (a) No set-op
15159        if (inner.getSetOperatorType() != null
15160                && inner.getSetOperatorType() != ESetOperatorType.none) {
15161            throw new SemanticIRBuildException(
15162                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_IS_SET_OP,
15163                    "EXISTS in JOIN ON: inner SELECT may not be a set operation", inner));
15164        }
15165        // (b) No nested CTE list
15166        if (inner.getCteList() != null && inner.getCteList().size() > 0) {
15167            throw new SemanticIRBuildException(
15168                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_WITH,
15169                    "EXISTS in JOIN ON: inner SELECT may not have its own WITH clause", inner));
15170        }
15171        // (c) No row-limit (delegated to rejectUnsupportedShape's row-limit
15172        //     guards which fire during buildSelectStatement). For an early,
15173        //     specific message we also fast-fail here.
15174        if (inner.getLimitClause() != null
15175                || inner.getTopClause() != null
15176                || inner.getFetchFirstClause() != null
15177                || inner.getOffsetClause() != null) {
15178            throw new SemanticIRBuildException(
15179                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_ROW_LIMIT,
15180                    "EXISTS in JOIN ON: inner SELECT may not have a row-limit clause", inner));
15181        }
15182        // (d) Inner FROM is required (codex round-2 MUST 2).
15183        if (inner.joins == null || inner.joins.size() == 0) {
15184            throw new SemanticIRBuildException(
15185                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_MISSING_FROM,
15186                    "EXISTS in JOIN ON: inner SELECT must have a FROM clause "
15187                            + "(degenerate EXISTS (SELECT 1) is not in scope)", inner));
15188        }
15189        // Slice 62 (codex plan-review round 1, P2 #1): predicate bodies
15190        // are built with allowFromSubqueries=false, so the gated reject
15191        // inside buildRelations also fires; we surface a slice-23
15192        // tuned message here so callers see the predicate-body shape
15193        // diagnostic before the generic comma-FROM message.
15194        if (inner.joins.size() > 1) {
15195            throw new SemanticIRBuildException(
15196                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_COMMA_FROM,
15197                    "EXISTS in JOIN ON: comma-separated FROM list "
15198                            + "(implicit cross join) in inner SELECT is not supported yet", inner));
15199        }
15200        // Slice 63: predicate body must not contain explicit CROSS JOIN
15201        // either. Surfaces a predicate-body-tuned diagnostic before the
15202        // gated reject inside buildRelations would fire with the
15203        // generic "scalar / set-op-branch / set-op-CTE / predicate"
15204        // message. The same shared preflight is used by EXISTS /
15205        // IN-SELECT / cmp-subquery / ANY-ALL-SOME wrappers.
15206        // Slice 64: same treatment for JOIN ... USING.
15207        for (TJoin j : inner.joins) {
15208            TJoinItemList items = j.getJoinItems();
15209            if (items == null) continue;
15210            for (int i = 0; i < items.size(); i++) {
15211                TJoinItem item = items.getJoinItem(i);
15212                if (item == null) continue;
15213                if (item.getJoinType() == EJoinType.cross) {
15214                    throw new SemanticIRBuildException(
15215                            Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_CROSS_JOIN,
15216                            "EXISTS in JOIN ON: CROSS JOIN in inner SELECT "
15217                                    + "is not supported yet", null));
15218                }
15219                if (item.getUsingColumns() != null
15220                        && item.getUsingColumns().size() > 0) {
15221                    throw new SemanticIRBuildException(
15222                            Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_USING,
15223                            "EXISTS in JOIN ON: JOIN ... USING (...) in inner "
15224                                    + "SELECT is not supported yet", null));
15225                }
15226                // Slice 66: NATURAL JOIN inside an EXISTS-style predicate
15227                // body is rejected with an EXISTS-tuned diagnostic. The
15228                // gated reject inside buildRelations would fire later
15229                // with the generic body-context message; surfacing here
15230                // gives users an EXISTS / IN-SELECT / cmp-subquery
15231                // friendly error.
15232                if (isNaturalJoinType(item.getJoinType())) {
15233                    throw new SemanticIRBuildException(
15234                            Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_NATURAL,
15235                            "EXISTS in JOIN ON: NATURAL JOIN in inner SELECT "
15236                                    + "is not supported yet", null));
15237                }
15238            }
15239        }
15240        // (d') No FROM-subquery on inner FROM/JOIN list. The recursive build
15241        //     passes allowFromSubqueries=false, so buildRelation would also
15242        //     reject; we surface a slice-23 specific message here.
15243        for (TJoin j : inner.joins) {
15244            if (j.getTable() != null
15245                    && j.getTable().getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
15246                throw new SemanticIRBuildException(
15247                        Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_FROM_SUBQUERY,
15248                        "EXISTS in JOIN ON: FROM-clause subquery in inner SELECT is not supported yet", null));
15249            }
15250            TJoinItemList items = j.getJoinItems();
15251            if (items == null) continue;
15252            for (int i = 0; i < items.size(); i++) {
15253                TTable r = items.getJoinItem(i).getTable();
15254                if (r != null && r.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
15255                    throw new SemanticIRBuildException(
15256                            Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_FROM_SUBQUERY_ON_JOIN,
15257                            "EXISTS in JOIN ON: FROM-clause subquery on JOIN side in inner SELECT is not supported yet", null));
15258                }
15259            }
15260        }
15261        // (e) Result-column list: exactly one column projecting either a
15262        //     constant expression (slice 23), a single column reference
15263        //     (slice 24), an expression / function call / CASE /
15264        //     aggregate over inner columns (slice 27), an aggregate with
15265        //     FILTER (WHERE ...) over inner columns (slice 28), or — on
15266        //     PostgreSQL only — a whitelisted WITHIN GROUP aggregate
15267        //     (slice 29 admits LISTAGG / STRING_AGG / GROUP_CONCAT /
15268        //     ARRAY_AGG / count / sum / avg / min / max / stddev /
15269        //     variance family; slice 30 extends with `mode`).
15270        //     Multi-column / star / window function / scalar subquery /
15271        //     non-whitelisted WITHIN GROUP aggregate projections are
15272        //     rejected with shape-specific tuned messages — see
15273        //     {@link #findUnsupportedWithinGroupFunctionName} for the
15274        //     vendor + name gate.
15275        TResultColumnList rcl = inner.getResultColumnList();
15276        if (rcl == null || rcl.size() != 1) {
15277            throw new SemanticIRBuildException(
15278                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_COLUMN_COUNT,
15279                    "EXISTS in JOIN ON: inner SELECT must project exactly one column, got "
15280                            + (rcl == null ? 0 : rcl.size()), null));
15281        }
15282        TResultColumn rc0 = rcl.getResultColumn(0);
15283        if ("*".equals(rc0.getColumnNameOnly())) {
15284            throw new SemanticIRBuildException(
15285                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_NON_CONSTANT_PROJECTION,
15286                    "EXISTS in JOIN ON: inner SELECT must project a constant expression "
15287                            + "or a single column reference, got SELECT *", null));
15288        }
15289        TExpression projExpr = rc0.getExpr();
15290        if (projExpr == null || !isAdmittedPredicateProjection(projExpr)) {
15291            throw new SemanticIRBuildException(
15292                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_NON_CONSTANT_PROJECTION,
15293                    "EXISTS in JOIN ON: inner SELECT must project a constant expression "
15294                            + "(e.g. SELECT 1), a single column reference "
15295                            + "(e.g. SELECT x.id), an expression / function call / "
15296                            + "CASE / aggregate over inner columns (e.g. SELECT x.id + 1, "
15297                            + "UPPER(x.region), MAX(x.id), CASE WHEN ...), an aggregate "
15298                            + "with FILTER (WHERE ...) over inner columns "
15299                            + "(e.g. SUM(x.id) FILTER (WHERE x.region = 'EU')), or a "
15300                            + "WITHIN GROUP (ORDER BY ...) aggregate over inner columns "
15301                            + "(PostgreSQL admits the direct fn.withinGroup attachment; "
15302                            + "Oracle and SQL Server admit the windowDef.withinGroup "
15303                            + "attachment via slice 31 when no OVER clause is present "
15304                            + "— see TWindowDef.isIncludingOverClause(); slice 44 also "
15305                            + "admits Snowflake hypothetical-set ordered-set aggregates "
15306                            + "(rank / dense_rank / percent_rank / cume_dist) via direct "
15307                            + "fn.withinGroup attachment); DB2 / Snowflake LISTAGG / "
15308                            + "STRING_AGG WITHIN GROUP remain rejected pending a probe "
15309                            + "of their parser-specific argument storage; window "
15310                            + "functions (any OVER-bearing form) and scalar subqueries "
15311                            + "are not supported yet (slice 31 boundary)", null));
15312        }
15313        // Slice 29 / Slice 31: vendor-gated WITHIN GROUP rejecter.
15314        //
15315        // Two attachment styles, gated by vendor:
15316        //   * Direct attachment ({@code fn.getWithinGroup()}): PG admits
15317        //     because its visitor descent (TFunctionCall.acceptChildren)
15318        //     does NOT walk fn.withinGroup, leaving OutputColumn.sources
15319        //     populated exactly with the function's column-bearing args.
15320        //     Snowflake and DB2 use the same field but their parser-
15321        //     specific arg storage (DB2's stringExpr / separatorExpr for
15322        //     LISTAGG) may not be visitor-visible — silently-empty
15323        //     sources while dlineage walks fdd to the base column =
15324        //     manufactured IR_MISSING_DEPENDENCY divergence; rejected.
15325        //   * WindowDef attachment ({@code fn.getWindowDef().getWithinGroup()}
15326        //     with WITHIN-GROUP-only windowDef): Oracle / MSSQL admit via
15327        //     slice 31. The visitor DOES descend through
15328        //     {@code windowDef.withinGroup.orderBy}, so the slice-31
15329        //     source-skip in
15330        //     {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}
15331        //     keeps OutputColumn.sources from leaking the WITHIN GROUP
15332        //     ORDER BY column refs (probe Q1 / Q3 / Q4 / Q5 in
15333        //     {@code /tmp/probe31}).
15334        //
15335        // Probed: PG (Q1, Q5, Q6, Q9, Q10), Oracle (Q1-Q5 in
15336        // {@code /tmp/probe31}), MSSQL (Q11-Q12), SparkSQL (parser drops
15337        // WITHIN GROUP attachment, so containsAggregateWithWithinGroup
15338        // returns false and the lift applies).
15339        if (containsAggregateWithWithinGroup(projExpr)) {
15340            EDbVendor v = inner.dbvendor;
15341            // Slice 44 / 45: Snowflake admitted at this gate ONLY when
15342            // every WITHIN GROUP-bearing call in the inner projection
15343            // is an admitted Snowflake direct-attachment shape:
15344            //   * hypothetical-set (rank / dense_rank / percent_rank /
15345            //     cume_dist with fn.getWithinGroup()!=null and
15346            //     fn.getWindowDef()==null) — slice 44; or
15347            //   * mode() with the same direct-attachment shape — slice 45.
15348            // Snowflake LISTAGG / STRING_AGG / percentile_cont /
15349            // percentile_disc share the direct-attachment shape but
15350            // their parser-specific argument storage (stringExpr /
15351            // separatorExpr) and / or name-whitelist exclusion keep
15352            // the slice-31/44 rejection (see Slice44Test §C and
15353            // Slice45Test §C boundary tests).
15354            boolean snowflakeAdmittedShape = (v == EDbVendor.dbvsnowflake)
15355                    && allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment(projExpr);
15356            if (v != EDbVendor.dbvpostgresql
15357                    && v != EDbVendor.dbvoracle
15358                    && v != EDbVendor.dbvmssql
15359                    && !snowflakeAdmittedShape) {
15360                throw new SemanticIRBuildException(
15361                        Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_WITHIN_GROUP_AGGREGATE,
15362                        "EXISTS in JOIN ON: WITHIN GROUP aggregate inner projection on "
15363                                + "vendor=" + v
15364                                + " is not supported yet — slice 31 admits PostgreSQL "
15365                                + "(direct fn.withinGroup attachment), Oracle, and "
15366                                + "SQL Server (windowDef.withinGroup attachment with "
15367                                + "isIncludingOverClause=false); slice 44 additionally "
15368                                + "admits Snowflake hypothetical-set ordered-set "
15369                                + "aggregates (rank / dense_rank / percent_rank / "
15370                                + "cume_dist) via direct fn.withinGroup attachment; "
15371                                + "slice 45 additionally admits Snowflake mode() via "
15372                                + "the same direct attachment; "
15373                                + "DB2 / Snowflake LISTAGG / STRING_AGG / "
15374                                + "percentile_cont / other direct-attachment vendors "
15375                                + "remain rejected pending a probe of their parser-"
15376                                + "specific argument storage", null));
15377            }
15378            // Codex impl-review round-3 MUST: name-whitelist guard. The PG
15379            // parser attaches WITHIN GROUP to generic `func_application`,
15380            // not only to whitelisted aggregate names. Without this check,
15381            // a non-whitelisted call like `foo(x.id) WITHIN GROUP (...)`
15382            // would slip through. Slice 31: same protection applies on
15383            // Oracle / MSSQL — the windowDef-attachment grammar admits
15384            // any function name (PERCENTILE_CONT, RANK, user-defined
15385            // foo); the name guard rejects them so the IR never sees a
15386            // shape whose canonical model is unverified.
15387            String unsupportedName = findUnsupportedWithinGroupFunctionName(
15388                    projExpr, inner.dbvendor);
15389            if (unsupportedName != null) {
15390                throw new SemanticIRBuildException(
15391                        Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_WITHIN_GROUP_NON_WHITELISTED,
15392                        "EXISTS in JOIN ON: WITHIN GROUP attached to non-whitelisted "
15393                                + "function '" + unsupportedName + "' is not supported yet — "
15394                                + "slice 31 admits whitelisted aggregates only "
15395                                + "(see SemanticIRBuilder.AGGREGATE_FUNCTION_NAMES); "
15396                                + "slice 43 additionally admits PostgreSQL hypothetical-set "
15397                                + "ordered-set aggregates (rank / dense_rank / percent_rank / "
15398                                + "cume_dist) via direct fn.getWithinGroup attachment", null));
15399            }
15400        }
15401        // (f) No subqueries in inner WHERE / inner JOIN ON / inner GROUP BY /
15402        //     inner HAVING / inner ORDER BY. Reuses the slice-11 helper
15403        //     style with a slice-23-specific message prefix.
15404        rejectSubqueriesInPredicateBodyClauses(inner);
15405        // (g) Window functions are caught by the rejecters that fire inside
15406        //     buildSelectStatement (rejectWindowFunctionInScope on WHERE /
15407        //     GROUP BY / HAVING / ORDER BY); the inner projection itself is
15408        //     a constant expression and cannot contain a window call.
15409    }
15410
15411    /**
15412     * Slice 27: true iff {@code e} is an admitted predicate-subquery inner
15413     * projection shape. Admits (in priority order):
15414     * <ul>
15415     *   <li>{@link EExpressionType#simple_object_name_t} — single column
15416     *       ref (slice 24 carryover); one JOIN canonical edge per inner-
15417     *       column lineage terminal.</li>
15418     *   <li>{@link #isConstantExpression}-shaped constant (slice-23
15419     *       carryover); zero canonical contribution.</li>
15420     *   <li>Slice 27 widenings via {@link #isAdmittedSlice27ShapeRoot}:
15421     *       expression / function call / CASE / aggregate over inner
15422     *       columns. Probes 27 / 27b confirmed dlineage's
15423     *       {@code fdr clause="on"} canonical model walks fdd to the
15424     *       underlying base columns identically to the IR's
15425     *       slice-24 predicate-body sweep — so canonical equivalence
15426     *       holds. Aggregate-over-constants (e.g. {@code COUNT(*)},
15427     *       {@code SUM(1)}) produce empty {@code OutputColumn.sources}
15428     *       and zero predicate-body JOIN edges; canonical-equivalent
15429     *       to the slice-23 constant projection.</li>
15430     * </ul>
15431     * Hard rejecters fire BEFORE the {@link #isAdmittedSlice27ShapeRoot}
15432     * admit-list to keep the surface tight:
15433     * <ul>
15434     *   <li>{@link #containsAnySubqueryExpression} — slice-23 invariant.</li>
15435     *   <li>{@link #containsWindowFunction} — slice-13 invariant.
15436     *       Slice 31 narrowed the rejecter via {@link #isWindowDefBearingFunction}
15437     *       so a WITHIN-GROUP-only windowDef (Oracle / MSSQL plain
15438     *       WITHIN GROUP attachment) is NOT classified as a window
15439     *       function. Real OVER-bearing windowDef shapes ({@code OVER ()},
15440     *       {@code OVER (PARTITION BY ...)}, KEEP DENSE_RANK) continue to
15441     *       fire the rejecter. The complementary slice-31 source-skip in
15442     *       {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}
15443     *       removes the WITHIN GROUP ORDER BY column refs from
15444     *       {@code OutputColumn.sources} on Oracle / MSSQL so the visitor's
15445     *       descent through {@code windowDef.withinGroup.orderBy} doesn't
15446     *       leak into projection sources.</li>
15447     *   <li>Slice 29 / 31's vendor-gated WITHIN GROUP rejecter at the
15448     *       {@link #preflightExistsInnerShape} call site — see that
15449     *       method's vendor gate. Slice 31 admits Oracle and MSSQL
15450     *       (windowDef.withinGroup attachment with
15451     *       {@code !isIncludingOverClause()}) alongside PostgreSQL.
15452     *       Snowflake and DB2 both attach {@code WITHIN GROUP} to the
15453     *       direct {@code fn.getWithinGroup()} field (same as PG), but
15454     *       their parser-specific argument storage may not be
15455     *       visitor-visible (DB2 stores LISTAGG args in
15456     *       {@code stringExpr} / {@code separatorExpr}, which
15457     *       {@code TFunctionCall.acceptChildren} does NOT walk); they
15458     *       remain rejected. SparkSQL silently drops the WITHIN GROUP
15459     *       attachment at parse time (both {@code fn.withinGroup} and
15460     *       {@code fn.windowDef} are null); after slice 29 SparkSQL
15461     *       admits the same shape as PG, parity-friendly per probe Q1
15462     *       SparkSQL.</li>
15463     * </ul>
15464     *
15465     * <p>Slice 28 lifted the prior {@code containsAggregateWithFilter}
15466     * rejecter; FILTER aggregates are now admitted, with the FILTER
15467     * predicate column refs excluded from {@code OutputColumn.sources}
15468     * globally via the FILTER-aware variant of {@link #collectColumnRefs}
15469     * used in {@link #buildOutputColumns}. See the slice-28 entry in
15470     * §14.5 of the unified roadmap and §B / §C of the slice history
15471     * archive for the load-bearing decision.
15472     *
15473     * <p>Slice 29 lifted the prior unconditional
15474     * {@code containsAggregateWithWithinGroup} rejecter and replaced it
15475     * with a vendor-gated rejecter at the
15476     * {@link #preflightExistsInnerShape} call site (Snowflake / DB2 /
15477     * other non-PostgreSQL vendors that use the direct
15478     * {@code fn.getWithinGroup()} attachment remain rejected). PG
15479     * attaches {@code WITHIN GROUP} to the direct
15480     * {@code fn.getWithinGroup()} field, and
15481     * {@code TFunctionCall.acceptChildren} does NOT descend into that
15482     * field, so {@link #collectColumnRefs} never picks up the ORDER BY
15483     * column refs — no source-skip is needed. dlineage probes Q1–Q10
15484     * confirmed canonical-model JOIN-on edges include only the aggregate's
15485     * primary argument across all four vendors (the WITHIN GROUP ORDER
15486     * BY ref appears as {@code fdr clauseType="orderby"} on PG only, and
15487     * {@code DlineageXmlProjector.projectColumn} follows fdd not fdr).
15488     * Slice 29 is restricted to whitelisted aggregates whose names
15489     * appear in {@link #AGGREGATE_FUNCTION_NAMES}. As of slice 30 the
15490     * whitelist is: {@code count}, {@code sum}, {@code avg}, {@code min},
15491     * {@code max}, {@code stddev}, {@code variance}, {@code var_samp},
15492     * {@code var_pop}, {@code stddev_samp}, {@code stddev_pop},
15493     * {@code listagg}, {@code string_agg}, {@code group_concat},
15494     * {@code array_agg}, {@code mode} (slice-30 addition — PG
15495     * ordered-set aggregate, gated for the WITHIN GROUP path only;
15496     * see {@code DlineageXmlProjector.ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES}). The predicate-body short-circuit's
15497     * {@code aggregate=true} branch fires for these regardless of
15498     * {@code OutputColumn.sources} content — column-bearing args
15499     * (e.g. {@code LISTAGG(x.id, ',')}) produce a synthesized
15500     * {@code OutputColumn} with {@code sources=[x.id]} that the slice-24
15501     * sweep walks to base-column terminals, while literal-only args
15502     * (e.g. {@code LISTAGG('hello', ',')}) produce {@code sources=[]}
15503     * with zero JOIN canonical edges — canonically equivalent to
15504     * slice-23's constant projection.
15505     *
15506     * <p>Functions NOT in the whitelist (which on PG includes
15507     * {@code percentile_cont}, {@code percentile_disc}, {@code rank},
15508     * {@code dense_rank}, {@code percent_rank}, {@code cume_dist}, plus
15509     * any user-defined function with a direct {@code fn.withinGroup}
15510     * attachment) remain rejected by the
15511     * {@link #findUnsupportedWithinGroupFunctionName} guard at the
15512     * {@link #preflightExistsInnerShape} call site. Slice 30 lifted
15513     * {@code mode} only — the one PG ordered-set aggregate with no
15514     * documented window form in any GSP-supported vendor. Lifting
15515     * {@code percentile_cont} / {@code percentile_disc} requires either
15516     * a vendor-scoped projector OR a structural discriminator strong
15517     * enough to distinguish the cross-vendor windowed forms (Redshift /
15518     * Vertica / BigQuery / Oracle / SQL Server emit
15519     * {@code PERCENTILE_CONT WITHIN GROUP OVER (...)} variants). Lifting
15520     * {@code rank}/{@code dense_rank}/{@code percent_rank}/{@code cume_dist}
15521     * requires distinguishing window form {@code RANK() OVER (ORDER BY)}
15522     * from hypothetical-set form {@code rank(0.5) WITHIN GROUP (ORDER BY)}
15523     * — dlineage XML for the two is structurally identical on PG. See
15524     * §14.6 of the unified roadmap.
15525     */
15526    private static boolean isAdmittedPredicateProjection(TExpression e) {
15527        if (e == null) return false;
15528        if (e.getExpressionType() == EExpressionType.simple_object_name_t) {
15529            return true;                                          // slice 24 (column ref)
15530        }
15531        if (isConstantExpression(e)) return true;                 // slice 23 (constant)
15532        // Slice 27: hard rejecters before admit-list. Slice 28 lifted the
15533        // FILTER rejecter; slice 29 replaced the unconditional WITHIN
15534        // GROUP rejecter with a vendor-gated rejecter at the
15535        // preflightExistsInnerShape call site (see slice-29 §3.2).
15536        if (containsAnySubqueryExpression(e)) return false;       // slice 23 invariant
15537        if (containsWindowFunction(e)) return false;              // slice 13 invariant
15538        return isAdmittedSlice27ShapeRoot(e);
15539    }
15540
15541    /**
15542     * Slice 29: detect a {@code WITHIN GROUP (ORDER BY ...)} attachment
15543     * on the direct {@code fn.getWithinGroup()} field anywhere in the
15544     * subtree. This is the PG / Snowflake / DB2 attachment style. Used
15545     * as a vendor-gated rejecter in {@link #preflightExistsInnerShape}:
15546     * non-admitted vendors with this attachment remain rejected because
15547     * their parser-specific argument storage may not be visitor-visible
15548     * (DB2's {@code LISTAGG} stores args in {@code stringExpr} /
15549     * {@code separatorExpr}, which the default
15550     * {@code TFunctionCall.acceptChildren} does NOT walk —
15551     * {@code OutputColumn.sources} would be silently empty while dlineage
15552     * walks fdd to the base column, manufacturing
15553     * {@code IR_MISSING_DEPENDENCY} divergence).
15554     *
15555     * <p>Slice 31: also detects WITHIN GROUP attached to
15556     * {@code fn.getWindowDef().getWithinGroup()} when the windowDef is
15557     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} — the
15558     * Oracle / MSSQL attachment style. Both attachments are routed
15559     * through {@link #hasWithinGroupAnyAttachment}.
15560     */
15561    private static boolean containsAggregateWithWithinGroup(TExpression e) {
15562        if (e == null) return false;
15563        final boolean[] found = {false};
15564        e.acceptChildren(new TParseTreeVisitor() {
15565            @Override
15566            public void preVisit(TFunctionCall fn) {
15567                if (found[0]) return;
15568                if (hasWithinGroupAnyAttachment(fn)) found[0] = true;
15569            }
15570        });
15571        if (!found[0] && e.getExpressionType() == EExpressionType.function_t) {
15572            TFunctionCall fn = e.getFunctionCall();
15573            if (hasWithinGroupAnyAttachment(fn)) found[0] = true;
15574        }
15575        return found[0];
15576    }
15577
15578    /**
15579     * Slice 31: shared predicate used by {@link #containsAggregateWithWithinGroup}
15580     * and {@link #findUnsupportedWithinGroupFunctionName}. Returns
15581     * {@code true} iff {@code fn} carries {@code WITHIN GROUP} via
15582     * either:
15583     * <ul>
15584     *   <li>direct {@code fn.getWithinGroup()} field (PG / Snowflake /
15585     *       DB2 / SparkSQL parser style);</li>
15586     *   <li>{@code fn.getWindowDef().getWithinGroup()} when the
15587     *       windowDef is {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}
15588     *       (Oracle / MSSQL parser style).</li>
15589     * </ul>
15590     */
15591    private static boolean hasWithinGroupAnyAttachment(TFunctionCall fn) {
15592        if (fn == null) return false;
15593        if (fn.getWithinGroup() != null) return true;
15594        return isWithinGroupOnlyWindowDef(fn.getWindowDef());
15595    }
15596
15597    /**
15598     * Slice 29 (codex impl-review round-3 MUST): walk the expression
15599     * subtree and return the (lower-cased) function name of any
15600     * {@code TFunctionCall} that carries WITHIN GROUP — via direct
15601     * {@code fn.getWithinGroup()} (PG style) or via
15602     * {@code fn.getWindowDef().getWithinGroup()} when the windowDef is
15603     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} (Oracle /
15604     * MSSQL style; slice 31) — whose name is NOT in
15605     * {@link #AGGREGATE_FUNCTION_NAMES}. Returns {@code null} if every
15606     * WITHIN GROUP-bearing call uses a whitelisted aggregate name.
15607     * Used at the {@code preflightExistsInnerShape} call site to reject
15608     * {@code foo(x.id) WITHIN GROUP (...)}-shaped projections where
15609     * {@code foo} isn't an aggregate the IR knows how to model.
15610     *
15611     * <p>Slice 43: now takes the inner {@link EDbVendor} so the
15612     * {@link #isAdmittedWithinGroupName} delegate can apply the
15613     * PG-only hypothetical-set carve-out
15614     * ({@link #isDirectAttachmentHypotheticalSetCall}; widened to
15615     * Snowflake by slice 44).
15616     */
15617    private static String findUnsupportedWithinGroupFunctionName(
15618            TExpression e, final EDbVendor vendor) {
15619        if (e == null) return null;
15620        final String[] firstUnsupported = {null};
15621        e.acceptChildren(new TParseTreeVisitor() {
15622            @Override
15623            public void preVisit(TFunctionCall fn) {
15624                if (firstUnsupported[0] != null) return;
15625                if (!hasWithinGroupAnyAttachment(fn)) return;
15626                String name = fn.getFunctionName() == null
15627                        ? null : fn.getFunctionName().toString();
15628                if (isAdmittedWithinGroupName(fn, name, vendor)) return;
15629                firstUnsupported[0] = name == null ? "<unnamed>" : name;
15630            }
15631        });
15632        if (firstUnsupported[0] == null
15633                && e.getExpressionType() == EExpressionType.function_t) {
15634            TFunctionCall fn = e.getFunctionCall();
15635            if (hasWithinGroupAnyAttachment(fn)) {
15636                String name = fn.getFunctionName() == null
15637                        ? null : fn.getFunctionName().toString();
15638                if (!isAdmittedWithinGroupName(fn, name, vendor)) {
15639                    firstUnsupported[0] = name == null ? "<unnamed>" : name;
15640                }
15641            }
15642        }
15643        return firstUnsupported[0];
15644    }
15645
15646    /**
15647     * Slice 42 helper used by {@link #findUnsupportedWithinGroupFunctionName}.
15648     * Returns {@code true} iff {@code name} is in the regular
15649     * {@link #AGGREGATE_FUNCTION_NAMES} whitelist, OR — under the
15650     * AST-shape constraint
15651     * {@link #isHypotheticalSetWithinGroupCall} — in the slice-42
15652     * {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES} whitelist (Oracle /
15653     * MSSQL windowDef-bearing attachment), OR — under the slice-43
15654     * AST-shape constraint
15655     * {@link #isDirectAttachmentHypotheticalSetCall} — in the same
15656     * hypothetical-set whitelist on PostgreSQL (slice 43) or Snowflake
15657     * (slice 44) via direct {@code fn.getWithinGroup()} attachment.
15658     *
15659     * <p>The shape constraints pin the carve-outs by parser flavor:
15660     * Oracle / MSSQL produce {@code fn.getWindowDef()!=null} with
15661     * {@code wd.getWithinGroup()!=null} and {@code !wd.isIncludingOverClause()};
15662     * PG produces {@code fn.getWithinGroup()!=null} with
15663     * {@code fn.getWindowDef()==null}. Slice 43 admits that direct-
15664     * attachment hypothetical-set carve-out for PostgreSQL; slice 44
15665     * widens the same probe-confirmed shape to Snowflake. DB2 and other
15666     * direct-attachment vendors remain outside this helper until their
15667     * AST / dlineage parity is explicitly probed and covered.
15668     */
15669    private static boolean isAdmittedWithinGroupName(
15670            TFunctionCall fn, String name, EDbVendor vendor) {
15671        if (name == null || name.isEmpty()) return false;
15672        String lower = name.toLowerCase(Locale.ROOT);
15673        if (AGGREGATE_FUNCTION_NAMES.contains(lower)) return true;
15674        if (isHypotheticalSetWithinGroupCall(fn)) return true;
15675        return isDirectAttachmentHypotheticalSetCall(fn, vendor);
15676    }
15677
15678    /**
15679     * Slice 43 / 44: true iff {@code fn} is a direct-attachment
15680     * hypothetical-set ordered-set aggregate call shape — {@code rank} /
15681     * {@code dense_rank} / {@code percent_rank} / {@code cume_dist} with
15682     * {@code fn.getWithinGroup()!=null} AND {@code fn.getWindowDef()==null},
15683     * AND {@code vendor} is in {PostgreSQL, Snowflake}.
15684     *
15685     * <p>Used as a name-whitelist exception inside
15686     * {@link #isAdmittedWithinGroupName} for predicate-body inner
15687     * projections only. Top-level admission is deliberately not granted:
15688     * top-level lifting requires a vendor-scoped projector override
15689     * (slice 43 introduces the API but defers the override to a future
15690     * slice because PG / Snowflake dlineage XML is structurally
15691     * indistinguishable between the WG and OVER forms — naive override
15692     * breaks {@code rank() OVER (ORDER BY x)} classification).
15693     *
15694     * <p>Vendor gate: PG (slice 43) and Snowflake (slice 44 — probe-
15695     * confirmed AST + dlineage XML byte-identical to PG for all
15696     * four hypothetical-set names). DB2 / Greenplum / Redshift parse-fail
15697     * on the syntax. Other direct-attachment vendors (e.g. SparkSQL drops
15698     * WITHIN GROUP attachment at parse time) remain rejected pending a
15699     * fresh probe.
15700     *
15701     * <p>Probe: {@code /tmp/probe43/Probe43.java} (slice 43) and
15702     * {@code probe44.Probe44Test} (slice 44, captured during slice-44
15703     * implementation) confirmed the AST predicate matches PG / Snowflake
15704     * hypothetical-set forms (and not the OVER form), and confirmed the
15705     * dlineage XML for {@code EXISTS (SELECT rank(0.5) WITHIN GROUP
15706     * (ORDER BY x.salary) FROM locations x)} contributes zero base-table
15707     * edges from the inner predicate body (literal arg + WG ORDER BY ref
15708     * via {@code clauseType="orderby"} fdr that the projector's
15709     * {@code clauseTypeToRole} does not map to FILTER/JOIN). Both
15710     * projectors therefore agree on zero predicate-body lineage edges
15711     * for the slice-43 / slice-44 shape — no projector change required.
15712     */
15713    private static boolean isDirectAttachmentHypotheticalSetCall(
15714            TFunctionCall fn, EDbVendor vendor) {
15715        if (fn == null) return false;
15716        if (vendor != EDbVendor.dbvpostgresql
15717                && vendor != EDbVendor.dbvsnowflake) return false;
15718        if (!isDirectAttachmentHypotheticalSetCallShape(fn)) return false;
15719        return true;
15720    }
15721
15722    /**
15723     * Slice 44: vendor-agnostic shape predicate for the direct-attachment
15724     * hypothetical-set call form ({@code fn.getWithinGroup()!=null} AND
15725     * {@code fn.getWindowDef()==null} AND function name in
15726     * {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES}). Used together with
15727     * {@link #isDirectAttachmentModeCallShape} (slice 45) by
15728     * {@link #allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment} to
15729     * gate Snowflake admission. Snowflake LISTAGG / STRING_AGG /
15730     * percentile_cont WITHIN GROUP share this attachment style but their
15731     * parser-specific argument storage ({@code stringExpr} /
15732     * {@code separatorExpr}) and dlineage XML parity remain unprobed
15733     * (slice-31 boundary preserved).
15734     */
15735    private static boolean isDirectAttachmentHypotheticalSetCallShape(
15736            TFunctionCall fn) {
15737        if (fn == null) return false;
15738        if (fn.getWithinGroup() == null) return false;
15739        if (fn.getWindowDef() != null) return false;
15740        if (fn.getFunctionName() == null) return false;
15741        String name = fn.getFunctionName().toString();
15742        if (name == null || name.isEmpty()) return false;
15743        return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains(
15744                name.toLowerCase(Locale.ROOT));
15745    }
15746
15747    /**
15748     * Slice 45: vendor-agnostic shape predicate for the direct-attachment
15749     * {@code mode()} ordered-set aggregate call form
15750     * ({@code fn.getWithinGroup()!=null} AND
15751     * {@code fn.getWindowDef()==null} AND function name equals
15752     * {@code mode}). Parallel to
15753     * {@link #isDirectAttachmentHypotheticalSetCallShape}; used by
15754     * {@link #allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment}
15755     * to admit Snowflake {@code mode() WITHIN GROUP (ORDER BY ...)}
15756     * predicate-body inner projections.
15757     *
15758     * <p>Probe-confirmed (see {@code /tmp/Probe45c.java} captured during
15759     * slice-45 implementation): Snowflake parses {@code mode() WITHIN
15760     * GROUP (ORDER BY x.salary)} with {@code fn.getWithinGroup() != null}
15761     * and {@code fn.getWindowDef() == null}, identical to PG. The
15762     * Snowflake dlineage XML for the predicate-body wrapper shape is
15763     * byte-equivalent to PG (same {@code resultset name="mode"
15764     * type="function"} wrapper, same {@code orderby} fdr that
15765     * {@code clauseTypeToRole} does not map to FILTER/JOIN); the
15766     * canonical model has zero predicate-body lineage edges, matching
15767     * the IR side (mode has no args, default visitor descent does not
15768     * walk direct {@code fn.withinGroup}).
15769     *
15770     * <p>Why mode is admitted but Snowflake LISTAGG / STRING_AGG /
15771     * percentile_cont aren't (slice-44/45 boundaries): mode has no
15772     * positional argument, so the OutputColumn.sources collection is
15773     * trivially empty and matches the dlineage zero-edge canonical model.
15774     * LISTAGG / STRING_AGG store args in parser-specific
15775     * {@code stringExpr} / {@code separatorExpr} fields whose visitor
15776     * descent has not been probed; admitting them risks
15777     * silently-empty IR sources against a non-empty dlineage column-arg
15778     * fdd. percentile_cont / percentile_disc use a literal arg
15779     * (slice-44 §C boundary preserved) but are not in
15780     * {@link #AGGREGATE_FUNCTION_NAMES}, so the slice-29 name-whitelist
15781     * guard fires inside {@link #findUnsupportedWithinGroupFunctionName}
15782     * and rejects regardless of vendor gate.
15783     */
15784    private static boolean isDirectAttachmentModeCallShape(
15785            TFunctionCall fn) {
15786        if (fn == null) return false;
15787        if (fn.getWithinGroup() == null) return false;
15788        if (fn.getWindowDef() != null) return false;
15789        if (fn.getFunctionName() == null) return false;
15790        String name = fn.getFunctionName().toString();
15791        if (name == null || name.isEmpty()) return false;
15792        return "mode".equals(name.toLowerCase(Locale.ROOT))
15793                && hasNoFunctionArgs(fn);
15794    }
15795
15796    private static boolean hasNoFunctionArgs(TFunctionCall fn) {
15797        return fn != null && (fn.getArgs() == null || fn.getArgs().size() == 0);
15798    }
15799
15800    /**
15801     * Slice 45 (renamed and widened from the slice-44 helper
15802     * {@code allWithinGroupCallsAreDirectAttachmentHypotheticalSet}):
15803     * returns {@code true} iff {@code e} contains at least one WITHIN
15804     * GROUP-bearing function call AND every such call uses an
15805     * <i>admitted</i> Snowflake direct-attachment shape — either
15806     * hypothetical-set ({@link #isDirectAttachmentHypotheticalSetCallShape},
15807     * slice 44) or mode ({@link #isDirectAttachmentModeCallShape},
15808     * slice 45). Used to gate the predicate-body vendor whitelist widen
15809     * at the {@code preflightExistsInnerShape} call site so Snowflake is
15810     * admitted only on these probe-confirmed shapes — Snowflake LISTAGG /
15811     * STRING_AGG / percentile_cont / percentile_disc / other names
15812     * remain rejected (their parser-specific argument storage and
15813     * dlineage XML parity are unprobed; slice-31/44 boundary
15814     * preserved).
15815     *
15816     * <p>Mixed expressions (e.g. {@code mode() WG (...) || rank(0.5)
15817     * WG (...)} in a single predicate-body inner projection) are
15818     * admitted when every WG-bearing call is admitted-shape;
15819     * one non-admitted-shape call blocks the whole expression
15820     * (Slice45Test §D).
15821     */
15822    private static boolean allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment(
15823            TExpression e) {
15824        if (e == null) return false;
15825        final boolean[] sawAny = {false};
15826        final boolean[] sawNonAdmitted = {false};
15827        e.acceptChildren(new TParseTreeVisitor() {
15828            @Override
15829            public void preVisit(TFunctionCall fn) {
15830                if (!hasWithinGroupAnyAttachment(fn)) return;
15831                sawAny[0] = true;
15832                if (!isDirectAttachmentHypotheticalSetCallShape(fn)
15833                        && !isDirectAttachmentModeCallShape(fn)) {
15834                    sawNonAdmitted[0] = true;
15835                }
15836            }
15837        });
15838        if (e.getExpressionType() == EExpressionType.function_t) {
15839            TFunctionCall fn = e.getFunctionCall();
15840            if (fn != null && hasWithinGroupAnyAttachment(fn)) {
15841                sawAny[0] = true;
15842                if (!isDirectAttachmentHypotheticalSetCallShape(fn)
15843                        && !isDirectAttachmentModeCallShape(fn)) {
15844                    sawNonAdmitted[0] = true;
15845                }
15846            }
15847        }
15848        return sawAny[0] && !sawNonAdmitted[0];
15849    }
15850
15851    /**
15852     * Slice 27: fail-closed enumeration of admitted projection root shapes
15853     * after the slice-23/24 fast paths and the hard-rejecter guards have
15854     * been considered by {@link #isAdmittedPredicateProjection}.
15855     * Open-ended type checks are intentionally avoided
15856     * (slice-history §C / codex round-1 SHOULD 5).
15857     *
15858     * <p>Admits:
15859     * <ul>
15860     *   <li>{@code function_t} — any function call (aggregate or scalar).
15861     *       OVER-bearing window functions are rejected by the caller's
15862     *       {@code containsWindowFunction} guard (slice 31 narrowed via
15863     *       {@link #isWindowDefBearingFunction} so WITHIN-GROUP-only
15864     *       windowDef passes; OVER-bearing forms still rejected).
15865     *       {@code FILTER (WHERE ...)} was admitted in slice 28 (with
15866     *       FILTER predicate refs excluded from {@code OutputColumn.sources}
15867     *       via {@link #collectColumnRefsExcludingFilterClauses} —
15868     *       slice 31 widens to
15869     *       {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}).
15870     *       PG-style direct {@code fn.withinGroup} attachment was admitted
15871     *       in slice 29 via the vendor-gated rejecter at the
15872     *       {@link #preflightExistsInnerShape} call site; slice 31 extends
15873     *       admission to Oracle / MSSQL windowDef-bearing WITHIN GROUP
15874     *       (Snowflake / DB2 / other direct-attachment vendors remain
15875     *       rejected pending probe).</li>
15876     *   <li>{@code case_t} — simple or searched CASE.</li>
15877     *   <li>Pure binary ({@link TExpression#isPureBinaryForDoParse}) —
15878     *       arithmetic, concat, comparison.</li>
15879     *   <li>{@code parenthesis_t} — descend.</li>
15880     *   <li>{@code typecast_t} — PostgreSQL / Snowflake / Redshift
15881     *       {@code expr::TYPE} (slice 37; cross-vendor probe slice 38).
15882     *       Admit unconditionally; the slice-13 invariant rejecters
15883     *       ({@link #containsAnySubqueryExpression} /
15884     *       {@link #containsWindowFunction}) fire BEFORE this admit check
15885     *       inside {@link #isAdmittedPredicateProjection}, so
15886     *       {@code (SELECT 1)::INT} and {@code (ROW_NUMBER() OVER ())::INT}
15887     *       are still rejected. The default visitor descent walks
15888     *       {@code typecast_t.getLeftOperand()} so
15889     *       {@code OutputColumn.sources} populates with the underlying
15890     *       column refs (probe-verified for PG —
15891     *       {@code /tmp/probe37/Probe37.java}; slice 38 extended the probe
15892     *       to Snowflake and Redshift —
15893     *       {@code /tmp/probe38/Probe38.java}, {@code CheckCurrent.java} —
15894     *       and confirmed byte-identical AST + dlineage XML to PG for both
15895     *       {@code x.id::VARCHAR [AS lst]} and {@code LOWER(x.id)::VARCHAR}
15896     *       composed forms with zero divergence; slice 39 extended the probe
15897     *       to Greenplum, Vertica, GaussDB, Netezza —
15898     *       {@code /tmp/probe39/Probe39.java}, {@code Probe39b.java} — and
15899     *       confirmed AST + dlineage XML byte-identical to the PG / Snowflake /
15900     *       Redshift contract for both aliased and unaliased forms with zero
15901     *       divergence; slice 40 extended the probe to BigQuery, Trino, Presto,
15902     *       EDB, DuckDB, Databricks —
15903     *       {@code /tmp/probe40/Probe40.java}, {@code Probe40b.java},
15904     *       {@code Probe40c.java}, {@code Probe40d.java}, {@code Probe40e.java}
15905     *       — and confirmed AST + dlineage XML byte-identical to the
15906     *       PG / Snowflake / Redshift contract for aliased, unaliased, and
15907     *       {@code LOWER(x.id)::VARCHAR} composed forms with zero divergence;
15908     *       slice 41 closes out the residual vendor matrix —
15909     *       {@code /tmp/probe41/Probe41.java}, {@code Probe41b.java} —
15910     *       confirming Informix native {@code typecast_t} (AST + dlineage XML
15911     *       byte-identical to the PG / Snowflake / Redshift contract);
15912     *       ClickHouse parser auto-lowers {@code expr::TYPE} to
15913     *       {@code function_t} so the slice-27 admission applies;
15914     *       Sybase / Flink / Dameng parse-fail on {@code ::TYPE} but accept
15915     *       {@code CAST(x AS TYPE)} via {@code function_t} (slice-27
15916     *       carryover); Exasol / AzureSQL parse {@code expr::TYPE} as
15917     *       {@code simple_object_name_t} (vendor-quirk — the {@code ::} is
15918     *       interpreted as a qualified-name separator, mirroring T-SQL's
15919     *       {@code tablename::method()} schema-qualified syntax) so the
15920     *       slice-32 exclusion routes via normal column handling;
15921     *       OceanBase / Impala / StarRocks parse-fail boundary locked in).
15922     *       Oracle uses {@code CAST(x AS TYPE)} which parses as
15923     *       {@code function_t} (already admitted above), so no Oracle-specific
15924     *       {@code typecast_t} admission is needed; Hive / SparkSQL parse-fail
15925     *       on the {@code ::TYPE} syntax — slice 39 pins this boundary; slice
15926     *       40 extends the parse-fail boundary lock-in to DB2, Teradata, MySQL,
15927     *       and HANA so a future grammar lift fires loudly and re-probe is
15928     *       required before relying on zero-divergence for those dialects.
15929     *       The slice-37 admission remains structural (no vendor gate); future
15930     *       vendors that surface {@code typecast_t} will be admitted
15931     *       automatically — re-probe before relying on zero-divergence
15932     *       guarantees.</li>
15933     * </ul>
15934     * Implicitly rejects {@code list_t}, {@code subquery_t} (caught by the
15935     * caller), and any unknown expression type.
15936     */
15937    private static boolean isAdmittedSlice27ShapeRoot(TExpression e) {
15938        if (e == null) return false;
15939        EExpressionType t = e.getExpressionType();
15940        if (t == EExpressionType.function_t) return true;
15941        if (t == EExpressionType.case_t) return true;
15942        if (t == EExpressionType.parenthesis_t) {
15943            return e.getLeftOperand() != null
15944                    && isAdmittedSlice27ShapeRoot(e.getLeftOperand());
15945        }
15946        if (t == EExpressionType.typecast_t) return true;     // slice 37 (cross-vendor parity probed in slice 38; widened in slice 39 + slice 40; residual vendors locked in by slice 41 — Informix typecast_t)
15947        if (TExpression.isPureBinaryForDoParse(t)) return true;
15948        return false;
15949    }
15950
15951    /**
15952     * Slice 27: visitor-based deep window-function detector. Mirrors
15953     * {@link #rejectWindowFunctions} (line ~4530) but boolean-returning so
15954     * it can be used as a guard inside
15955     * {@link #isAdmittedPredicateProjection}.
15956     *
15957     * <p>Slice 31: discriminates WITHIN-GROUP-only windowDef shapes
15958     * (Oracle / MSSQL plain {@code WITHIN GROUP (ORDER BY ...)} attachment
15959     * without OVER) from OVER-bearing ones via
15960     * {@link #isWindowDefBearingFunction}. Plain WITHIN GROUP no longer
15961     * counts as a window function for predicate-body inner-projection
15962     * admission. NOTE: only this helper and {@link #isAggregateFunction}
15963     * are lifted — every other slice-13 invariant rejecter
15964     * ({@link #rejectHavingWindowFunction},
15965     * {@link #rejectOrderByWindowFunction},
15966     * {@link #rejectWindowFunctionInScope},
15967     * {@link #rejectWindowFunctions},
15968     * {@link #rejectEmbeddedWindowFunction},
15969     * {@link #isTopLevelWindowProjection}, and the OVER ORDER BY
15970     * window check inside {@code buildWindowOrderRefs}) keeps the
15971     * strict {@code wd != null} check unchanged so HAVING / ORDER BY /
15972     * WHERE / GROUP BY / JOIN ON / top-level projection contexts still
15973     * reject WITHIN-GROUP-only attachments — slice 31 boundary.
15974     */
15975    private static boolean containsWindowFunction(TExpression e) {
15976        if (e == null) return false;
15977        final boolean[] found = {false};
15978        e.acceptChildren(new TParseTreeVisitor() {
15979            @Override
15980            public void preVisit(TFunctionCall fn) {
15981                if (found[0]) return;
15982                if (isWindowDefBearingFunction(fn)) found[0] = true;
15983            }
15984        });
15985        if (!found[0] && e.getExpressionType() == EExpressionType.function_t) {
15986            TFunctionCall fn = e.getFunctionCall();
15987            if (isWindowDefBearingFunction(fn)) found[0] = true;
15988        }
15989        return found[0];
15990    }
15991
15992    /**
15993     * Slice 31: discriminate WITHIN-GROUP-only {@link TWindowDef} shapes
15994     * from OVER-bearing ones. Returns {@code true} iff:
15995     * <ul>
15996     *   <li>{@code wd.getWithinGroup() != null} — the discriminating
15997     *       attachment;</li>
15998     *   <li>{@code !wd.isIncludingOverClause()} — no OVER syntax of any
15999     *       kind (including empty {@code OVER ()});</li>
16000     *   <li>{@code wd.getKeepDenseRankClause() == null} — Oracle KEEP
16001     *       DENSE_RANK FIRST/LAST is a slice-22 deferred shape and must
16002     *       remain windowed.</li>
16003     * </ul>
16004     *
16005     * <p>Probe-validated: {@code TWindowDef.isIncludingOverClause()} is
16006     * {@code false} for plain {@code WITHIN GROUP} and {@code true} for
16007     * any OVER-bearing form (probe Q8 / Q9 / Q11 in
16008     * {@code /tmp/probe31}).
16009     *
16010     * <p>Used by {@link #isWindowDefBearingFunction} (the slice-13
16011     * invariant lift's discriminator).
16012     */
16013    private static boolean isWithinGroupOnlyWindowDef(TWindowDef wd) {
16014        if (wd == null) return false;
16015        if (wd.isIncludingOverClause()) return false;
16016        if (wd.getWithinGroup() == null) return false;
16017        if (wd.getKeepDenseRankClause() != null) return false;
16018        return true;
16019    }
16020
16021    /**
16022     * Slice 31: a {@link TFunctionCall} is an OVER-bearing window-def
16023     * function iff its {@code windowDef} is non-null AND not
16024     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}. Replaces
16025     * the historical {@code fn.getWindowDef() != null} check inside
16026     * {@link #containsWindowFunction} and {@link #isAggregateFunction};
16027     * every other rejecter retains the strict {@code wd != null} check
16028     * unchanged (slice 31 narrow lift).
16029     */
16030    private static boolean isWindowDefBearingFunction(TFunctionCall fn) {
16031        if (fn == null) return false;
16032        TWindowDef wd = fn.getWindowDef();
16033        if (wd == null) return false;
16034        return !isWithinGroupOnlyWindowDef(wd);
16035    }
16036
16037    /**
16038     * Slice 33: a {@link TFunctionCall} is an admitted top-level
16039     * WITHIN-GROUP-only aggregate iff:
16040     *
16041     * <ul>
16042     *   <li>{@link #isWithinGroupOnlyWindowDef} returns true on its
16043     *       windowDef (Oracle / MSSQL plain {@code WITHIN GROUP}, no
16044     *       OVER, no KEEP DENSE_RANK);</li>
16045     *   <li>{@code vendor} is Oracle or MSSQL — explicit gate mirroring
16046     *       the slice-31 predicate-body gate at line ~3860. PG /
16047     *       Snowflake / DB2 / SparkSQL produce direct
16048     *       {@code fn.withinGroup} attachment with {@code windowDef=null}
16049     *       and don't reach this helper today, but the explicit gate
16050     *       keeps the contract narrow against future parser changes;</li>
16051     *   <li>The function name is in {@link #AGGREGATE_FUNCTION_NAMES}
16052     *       (LISTAGG / STRING_AGG / SUM / MIN / MAX / MODE / etc.).</li>
16053     * </ul>
16054     *
16055     * <p>Used only by {@link #buildOutputColumns} to fall through to the
16056     * normal aggregate path. The slice-13 invariant rejecters
16057     * ({@link #isTopLevelWindowProjection},
16058     * {@link #rejectWindowFunctions},
16059     * {@link #rejectEmbeddedWindowFunction},
16060     * {@link #rejectHavingWindowFunction},
16061     * {@link #rejectOrderByWindowFunction},
16062     * {@link #rejectWindowFunctionInScope})
16063     * keep the strict {@code wd != null} check unchanged; slice 33
16064     * admission is gated by a single boolean local to
16065     * {@code buildOutputColumns}.
16066     *
16067     * <p>Non-whitelisted names (PERCENTILE_CONT / PERCENTILE_DISC /
16068     * RANK / DENSE_RANK / PERCENT_RANK / CUME_DIST / user-defined)
16069     * keep routing to {@link #buildWindowOutputColumn} where the
16070     * {@link #WINDOW_FUNCTION_NAMES} guard rejects them as
16071     * "unsupported window function".
16072     */
16073    private static boolean isAdmittedTopLevelWithinGroupAggregate(
16074            TFunctionCall fn, EDbVendor vendor) {
16075        if (fn == null) return false;
16076        if (!isWithinGroupOnlyWindowDef(fn.getWindowDef())) return false;
16077        if (vendor != EDbVendor.dbvoracle && vendor != EDbVendor.dbvmssql) {
16078            return false;
16079        }
16080        if (fn.getFunctionName() == null) return false;
16081        String name = fn.getFunctionName().toString();
16082        if (name == null || name.isEmpty()) return false;
16083        String lower = name.toLowerCase(Locale.ROOT);
16084        if (AGGREGATE_FUNCTION_NAMES.contains(lower)) return true;
16085        // Slice 42: hypothetical-set ordered-set aggregates (RANK /
16086        // DENSE_RANK / PERCENT_RANK / CUME_DIST) admitted on
16087        // Oracle / MSSQL with WITHIN-GROUP-only windowDef shape. The
16088        // surrounding {@code isWithinGroupOnlyWindowDef} guard above
16089        // already enforces the shape; the name-set membership here keeps
16090        // PERCENTILE_CONT / PERCENTILE_DISC / user-defined names rejected.
16091        return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains(lower);
16092    }
16093
16094    /**
16095     * Slice 35 / 36 / 46: top-level direct-attachment WITHIN GROUP
16096     * aggregate.
16097     *
16098     * <p>PG stores {@code LISTAGG(... ) WITHIN GROUP (...)} (slice 35) and
16099     * {@code STRING_AGG(... ) WITHIN GROUP (...)} (slice 36) in
16100     * {@code fn.getWithinGroup()} with {@code windowDef=null}. Both bypass
16101     * the slice-33 Oracle/MSSQL helper above, but the plain aggregate path
16102     * is otherwise already correct: the root is not a window function,
16103     * {@link #isAggregateFunction} sees the whitelisted name, and default
16104     * visitor descent does not walk direct {@code fn.withinGroup}, so
16105     * sources contain the function argument but not the WITHIN GROUP ORDER
16106     * BY ref. This helper exists only to unlock the slice-34 expression-text
16107     * fallback for the unaliased top-level form.
16108     *
16109     * <p>Slice 36 widens the PG name whitelist from {@code {listagg}} to
16110     * {@code {listagg, string_agg}}. Snowflake / DB2 / SparkSQL
16111     * {@code LISTAGG} / {@code STRING_AGG} WG remain rejected because
16112     * their argument-storage shape (e.g. DB2's {@code stringExpr}/
16113     * {@code separatorExpr}) is not yet probed for visitor descent and
16114     * silent empty {@code OutputColumn.sources} would manufacture
16115     * {@code IR_MISSING_DEPENDENCY} divergence.
16116     *
16117     * <p>Slice 46 widens the vendor gate to additionally admit
16118     * Snowflake — but only for {@code mode}, the only Snowflake
16119     * direct-attachment WITHIN GROUP name whose dlineage XML has been
16120     * probe-confirmed byte-equivalent to PG (the projector's slice-30
16121     * vendor-agnostic {@code AGGREGATE_FUNCTION_NAMES} +
16122     * {@code ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} entries already
16123     * cover {@code mode}, so no projector change is needed). Snowflake
16124     * {@code listagg} / {@code string_agg} / hypothetical-set names
16125     * stay out of slice-46 scope.
16126     *
16127     * <p>Slice 47 widens the PG name whitelist from
16128     * {@code {listagg, string_agg}} to {@code {listagg, string_agg, mode}},
16129     * the symmetrical lift to slice 46's Snowflake widen. The slice-46
16130     * pre-plan probe ({@code /tmp/Probe46Slice30.java}) already
16131     * confirmed PG aliased {@code mode()} WG was zero-divergence and the
16132     * unaliased form was blocked only by {@code effectiveOutputName}; PG
16133     * top-level {@code mode()} dlineage XML is byte-identical to
16134     * Snowflake's. No projector change is needed (slice 30 already
16135     * registered {@code mode}, vendor-agnostic).
16136     */
16137    private static boolean isAdmittedTopLevelDirectWithinGroupAggregate(
16138            TFunctionCall fn, EDbVendor vendor) {
16139        if (fn == null) return false;
16140        if (fn.getWithinGroup() == null) return false;
16141        if (fn.getWindowDef() != null) return false;
16142        if (fn.getFunctionName() == null) return false;
16143        String name = fn.getFunctionName().toString();
16144        if (name == null || name.isEmpty()) return false;
16145        String lower = name.toLowerCase(Locale.ROOT);
16146        if (vendor == EDbVendor.dbvpostgresql) {
16147            // Slice 35/36: PG LISTAGG / STRING_AGG WG.
16148            // Slice 47: PG mode() WG (parallels slice-46 Snowflake mode
16149            // lift; probed mode() carries no positional argument so
16150            // OutputColumn.sources is trivially empty matching
16151            // dlineage's zero-edge canonical model).
16152            return "listagg".equals(lower)
16153                    || "string_agg".equals(lower)
16154                    || ("mode".equals(lower) && hasNoFunctionArgs(fn));
16155        }
16156        if (vendor == EDbVendor.dbvsnowflake) {
16157            // Slice 46: Snowflake mode() WG only. The admitted shape is
16158            // constrained to the probed no-arg mode() form: OutputColumn.
16159            // sources is trivially empty (matching dlineage's zero-edge
16160            // canonical model), and dlineage XML was probe-confirmed
16161            // byte-identical to PG's mode() WG XML for both aliased and
16162            // unaliased forms.
16163            return "mode".equals(lower) && hasNoFunctionArgs(fn);
16164        }
16165        return false;
16166    }
16167    /**
16168     * Slice 27: detect {@code FILTER (WHERE ...)} on any function call in
16169     * the expression subtree. Probe Q5 (PostgreSQL) confirmed dlineage's
16170     * {@code fdr clause="on"} omits FILTER-predicate column refs while
16171     * {@link #collectColumnRefs} would include them — canonical-model
16172     * divergence. Reject as slice-27 boundary.
16173     */
16174    private static boolean containsAggregateWithFilter(TExpression e) {
16175        if (e == null) return false;
16176        final boolean[] found = {false};
16177        e.acceptChildren(new TParseTreeVisitor() {
16178            @Override
16179            public void preVisit(TFunctionCall fn) {
16180                if (found[0]) return;
16181                if (fn.getFilterClause() != null) found[0] = true;
16182            }
16183        });
16184        if (!found[0] && e.getExpressionType() == EExpressionType.function_t) {
16185            TFunctionCall fn = e.getFunctionCall();
16186            if (fn != null && fn.getFilterClause() != null) found[0] = true;
16187        }
16188        return found[0];
16189    }
16190
16191    /**
16192     * Slice 23: true iff every leaf of {@code e} is a {@code simple_constant_t}.
16193     * Admits {@code 1}, {@code 1+1}, {@code (1)}, {@code 'a' || 'b'} (vendor-
16194     * dependent). Slice 61 additionally admits unary {@code +}/{@code -}
16195     * wrappers over a constant operand so common signed literals like
16196     * {@code -1} and {@code -1.5} count as constants. Rejects column refs,
16197     * function calls (including {@code COALESCE}), CASE, scalar subqueries,
16198     * etc. The predicate body may STILL have inner WHERE / GROUP BY /
16199     * HAVING / ORDER BY referencing inner columns — only the projection
16200     * must be constant.
16201     */
16202    private static boolean isConstantExpression(TExpression e) {
16203        if (e == null) return false;
16204        EExpressionType t = e.getExpressionType();
16205        if (t == EExpressionType.simple_constant_t) return true;
16206        if (t == EExpressionType.parenthesis_t) {
16207            return e.getLeftOperand() != null && isConstantExpression(e.getLeftOperand());
16208        }
16209        // Slice 61: unary +/- over a constant operand. The Oracle parser
16210        // emits `-1` as {@code unary_minus_t} with {@code left=null} and
16211        // {@code right=simple_constant_t(1)}. Pre-slice-61 this fell
16212        // through and was rejected; slice 61 lifts it so signed literals
16213        // like `SELECT -1 FROM t` and `SELECT -1 UNION ALL SELECT -2`
16214        // round-trip through the constant-projection path.
16215        if (t == EExpressionType.unary_minus_t || t == EExpressionType.unary_plus_t) {
16216            TExpression operand = e.getRightOperand() != null
16217                    ? e.getRightOperand()
16218                    : e.getLeftOperand();
16219            return operand != null && isConstantExpression(operand);
16220        }
16221        // Pure binary ops (slice-22 isPureBinaryForDoParse helper) — both
16222        // operands must be constant. Concatenation, arithmetic, etc.
16223        if (TExpression.isPureBinaryForDoParse(t)) {
16224            TExpression l = e.getLeftOperand();
16225            TExpression r = e.getRightOperand();
16226            return l != null && isConstantExpression(l)
16227                    && r != null && isConstantExpression(r);
16228        }
16229        return false;
16230    }
16231
16232    /**
16233     * Slice 23: reject subqueries in an EXISTS body's inner WHERE / JOIN ON /
16234     * GROUP BY / HAVING / ORDER BY (would be predicate subqueries OR scalar
16235     * subqueries). Mirrors the slice-11
16236     * {@link #rejectSubqueriesInScalarBodyClauses} structure but uses a
16237     * slice-23-specific error message.
16238     */
16239    private static void rejectSubqueriesInPredicateBodyClauses(TSelectSqlStatement inner) {
16240        TWhereClause where = inner.getWhereClause();
16241        if (where != null && containsAnySubquery(where)) {
16242            throw new SemanticIRBuildException(
16243                    Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_WHERE,
16244                    "EXISTS in JOIN ON: inner SELECT has a subquery in its WHERE clause; "
16245                            + "not supported yet", null));
16246        }
16247        if (inner.joins != null) {
16248            for (TJoin join : inner.joins) {
16249                TJoinItemList items = join.getJoinItems();
16250                if (items == null) continue;
16251                for (int i = 0; i < items.size(); i++) {
16252                    TJoinItem item = items.getJoinItem(i);
16253                    TExpression onCond = item == null ? null : item.getOnCondition();
16254                    if (onCond != null && containsAnySubqueryExpression(onCond)) {
16255                        throw new SemanticIRBuildException(
16256                                Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_JOIN_ON,
16257                                "EXISTS in JOIN ON: inner SELECT has a subquery in a JOIN ON "
16258                                        + "clause; not supported yet", null));
16259                    }
16260                }
16261            }
16262        }
16263        TGroupBy groupBy = inner.getGroupByClause();
16264        if (groupBy != null) {
16265            TGroupByItemList items = groupBy.getItems();
16266            if (items != null && containsAnySubquery(items)) {
16267                throw new SemanticIRBuildException(
16268                        Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_GROUP_BY,
16269                        "EXISTS in JOIN ON: inner SELECT has a subquery in a GROUP BY clause; "
16270                                + "not supported yet", null));
16271            }
16272        }
16273        // HAVING / ORDER BY subqueries are caught by the slice-9 / 10
16274        // deep-scan rejecters that fire during the recursive build.
16275    }
16276
16277    /**
16278     * Slice 25 (impl-review S2-fix): walk {@code onCond} (root + every
16279     * descendant outside {@code extractedRoots}) and return the first
16280     * wrapper expression whose <b>left</b> operand is a
16281     * {@code subquery_t}. Returns null if none.
16282     */
16283    private static TExpression findSubqueryOnLeftWrapper(TExpression onCond,
16284                                                          final Set<TExpression> extractedRoots) {
16285        if (onCond == null) return null;
16286        if (isSubqueryOnLeftOfWrapper(onCond)) return onCond;
16287        final TExpression[] found = {null};
16288        onCond.acceptChildren(new TParseTreeVisitor() {
16289            int skipDepth = 0;
16290
16291            @Override
16292            public void preVisit(TExpression e) {
16293                if (found[0] != null) return;
16294                if (extractedRoots.contains(e)) {
16295                    skipDepth++;
16296                    return;
16297                }
16298                if (skipDepth > 0) return;
16299                if (isSubqueryOnLeftOfWrapper(e)) {
16300                    found[0] = e;
16301                }
16302            }
16303
16304            @Override
16305            public void postVisit(TExpression e) {
16306                if (extractedRoots.contains(e) && skipDepth > 0) {
16307                    skipDepth--;
16308                }
16309            }
16310        });
16311        return found[0];
16312    }
16313
16314    /**
16315     * Slice 25 / Slice 26: true iff {@code e} is a comparison/IN/
16316     * quantifier wrapper that the slice-25 / slice-26 surface still
16317     * rejects on subquery-positioning grounds.
16318     *
16319     * <p>Slice 26 narrowing: {@code simple_comparison_t} with subquery
16320     * on the LHS and a non-subquery RHS is now ADMITTED (returns
16321     * false here so the post-extraction rejecter doesn't fire). Other
16322     * shapes still fail:
16323     * <ul>
16324     *   <li>{@code in_t} with LHS=subquery: dlineage's
16325     *       {@code fdr clause="on"} sources omit the outer column for
16326     *       IN-LHS, so admitting on the IR side would manufacture
16327     *       canonical-model divergence (still rejected).</li>
16328     *   <li>{@code group_comparison_t} with LHS=subquery: borderline
16329     *       grammar; defensively rejected (slice-26 boundary).</li>
16330     *   <li>{@code simple_comparison_t} with subqueries on BOTH
16331     *       sides: would require dual extraction; deferred to a future
16332     *       slice (slice-26 boundary). Caller emits a tuned message.</li>
16333     * </ul>
16334     */
16335    private static boolean isSubqueryOnLeftOfWrapper(TExpression e) {
16336        if (e == null) return false;
16337        EExpressionType t = e.getExpressionType();
16338        TExpression l = e.getLeftOperand();
16339        TExpression r = e.getRightOperand();
16340        boolean lhsIsSubq = l != null && l.getExpressionType() == EExpressionType.subquery_t;
16341        boolean rhsIsSubq = r != null && r.getExpressionType() == EExpressionType.subquery_t;
16342        if (t == EExpressionType.in_t) {
16343            return lhsIsSubq;
16344        }
16345        if (t == EExpressionType.group_comparison_t) {
16346            return lhsIsSubq;
16347        }
16348        if (t == EExpressionType.simple_comparison_t) {
16349            // Slice 26: lifted UNLESS both sides are subqueries.
16350            return lhsIsSubq && rhsIsSubq;
16351        }
16352        return false;
16353    }
16354
16355    /**
16356     * Slice 26: true iff {@code e} is a {@code simple_comparison_t}
16357     * whose BOTH operands are {@code subquery_t}. Used by the
16358     * post-extraction rejecter to emit a slice-26-specific tuned
16359     * message distinguishing this case from the slice-25 LHS-subquery
16360     * shapes.
16361     */
16362    private static boolean isComparisonWithBothSubqueries(TExpression e) {
16363        if (e == null) return false;
16364        if (e.getExpressionType() != EExpressionType.simple_comparison_t) return false;
16365        TExpression l = e.getLeftOperand();
16366        TExpression r = e.getRightOperand();
16367        return l != null && l.getExpressionType() == EExpressionType.subquery_t
16368                && r != null && r.getExpressionType() == EExpressionType.subquery_t;
16369    }
16370
16371    /**
16372     * Slice 23: after extraction, any remaining subquery-bearing expression in
16373     * the JOIN-ON tree is an unsupported shape — EXISTS that failed
16374     * extraction (inner-shape rejection), correlated wrappers, subquery on
16375     * left side, etc. Catch them here with a tuned message before
16376     * {@link #collectColumnRefsSkipping} would otherwise descend into them
16377     * and bind their inner refs against the outer scope.
16378     *
16379     * <p>Slice 25 (impl-review S2-fix): subquery-on-LEFT cases get a
16380     * tuned message (uses the slice-25 outer-shape prefix
16381     * "predicate subquery in JOIN ON:") via
16382     * {@link #findSubqueryOnLeftWrapper}.
16383     *
16384     * <p>Slice 26: a NEW first pass (before the slice-25 LHS-subquery
16385     * pass) detects {@code simple_comparison_t} wrappers with
16386     * subqueries on BOTH sides via
16387     * {@link #findComparisonWithBothSubqueries} and emits a slice-26
16388     * tuned message. Both-sides shape satisfies
16389     * {@link #isSubqueryOnLeftOfWrapper} (which slice 26 narrowed to
16390     * {@code lhsIsSubq && rhsIsSubq} for {@code simple_comparison_t}),
16391     * so ordering matters — without the both-sides first pass, the
16392     * slice-25 LHS-subquery wording would fire first.
16393     */
16394    private static void rejectAnyRemainingSubqueriesInJoinOn(TExpression onCond,
16395                                                              final Set<TExpression> extractedRoots) {
16396        rejectAnyRemainingSubqueriesFromClause(onCond, extractedRoots,
16397                PredicateClauseContext.JOIN_ON);
16398    }
16399
16400    /**
16401     * Slice 110 — clause-agnostic remaining-subquery rejecter. Mirrors
16402     * the slice-26 logic in {@link #rejectAnyRemainingSubqueriesInJoinOn}
16403     * but uses {@code ctx.*} codes / labels so the same body powers JOIN-ON
16404     * (slice 26) and UPDATE WHERE (slice 110).
16405     */
16406    private static void rejectAnyRemainingSubqueriesFromClause(TExpression onCond,
16407                                                                final Set<TExpression> extractedRoots,
16408                                                                final PredicateClauseContext ctx) {
16409        if (onCond == null) return;
16410        // Slice 26: tuned message for a comparison with subqueries on
16411        // BOTH sides. Fires at root or any descent. Checked BEFORE the
16412        // slice-25 subquery-on-LEFT pass because both-subqueries
16413        // satisfies isSubqueryOnLeftOfWrapper too — without this
16414        // ordering the slice-25 wording would fire first.
16415        TExpression bothSubqueriesWrapper = findComparisonWithBothSubqueries(onCond,
16416                extractedRoots);
16417        if (bothSubqueriesWrapper != null) {
16418            throw new SemanticIRBuildException(
16419                    Diagnostic.error(ctx.scalarComparisonBothSides,
16420                    "predicate subquery in " + ctx.clauseLabel + ": scalar comparison with "
16421                            + "subqueries on both sides is not supported yet "
16422                            + "(slice 26 admits exactly one subquery side, with a "
16423                            + "single column reference on the other side; rewrite "
16424                            + "as a join across a derived table or a CTE)", null));
16425        }
16426        // Slice 25 (impl-review S2-fix): tuned message for subquery
16427        // on the LEFT side of a wrapper. Fires at root or any descent.
16428        // Slice 26 narrowed isSubqueryOnLeftOfWrapper:
16429        // simple_comparison_t with LHS=subquery and non-subquery RHS is
16430        // now ADMITTED, so this rejecter only fires for in_t-LHS-subq /
16431        // group_comparison_t-LHS-subq (still rejected as asymmetric /
16432        // borderline shapes).
16433        TExpression leftSubqueryWrapper = findSubqueryOnLeftWrapper(onCond, extractedRoots);
16434        if (leftSubqueryWrapper != null) {
16435            throw new SemanticIRBuildException(
16436                    Diagnostic.error(ctx.predicateSubqueryOnLeft,
16437                    "predicate subquery in " + ctx.clauseLabel + ": "
16438                            + leftSubqueryWrapper.getExpressionType()
16439                            + " wrapper has a subquery on the LEFT side "
16440                            + "(only RHS-subquery IN / ANY-ALL-SOME and "
16441                            + "either-side scalar comparison are admitted; "
16442                            + "rewrite to put the subquery on the right side, "
16443                            + "or rewrite as a join across a derived table)", null));
16444        }
16445        // Root check: if the entire condition IS an EXISTS root and it WASN'T
16446        // extracted (meaning extraction threw an exception, which should not
16447        // reach here, OR some other root subquery shape), reject. The root
16448        // walker would otherwise miss it.
16449        TExpression rootSubject = isExistsRoot(onCond) ? unwrapExistsRoot(onCond) : onCond;
16450        if (rootSubject != null
16451                && (rootSubject.getExpressionType() == EExpressionType.subquery_t
16452                        || rootSubject.getSubQuery() != null
16453                        || rootSubject.getExpressionType() == EExpressionType.exists_t)
16454                && !extractedRoots.contains(rootSubject)) {
16455            throw new SemanticIRBuildException(
16456                    Diagnostic.error(ctx.genericSubqueryNotSupported,
16457                    "subquery in " + ctx.clauseLabel + " predicate is not supported yet "
16458                            + "(slice 26 accepts only uncorrelated EXISTS / "
16459                            + "IN-SELECT / scalar-comparison / ANY-ALL-SOME with "
16460                            + "single column-ref or constant-only inner projection "
16461                            + "and a single column ref on the non-subquery side)", null));
16462        }
16463        final boolean[] found = {false};
16464        onCond.acceptChildren(new TParseTreeVisitor() {
16465            int skipDepth = 0;
16466
16467            @Override
16468            public void preVisit(TExpression e) {
16469                if (found[0]) return;
16470                if (extractedRoots.contains(e)) {
16471                    skipDepth++;
16472                    return;
16473                }
16474                if (skipDepth > 0) return;
16475                if (e.getExpressionType() == EExpressionType.subquery_t
16476                        || e.getSubQuery() != null
16477                        || e.getExpressionType() == EExpressionType.exists_t) {
16478                    found[0] = true;
16479                }
16480            }
16481
16482            @Override
16483            public void postVisit(TExpression e) {
16484                if (extractedRoots.contains(e) && skipDepth > 0) {
16485                    skipDepth--;
16486                }
16487            }
16488        });
16489        if (found[0]) {
16490            throw new SemanticIRBuildException(
16491                    Diagnostic.error(ctx.genericSubqueryNotSupported,
16492                    "subquery in " + ctx.clauseLabel + " predicate is not supported yet "
16493                            + "(slice 26 accepts only uncorrelated EXISTS / "
16494                            + "IN-SELECT / scalar-comparison / ANY-ALL-SOME with "
16495                            + "single column-ref or constant-only inner projection "
16496                            + "and a single column ref on the non-subquery side)", null));
16497        }
16498    }
16499
16500    /**
16501     * Slice 26: walk {@code onCond} (root + every descendant outside
16502     * {@code extractedRoots}) and return the first
16503     * {@code simple_comparison_t} expression whose BOTH operands are
16504     * {@code subquery_t}. Returns null if none.
16505     */
16506    private static TExpression findComparisonWithBothSubqueries(TExpression onCond,
16507                                                                final Set<TExpression> extractedRoots) {
16508        if (onCond == null) return null;
16509        if (isComparisonWithBothSubqueries(onCond)) return onCond;
16510        final TExpression[] found = {null};
16511        onCond.acceptChildren(new TParseTreeVisitor() {
16512            int skipDepth = 0;
16513
16514            @Override
16515            public void preVisit(TExpression e) {
16516                if (found[0] != null) return;
16517                if (extractedRoots.contains(e)) {
16518                    skipDepth++;
16519                    return;
16520                }
16521                if (skipDepth > 0) return;
16522                if (isComparisonWithBothSubqueries(e)) {
16523                    found[0] = e;
16524                }
16525            }
16526
16527            @Override
16528            public void postVisit(TExpression e) {
16529                if (extractedRoots.contains(e) && skipDepth > 0) {
16530                    skipDepth--;
16531                }
16532            }
16533        });
16534        return found[0];
16535    }
16536
16537    /**
16538     * Slice 23: variant of {@link #rejectWindowFunctionInScope} that skips
16539     * subtrees in {@code skipRoots}. The outer-SELECT JOIN-ON path passes the
16540     * extracted EXISTS roots so a window function inside an extracted body
16541     * is NOT incorrectly rejected as a window in the outer JOIN-ON. (The
16542     * inner statement's own buildSelectStatement does its own
16543     * rejectWindowFunctionInScope sweeps on WHERE / GROUP BY / HAVING /
16544     * ORDER BY, so legitimate inner-window violations still surface.)
16545     */
16546    private static void rejectWindowFunctionInScopeSkipping(
16547            gudusoft.gsqlparser.nodes.TParseTreeNode root,
16548            String clauseLabel,
16549            final Set<TExpression> skipRoots) {
16550        if (root == null) return;
16551        // Root fast path: if the root itself IS a skipped subtree, nothing to
16552        // check. (acceptChildren wouldn't see the root anyway.)
16553        if (root instanceof TExpression && skipRoots.contains(root)) {
16554            return;
16555        }
16556        final boolean[] found = {false};
16557        root.acceptChildren(new TParseTreeVisitor() {
16558            int skipDepth = 0;
16559
16560            @Override
16561            public void preVisit(TExpression e) {
16562                if (skipRoots.contains(e)) {
16563                    skipDepth++;
16564                }
16565            }
16566
16567            @Override
16568            public void postVisit(TExpression e) {
16569                if (skipRoots.contains(e) && skipDepth > 0) {
16570                    skipDepth--;
16571                }
16572            }
16573
16574            @Override
16575            public void preVisit(TFunctionCall fn) {
16576                if (found[0] || skipDepth > 0) return;
16577                if (fn.getWindowDef() != null) found[0] = true;
16578            }
16579        });
16580        if (found[0]) {
16581            throw new SemanticIRBuildException(
16582                    Diagnostic.error(DiagnosticCode.CLAUSE_WINDOW_FUNCTION_LEAK,
16583                    clauseLabel + " contains a window function (OVER (...)); "
16584                            + "window functions are not allowed in " + clauseLabel
16585                            + " per standard SQL", root));
16586        }
16587    }
16588
16589    /**
16590     * Slice 23: variant of {@link #collectColumnRefs} that skips subtrees in
16591     * {@code skipRoots}. Used by the outer-SELECT JOIN-ON path so the
16592     * extracted EXISTS bodies' inner refs do not leak into outer
16593     * {@code joinColumnRefs}.
16594     */
16595    private static List<ColumnRef> collectColumnRefsSkipping(
16596            gudusoft.gsqlparser.nodes.TParseTreeNode root,
16597            final NameBindingProvider provider,
16598            final Set<TExpression> skipRoots) {
16599        // Slice 31 refactor: delegate to the extended variant with no
16600        // TWithinGroup skips. Behavior preserved exactly for the
16601        // existing slice-28 caller (outer JOIN-ON path at line ~3021)
16602        // and for the new slice-31 caller via
16603        // {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}.
16604        return collectColumnRefsSkippingExtended(root, provider,
16605                skipRoots, Collections.<TWithinGroup>emptySet());
16606    }
16607
16608    /**
16609     * Slice 28: collect every non-null {@link TFunctionCall#getFilterClause()}
16610     * subtree reachable from {@code root}. The returned set is identity-keyed
16611     * (uses {@link IdentityHashMap}) — required because the parser may yield
16612     * two structurally-equal FILTER WHERE expressions with different
16613     * identities; a value-keyed set would coalesce them and the
16614     * downstream {@link #collectColumnRefsSkipping} call would skip only one.
16615     *
16616     * <p>Contract: {@code root} is one of {@link TResultColumn} or
16617     * {@link TExpression}. Both call sites
16618     * ({@link #collectColumnRefsExcludingFilterClauses} for projection
16619     * source collection; the slice-28 correlation walk inside
16620     * {@link #extractOnePredicateSubqueryBody}) pass values of those
16621     * two types. Other {@code TParseTreeNode} subclasses are accepted
16622     * defensively (the visitor scan still works) but the top-level
16623     * direct-check fast paths only cover {@code TResultColumn} and
16624     * {@code TExpression}; this is intentional — adding a
16625     * {@code TFunctionCall} fast path would be reachable only if a
16626     * future call site were added with a {@code TFunctionCall} root.
16627     *
16628     * <p>Visitor-driven; descends into all expression subtrees the
16629     * standard {@code TFunctionCall.acceptChildren} path visits — function
16630     * args, {@code OVER} (analyticFunction / windowDef), FILTER, CASE arms,
16631     * parenthesised sub-expressions, AND the
16632     * {@code windowDef.withinGroup.orderBy} path on Oracle / MSSQL /
16633     * SparkSQL parsers. Note: the PostgreSQL parser stores WITHIN GROUP
16634     * on the direct {@code fn.withinGroup} field, which
16635     * {@code TFunctionCall.acceptChildren} does NOT visit, so PG WITHIN
16636     * GROUP ORDER BY refs are invisible to this collector. Slice 29
16637     * relies on that asymmetry to admit PG WITHIN GROUP aggregates in
16638     * predicate-subquery inner projections without a source-skip.
16639     * Used by:
16640     * <ul>
16641     *   <li>{@link #collectColumnRefsExcludingFilterClauses} (Pass 1) — the
16642     *       global source-skip in {@link #buildOutputColumns}.</li>
16643     *   <li>{@link #extractOnePredicateSubqueryBody}'s slice-28 correlation
16644     *       walk — projection-only correlation check for FILTER predicate
16645     *       refs.</li>
16646     * </ul>
16647     */
16648    private static Set<TExpression> collectFilterClauses(
16649            gudusoft.gsqlparser.nodes.TParseTreeNode root) {
16650        final Set<TExpression> out =
16651                Collections.newSetFromMap(new IdentityHashMap<TExpression, Boolean>());
16652        if (root == null) return out;
16653        // Visitor descends into all expression subtrees; preVisit on
16654        // TFunctionCall records the filter clause if present. The
16655        // visitor's preVisit(TFunctionCall) does NOT fire for a top-level
16656        // function_t expression's root TFunctionCall (matches the slice-13 /
16657        // slice-27 visitor descent behavior); the defensive direct checks
16658        // below cover the top-level case for the two supported root types.
16659        root.acceptChildren(new TParseTreeVisitor() {
16660            @Override
16661            public void preVisit(TFunctionCall fn) {
16662                TExpression f = fn.getFilterClause();
16663                if (f != null) out.add(f);
16664            }
16665        });
16666        if (root instanceof TExpression) {
16667            TExpression e = (TExpression) root;
16668            if (e.getExpressionType() == EExpressionType.function_t) {
16669                TFunctionCall fn = e.getFunctionCall();
16670                if (fn != null && fn.getFilterClause() != null) {
16671                    out.add(fn.getFilterClause());
16672                }
16673            }
16674        } else if (root instanceof TResultColumn) {
16675            TResultColumn rc = (TResultColumn) root;
16676            TExpression e = rc.getExpr();
16677            if (e != null && e.getExpressionType() == EExpressionType.function_t) {
16678                TFunctionCall fn = e.getFunctionCall();
16679                if (fn != null && fn.getFilterClause() != null) {
16680                    out.add(fn.getFilterClause());
16681                }
16682            }
16683        }
16684        return out;
16685    }
16686
16687    /**
16688     * Slice 30: collect every qualifier alias (the {@code x} of an
16689     * {@code x.region} TObjectName column reference) reachable from
16690     * {@code root}, without going through the resolver. Used by the
16691     * slice-30 WITHIN GROUP ORDER BY correlation walk in
16692     * {@link #extractOnePredicateSubqueryBody}.
16693     *
16694     * <p>Why bypass the resolver: PostgreSQL's parser stores WITHIN GROUP
16695     * on the direct {@code fn.withinGroup} field. {@code TFunctionCall.acceptChildren}
16696     * does NOT descend into that field, AND Resolver2 follows the same
16697     * traversal, so its {@code ResolutionResult} is null on TObjectName
16698     * nodes inside {@code fn.withinGroup.orderBy}. Calling
16699     * {@link #collectColumnRefs} on the WG ORDER BY would route through
16700     * {@code provider.bindColumn} → {@code NOT_FOUND}, and the
16701     * {@code non-exact column bindings} check would throw on legitimate
16702     * non-correlated refs. The qualifier-only collector here reads the
16703     * alias straight off the TObjectName via {@link TObjectName#getTableString()},
16704     * matching the slice-23 correlation invariant: qualified refs only.
16705     * Unqualified refs are out of scope (same schema-less limitation as
16706     * the rest of slice-23).
16707     *
16708     * <p>Returned in iteration order (so error messages identify the
16709     * first offender) using a list rather than a set.
16710     */
16711    private static List<String> collectQualifierAliases(
16712            gudusoft.gsqlparser.nodes.TParseTreeNode root) {
16713        final List<String> out = new ArrayList<>();
16714        if (root == null) return out;
16715        root.acceptChildren(new TParseTreeVisitor() {
16716            @Override
16717            public void preVisit(TObjectName node) {
16718                if (node.getDbObjectType() != EDbObjectType.column) return;
16719                String t = node.getTableString();
16720                if (t != null && !t.isEmpty()) out.add(t);
16721            }
16722        });
16723        return out;
16724    }
16725
16726    /**
16727     * Slice 30 / Slice 31: collect every WITHIN-GROUP {@code ORDER BY}
16728     * clause anywhere in the subtree, identity-keyed so two
16729     * structurally-equal order-by clauses don't collapse.
16730     *
16731     * <p>Two attachment styles are covered:
16732     * <ul>
16733     *   <li><b>Slice 30 — direct attachment</b>: PostgreSQL /
16734     *       Snowflake / DB2 / SparkSQL parsers store WITHIN GROUP on
16735     *       {@code fn.getWithinGroup()}. The default
16736     *       {@code TFunctionCall.acceptChildren} does NOT descend into
16737     *       that field, so the slice-23 {@link #collectAllInnerRefs}
16738     *       walk is blind to outer-alias references inside the ORDER BY.
16739     *       The slice-30 correlation walk needs explicit access, hence
16740     *       this helper.</li>
16741     *   <li><b>Slice 31 — windowDef attachment</b>: Oracle / MSSQL
16742     *       parsers store WITHIN GROUP on
16743     *       {@code fn.getWindowDef().getWithinGroup()} when the
16744     *       windowDef is {@link #isWithinGroupOnlyWindowDef
16745     *       WITHIN-GROUP-only}. The default {@code acceptChildren}
16746     *       DOES descend through {@code windowDef.acceptChildren}
16747     *       which calls {@code withinGroup.acceptChildren} which calls
16748     *       {@code orderBy.acceptChildren}, so column refs would
16749     *       already appear in {@link #collectAllInnerRefs}-driven
16750     *       walks. <b>However</b>, the slice-31 source-skip in
16751     *       {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}
16752     *       removes those refs from {@link OutputColumn#getSources()};
16753     *       the slice-23 correlation walk only sees {@code OutputColumn.sources}
16754     *       for the projection bucket, so a correlated
16755     *       {@code LISTAGG(x.id) WITHIN GROUP (ORDER BY e.region)} on
16756     *       Oracle would slip past the slice-23 loop after the source-
16757     *       skip. This dual-attachment helper closes that asymmetry.
16758     *       (Inner WHERE / JOIN / HAVING / ORDER BY clauses are
16759     *       independently rejected by the slice-13 strict
16760     *       {@code rejectWindowFunctionInScope} family — Oracle
16761     *       LISTAGG WG inside a clause never reaches this helper.)</li>
16762     * </ul>
16763     *
16764     * <p>The visitor's {@code preVisit(TFunctionCall)} does NOT fire for
16765     * a top-level {@code function_t} expression's root TFunctionCall;
16766     * defensive direct checks below cover the top-level case for both
16767     * {@link TExpression} and {@link TResultColumn} roots, mirroring
16768     * {@link #collectFilterClauses}.
16769     *
16770     * <p>Used by {@link #extractOnePredicateSubqueryBody}'s
16771     * projection-only correlation walk (line ~3690) for both
16772     * direct-attachment (slice 30) and windowDef-attachment (slice 31)
16773     * outer-alias references inside WITHIN GROUP ORDER BY.
16774     */
16775    private static Set<TOrderBy> collectDirectWithinGroupOrderBys(
16776            gudusoft.gsqlparser.nodes.TParseTreeNode root) {
16777        final Set<TOrderBy> out =
16778                Collections.newSetFromMap(new IdentityHashMap<TOrderBy, Boolean>());
16779        if (root == null) return out;
16780        root.acceptChildren(new TParseTreeVisitor() {
16781            @Override
16782            public void preVisit(TFunctionCall fn) {
16783                TOrderBy direct = fn.getWithinGroup() == null
16784                        ? null : fn.getWithinGroup().getOrderBy();
16785                if (direct != null) out.add(direct);
16786                TWindowDef wd = fn.getWindowDef();
16787                if (isWithinGroupOnlyWindowDef(wd)) {
16788                    TOrderBy wdOb = wd.getWithinGroup().getOrderBy();
16789                    if (wdOb != null) out.add(wdOb);
16790                }
16791            }
16792        });
16793        if (root instanceof TExpression) {
16794            TExpression e = (TExpression) root;
16795            if (e.getExpressionType() == EExpressionType.function_t) {
16796                addWithinGroupOrderByIfPresent(e.getFunctionCall(), out);
16797            }
16798        } else if (root instanceof TResultColumn) {
16799            TResultColumn rc = (TResultColumn) root;
16800            TExpression e = rc.getExpr();
16801            if (e != null && e.getExpressionType() == EExpressionType.function_t) {
16802                addWithinGroupOrderByIfPresent(e.getFunctionCall(), out);
16803            }
16804        }
16805        return out;
16806    }
16807
16808    /**
16809     * Slice 30 / 31: helper for top-level direct check inside
16810     * {@link #collectDirectWithinGroupOrderBys}. Adds the WITHIN GROUP
16811     * ORDER BY clause to {@code out} for whichever attachment style
16812     * the function carries.
16813     */
16814    private static void addWithinGroupOrderByIfPresent(TFunctionCall fn,
16815                                                       Set<TOrderBy> out) {
16816        if (fn == null) return;
16817        if (fn.getWithinGroup() != null && fn.getWithinGroup().getOrderBy() != null) {
16818            out.add(fn.getWithinGroup().getOrderBy());
16819        }
16820        TWindowDef wd = fn.getWindowDef();
16821        if (isWithinGroupOnlyWindowDef(wd) && wd.getWithinGroup().getOrderBy() != null) {
16822            out.add(wd.getWithinGroup().getOrderBy());
16823        }
16824    }
16825
16826    /**
16827     * Slice 28: variant of {@link #collectColumnRefs} that excludes column
16828     * refs inside {@code FILTER (WHERE ...)} clauses on any function call
16829     * in the subtree. Used by {@link #buildOutputColumns} for ALL output
16830     * source collection so the IR's per-projection {@code OutputColumn.sources}
16831     * matches dlineage's lineage-relationship view (which omits FILTER
16832     * predicate column refs entirely; see slice-28 probes Q1–Q4).
16833     *
16834     * <p>For projections that contain no FILTER aggregates (the common case),
16835     * Pass 1 yields zero skip-roots and Pass 2 reduces to the plain
16836     * {@link #collectColumnRefs}. The asymmetry between projection sources
16837     * (FILTER-skipped) and clause refs ({@code filterColumnRefs},
16838     * {@code joinColumnRefs}, {@code groupByColumnRefs},
16839     * {@code havingColumnRefs}, {@code orderByColumnRefs} — NOT
16840     * FILTER-skipped) is intentional: it keeps the existing
16841     * {@link #collectAllInnerRefs}-driven correlation check at line ~3603
16842     * sufficient for FILTER refs landing in non-projection clauses, while
16843     * the slice-28 correlation walk in {@link #extractOnePredicateSubqueryBody}
16844     * covers projection-FILTER refs.
16845     */
16846    private static List<ColumnRef> collectColumnRefsExcludingFilterClauses(
16847            gudusoft.gsqlparser.nodes.TParseTreeNode root,
16848            NameBindingProvider provider) {
16849        Set<TExpression> filterClauses = collectFilterClauses(root);
16850        if (filterClauses.isEmpty()) {
16851            return collectColumnRefs(root, provider);
16852        }
16853        return collectColumnRefsSkipping(root, provider, filterClauses);
16854    }
16855
16856    /**
16857     * Slice 31: identity-keyed set of every {@link TWithinGroup} reachable
16858     * from {@code root} via {@code fn.getWindowDef().getWithinGroup()} —
16859     * the Oracle / MSSQL attachment style for plain {@code WITHIN GROUP
16860     * (ORDER BY ...)} aggregates. Used as additional skip-roots in
16861     * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} so
16862     * the column refs inside the WITHIN GROUP ORDER BY do NOT enter
16863     * {@link OutputColumn#getSources()} on Oracle / MSSQL — matching
16864     * dlineage's omission of those refs from {@code fdr clause="on"}
16865     * sources (probe Q1 / Q3 / Q4 / Q5 in {@code /tmp/probe31}).
16866     *
16867     * <p>Discriminator: {@link #isWithinGroupOnlyWindowDef}. OVER-bearing
16868     * windowDefs (real window functions) are NOT collected here — the
16869     * slice-13 invariant rejecters keep them rejected before this
16870     * collector ever fires for projection sources, so they cannot
16871     * reach the source-skip in practice. Defensive: even if they did,
16872     * the discriminator excludes them so PARTITION BY / OVER ORDER BY
16873     * column refs (slice-13 / slice-19 alias-bound contracts) keep
16874     * their existing semantics.
16875     *
16876     * <p>The PostgreSQL direct {@code fn.getWithinGroup()} attachment
16877     * is NOT collected here because PG's
16878     * {@code TFunctionCall.acceptChildren} does not descend into the
16879     * direct field — slice 29 relied on that asymmetry to admit PG
16880     * WITHIN GROUP aggregates without any source-skip; slice 31
16881     * preserves that asymmetry on PG.
16882     */
16883    private static Set<TWithinGroup> collectWithinGroupClausesFromWindowDef(
16884            gudusoft.gsqlparser.nodes.TParseTreeNode root) {
16885        final Set<TWithinGroup> out =
16886                Collections.newSetFromMap(new IdentityHashMap<TWithinGroup, Boolean>());
16887        if (root == null) return out;
16888        root.acceptChildren(new TParseTreeVisitor() {
16889            @Override
16890            public void preVisit(TFunctionCall fn) {
16891                TWindowDef wd = fn.getWindowDef();
16892                if (isWithinGroupOnlyWindowDef(wd)) {
16893                    out.add(wd.getWithinGroup());
16894                }
16895            }
16896        });
16897        if (root instanceof TExpression) {
16898            TExpression e = (TExpression) root;
16899            if (e.getExpressionType() == EExpressionType.function_t) {
16900                addWithinGroupFromWindowDefIfPresent(e.getFunctionCall(), out);
16901            }
16902        } else if (root instanceof TResultColumn) {
16903            TResultColumn rc = (TResultColumn) root;
16904            TExpression e = rc.getExpr();
16905            if (e != null && e.getExpressionType() == EExpressionType.function_t) {
16906                addWithinGroupFromWindowDefIfPresent(e.getFunctionCall(), out);
16907            }
16908        }
16909        return out;
16910    }
16911
16912    /**
16913     * Slice 31: helper for top-level direct check inside
16914     * {@link #collectWithinGroupClausesFromWindowDef}. Mirrors the
16915     * slice-30 {@link #addWithinGroupOrderByIfPresent} helper but adds
16916     * the {@link TWithinGroup} node itself to {@code out} (the entire
16917     * WITHIN GROUP subtree is the skip-root, not just its ORDER BY).
16918     */
16919    private static void addWithinGroupFromWindowDefIfPresent(TFunctionCall fn,
16920                                                             Set<TWithinGroup> out) {
16921        if (fn == null) return;
16922        TWindowDef wd = fn.getWindowDef();
16923        if (isWithinGroupOnlyWindowDef(wd)) {
16924            out.add(wd.getWithinGroup());
16925        }
16926    }
16927
16928    /**
16929     * Slice 31: extends slice-28's filter-skipping projection-source
16930     * collector with an additional skip for Oracle / MSSQL
16931     * {@code fn.windowDef.withinGroup} subtrees. Reduces to slice-28
16932     * behavior on PostgreSQL (where windowDef is null) and to plain
16933     * {@link #collectColumnRefs} when neither FILTER nor WITHIN GROUP
16934     * is present.
16935     *
16936     * <p>Used by {@link #buildOutputColumns} for ALL projection source
16937     * collection (predicate-body short-circuit at line ~4952 and
16938     * normal projection loop at line ~5069) so the IR's
16939     * {@link OutputColumn#getSources()} matches dlineage's
16940     * lineage-relationship view across PG / Oracle / MSSQL. The
16941     * non-projection clause-bucket collectors
16942     * ({@code filterColumnRefs}, {@code joinColumnRefs},
16943     * {@code groupByColumnRefs}, {@code havingColumnRefs},
16944     * {@code orderByColumnRefs}) intentionally keep using plain
16945     * {@link #collectColumnRefs} — the slice-13 strict
16946     * {@code rejectWindowFunctionInScope} family rejects any
16947     * {@code wd != null} function in those clauses BEFORE collection
16948     * descends into them, so WITHIN GROUP refs cannot leak into
16949     * clause buckets in practice.
16950     */
16951    private static List<ColumnRef> collectColumnRefsExcludingFilterAndWithinGroupClauses(
16952            gudusoft.gsqlparser.nodes.TParseTreeNode root,
16953            NameBindingProvider provider) {
16954        Set<TExpression> filterClauses = collectFilterClauses(root);
16955        Set<TWithinGroup> withinGroupClauses = collectWithinGroupClausesFromWindowDef(root);
16956        if (filterClauses.isEmpty() && withinGroupClauses.isEmpty()) {
16957            return collectColumnRefs(root, provider);
16958        }
16959        return collectColumnRefsSkippingExtended(root, provider,
16960                filterClauses, withinGroupClauses);
16961    }
16962
16963    /**
16964     * Slice 31: variant of {@link #collectColumnRefsSkipping} that
16965     * additionally skips column refs inside {@link TWithinGroup}
16966     * subtrees in {@code wgSkipRoots} (Oracle / MSSQL
16967     * {@code fn.windowDef.withinGroup} attachment). The existing
16968     * {@code exprSkipRoots} carries the slice-28 FILTER subtrees.
16969     * Returns column refs in iteration order.
16970     *
16971     * <p>Refactor note: {@link #collectColumnRefsSkipping} now delegates
16972     * to this method with an empty {@code wgSkipRoots} set so its
16973     * behavior is preserved exactly for legacy callers (the outer
16974     * JOIN-ON path at line ~3021).
16975     */
16976    private static List<ColumnRef> collectColumnRefsSkippingExtended(
16977            gudusoft.gsqlparser.nodes.TParseTreeNode root,
16978            final NameBindingProvider provider,
16979            final Set<TExpression> exprSkipRoots,
16980            final Set<TWithinGroup> wgSkipRoots) {
16981        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
16982        final List<String> rejects = new ArrayList<>();
16983        // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at
16984        // the wrong side). Such a miss is a genuine error and must stay fatal;
16985        // only all-unqualified rejects are eligible for the join-graph degrade.
16986        final boolean[] sawQualifiedReject = {false};
16987        // Root fast path: if root IS a skipped TExpression subtree, return empty.
16988        if (root instanceof TExpression && exprSkipRoots.contains(root)) {
16989            return new ArrayList<>(refs);
16990        }
16991        root.acceptChildren(new TParseTreeVisitor() {
16992            int skipDepth = 0;
16993            int nestedSelectDepth = 0;
16994
16995            @Override
16996            public void preVisit(TExpression e) {
16997                if (exprSkipRoots.contains(e)) skipDepth++;
16998            }
16999
17000            @Override
17001            public void postVisit(TExpression e) {
17002                if (exprSkipRoots.contains(e) && skipDepth > 0) skipDepth--;
17003            }
17004
17005            @Override
17006            public void preVisit(TWithinGroup wg) {
17007                if (wgSkipRoots.contains(wg)) skipDepth++;
17008            }
17009
17010            @Override
17011            public void postVisit(TWithinGroup wg) {
17012                if (wgSkipRoots.contains(wg) && skipDepth > 0) skipDepth--;
17013            }
17014
17015            @Override
17016            public void preVisit(TSelectSqlStatement nested) {
17017                nestedSelectDepth++;
17018            }
17019
17020            @Override
17021            public void postVisit(TSelectSqlStatement nested) {
17022                nestedSelectDepth--;
17023            }
17024
17025            @Override
17026            public void preVisit(TObjectName node) {
17027                if (skipDepth > 0) return;
17028                if (nestedSelectDepth > 0) return;
17029                appendMergedOrBoundColumnRef(node, provider, refs, rejects,
17030                        sawQualifiedReject);
17031            }
17032        });
17033        if (!rejects.isEmpty()) {
17034            rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]);
17035        }
17036        return new ArrayList<>(refs);
17037    }
17038
17039    /**
17040     * Reject join shapes that would silently drop predicate semantics:
17041     * semi/anti, vendor-specific kinds; predicate-bearing joins with
17042     * no ON and no USING clause; CROSS / NATURAL JOIN with ON or
17043     * USING. Slice 63 admits {@code CROSS JOIN} via
17044     * {@link #ALLOWED_ON_LESS_JOIN_TYPES}. Slice 64 admits
17045     * {@code JOIN ... USING (...)} on predicate join types; the
17046     * per-key {@code joinColumnRefs} emission is handled in
17047     * {@link #buildRelations}. Slice 66 admits {@code NATURAL JOIN}
17048     * via {@link #NATURAL_JOIN_TYPES} when catalog metadata is
17049     * available on both sides; the catalog-required reject fires
17050     * inside {@link #buildRelations}, not here.
17051     */
17052    private static void rejectUnsupportedJoinShape(TJoinItem item) {
17053        EJoinType jt = item.getJoinType();
17054        boolean isPredicate = jt != null && ALLOWED_PREDICATE_JOIN_TYPES.contains(jt);
17055        boolean isOnLess = jt != null && ALLOWED_ON_LESS_JOIN_TYPES.contains(jt);
17056        boolean isNatural = isNaturalJoinType(jt);
17057        boolean isLateral = isLateralJoinType(jt);
17058        if (!isPredicate && !isOnLess && !isNatural && !isLateral) {
17059            throw new SemanticIRBuildException(
17060                    Diagnostic.error(DiagnosticCode.UNSUPPORTED_JOIN_TYPE,
17061                    "join type " + jt + " is not supported yet; "
17062                            + "only INNER/LEFT/RIGHT/FULL [OUTER] JOIN ... ON, "
17063                            + "JOIN ... USING (...), CROSS JOIN, CROSS/OUTER APPLY, and "
17064                            + "NATURAL [INNER/LEFT/RIGHT/FULL [OUTER]] JOIN are accepted", item));
17065        }
17066        boolean hasUsing = item.getUsingColumns() != null
17067                && item.getUsingColumns().size() > 0;
17068        boolean hasOn = item.getOnCondition() != null;
17069        if (isLateral) {
17070            // CROSS/OUTER APPLY is an ON-less lateral join; the correlation
17071            // lives inside the right operand, never on an ON/USING clause.
17072            // The parser does not attach ON/USING to APPLY items, so these
17073            // are defensive guards (reuse the CROSS_WITH_* codes — no new
17074            // diagnostic code is introduced for APPLY).
17075            if (hasOn) {
17076                throw new SemanticIRBuildException(
17077                        Diagnostic.error(DiagnosticCode.CROSS_WITH_ON,
17078                        "CROSS/OUTER APPLY must not carry an ON condition; the "
17079                                + "correlation belongs inside the right operand", item));
17080            }
17081            if (hasUsing) {
17082                throw new SemanticIRBuildException(
17083                        Diagnostic.error(DiagnosticCode.CROSS_WITH_USING,
17084                        "CROSS/OUTER APPLY must not carry a USING clause; the "
17085                                + "correlation belongs inside the right operand", item));
17086            }
17087            return;
17088        }
17089        if (isNatural) {
17090            if (hasOn) {
17091                throw new SemanticIRBuildException(
17092                        Diagnostic.error(DiagnosticCode.NATURAL_WITH_ON,
17093                        "NATURAL JOIN must not carry an ON condition; rewrite "
17094                                + "as JOIN ... ON, or drop the NATURAL keyword", item));
17095            }
17096            if (hasUsing) {
17097                throw new SemanticIRBuildException(
17098                        Diagnostic.error(DiagnosticCode.NATURAL_WITH_USING,
17099                        "NATURAL JOIN must not carry a USING clause; choose "
17100                                + "either NATURAL or USING, not both", item));
17101            }
17102            return;
17103        }
17104        if (isOnLess) {
17105            if (hasOn) {
17106                throw new SemanticIRBuildException(
17107                        Diagnostic.error(DiagnosticCode.CROSS_WITH_ON,
17108                        "CROSS JOIN must not carry an ON condition; rewrite "
17109                                + "as INNER JOIN ... ON, or drop the ON clause", item));
17110            }
17111            if (hasUsing) {
17112                throw new SemanticIRBuildException(
17113                        Diagnostic.error(DiagnosticCode.CROSS_WITH_USING,
17114                        "CROSS JOIN must not carry a USING clause; rewrite "
17115                                + "as INNER JOIN ... USING (...) or drop USING", item));
17116            }
17117            return;
17118        }
17119        // Predicate-bearing path.
17120        if (hasUsing && hasOn) {
17121            throw new SemanticIRBuildException(
17122                    Diagnostic.error(DiagnosticCode.JOIN_WITH_BOTH_ON_AND_USING,
17123                    "JOIN cannot carry both ON and USING; choose one", item));
17124        }
17125        if (!hasUsing && !hasOn) {
17126            throw new SemanticIRBuildException(
17127                    Diagnostic.error(DiagnosticCode.JOIN_MISSING_ON_OR_USING,
17128                    "JOIN with no ON or USING condition is not supported yet "
17129                            + "(implicit joins must be explicit and supported)", item));
17130        }
17131    }
17132
17133    /**
17134     * Slice 64 — populate {@code joinColumnRefs} for a USING-shaped
17135     * join item. Emits refs in <b>left-then-right</b> order per key.
17136     *
17137     * <p>Column-source resolution looks at two sources, in order:
17138     * <ol>
17139     *   <li>The catalog (via
17140     *       {@link NameBindingProvider#getRelationColumnNames(TTable)})
17141     *       for base tables;</li>
17142     *   <li>The slice-60 in-scope-relation-columns map (via
17143     *       {@link NameBindingProvider#getInScopeRelationColumns()})
17144     *       for CTE and FROM-subquery relations, keyed by effective
17145     *       alias.</li>
17146     * </ol>
17147     *
17148     * <p>Left side uses these two sources to narrow to the prior
17149     * relations that actually declare the USING key, walking
17150     * {@code topJoin.getTable()} then
17151     * {@code items[0..itemIndex-1].getTable()} in FROM order.
17152     *
17153     * <p>Right side is always {@code item.getTable()}. When either
17154     * source declares the right relation's columns and the USING key
17155     * is absent there, the build is failed-fast with a
17156     * non-exact-binding-style reject — matching what the resolver
17157     * does for plain {@code SELECT k} where {@code k} doesn't exist.
17158     *
17159     * <p>When neither source has any column info for the prior
17160     * relations (no catalog and no in-scope map), fall back to
17161     * emitting one ref for the immediately-prior relation so the
17162     * slice-64 admission still works without a catalog. Same
17163     * fallback applies to the right side (emit unconditionally).
17164     *
17165     * <p>This matches resolver2's all-chain-tables linkage
17166     * ({@code ScopeBuilder.preVisit(TJoinItem)}) for the cases where
17167     * catalog/in-scope info is missing, without adopting its
17168     * over-approximation when info IS available.
17169     */
17170    private static void populateUsingJoinRefs(TJoin topJoin,
17171                                              TJoinItemList items,
17172                                              int itemIndex,
17173                                              TTable rightTable,
17174                                              TObjectNameList usingCols,
17175                                              NameBindingProvider provider,
17176                                              List<ColumnRef> joinRefsOut) {
17177        // Slice 66: collect the SQL-written USING key spellings and
17178        // delegate to the shared {@link #emitMergedJoinRefs} helper
17179        // which serves both USING (this path) and NATURAL.
17180        List<String> keyNames = new ArrayList<>(usingCols.size());
17181        for (int k = 0; k < usingCols.size(); k++) {
17182            TObjectName usingKey = usingCols.getObjectName(k);
17183            if (usingKey == null) continue;
17184            String keyName = usingKey.getColumnNameOnly();
17185            if (keyName == null || keyName.isEmpty()) continue;
17186            keyNames.add(keyName);
17187        }
17188        emitMergedJoinRefs(JoinKind.USING, keyNames, topJoin, items,
17189                itemIndex, rightTable, provider, joinRefsOut);
17190    }
17191
17192    /**
17193     * Slice 66 — discriminator for {@link #emitMergedJoinRefs}. USING
17194     * comes from a syntactic clause and enforces left-and-right
17195     * "key-must-exist" rejects; NATURAL comes from catalog inference
17196     * and never has a missing key by construction.
17197     */
17198    private enum JoinKind { USING, NATURAL }
17199
17200    /**
17201     * Slice 66 — shared emit-refs helper used by USING and NATURAL.
17202     * Emits per-key {@code joinColumnRefs} in <b>left-then-right</b>
17203     * order, walking every prior FROM relation for the left side. The
17204     * {@code kind} discriminator controls:
17205     *
17206     * <ul>
17207     *   <li><b>CTE-explicit-column-list deferral</b>: applies to BOTH
17208     *       (the diagnostic wording mentions JOIN kind);</li>
17209     *   <li><b>Right-side "missing key" reject</b>: USING-only —
17210     *       NATURAL keys come from catalog intersection so the key
17211     *       must be present on the right by construction;</li>
17212     *   <li><b>Left-side "missing key" reject</b>: USING-only — same
17213     *       rationale.</li>
17214     * </ul>
17215     *
17216     * <p>Spelling: the caller supplies the emitted spelling (USING
17217     * passes the SQL-written spelling; NATURAL passes the catalog-
17218     * declared spelling of the first contributor — see
17219     * {@link #naturalSharedKeys}).
17220     */
17221    private static void emitMergedJoinRefs(JoinKind kind,
17222                                           List<String> keyNames,
17223                                           TJoin topJoin,
17224                                           TJoinItemList items,
17225                                           int itemIndex,
17226                                           TTable rightTable,
17227                                           NameBindingProvider provider,
17228                                           List<ColumnRef> joinRefsOut) {
17229        String rightAlias = effectiveAliasOf(rightTable);
17230        List<TTable> priorRelations = new ArrayList<>();
17231        if (topJoin.getTable() != null) {
17232            priorRelations.add(topJoin.getTable());
17233        }
17234        for (int j = 0; j < itemIndex; j++) {
17235            TJoinItem prevItem = items.getJoinItem(j);
17236            if (prevItem != null && prevItem.getTable() != null) {
17237                priorRelations.add(prevItem.getTable());
17238            }
17239        }
17240        // Slice 60 / 64 / 66 originally rejected USING / NATURAL joins
17241        // against a CTE with an explicit column list because the CTE
17242        // body's StatementGraph published inner-projection names rather
17243        // than the renamed list. Slice 103 lifts that rejection by
17244        // wiring the slice-102 rename helper into the SELECT-side CTE
17245        // walker; the published column list now matches the explicit
17246        // list, so lookupRelationColumnNames returns the renamed names
17247        // from the in-scope map and the merged-key emit below works.
17248        // `MERGED_JOIN_AGAINST_CTE_WITH_EXPLICIT_COLUMN_LIST` stays
17249        // declared-but-unreached (slice 71/72/82/86/95/96/97/98/99/100/
17250        // 101/102 precedent).
17251        for (String keyName : keyNames) {
17252            if (keyName == null || keyName.isEmpty()) continue;
17253
17254            // Left side FIRST (matches ON-clause natural reading order).
17255            // For USING: priorRelations with metadata-unknown or
17256            // declared-key emit a ref; missing-key skips. If no ref
17257            // emitted and all priors had known info → USING-only
17258            // reject (NATURAL never reaches this because the catalog
17259            // intersection guarantees at least one contributor).
17260            boolean emittedAnyLeft = false;
17261            boolean allPriorsHadColumnInfo = true;
17262            for (TTable prior : priorRelations) {
17263                List<String> cols = lookupRelationColumnNames(prior, provider);
17264                if (cols == null) {
17265                    allPriorsHadColumnInfo = false;
17266                    joinRefsOut.add(new ColumnRef(
17267                            effectiveAliasOf(prior), keyName));
17268                    emittedAnyLeft = true;
17269                    continue;
17270                }
17271                for (String c : cols) {
17272                    if (c != null && c.equalsIgnoreCase(keyName)) {
17273                        joinRefsOut.add(new ColumnRef(
17274                                effectiveAliasOf(prior), keyName));
17275                        emittedAnyLeft = true;
17276                        break;
17277                    }
17278                }
17279            }
17280            if (kind == JoinKind.USING
17281                    && !emittedAnyLeft && allPriorsHadColumnInfo
17282                    && !priorRelations.isEmpty()) {
17283                throw new SemanticIRBuildException(
17284                        Diagnostic.error(DiagnosticCode.USING_KEY_NOT_DECLARED,
17285                        "USING key '" + keyName + "' is not declared on "
17286                                + "any left-side relation; check that the "
17287                                + "key exists on at least one of the "
17288                                + "joined-in relations", rightTable));
17289            }
17290
17291            // Right side. USING: must exist OR catalog unknown.
17292            // NATURAL: by construction the key is in right's catalog.
17293            // For the unknown-catalog case we still emit (over-approximate).
17294            List<String> rightCols = lookupRelationColumnNames(rightTable, provider);
17295            if (rightCols == null) {
17296                joinRefsOut.add(new ColumnRef(rightAlias, keyName));
17297            } else {
17298                boolean rightHasKey = false;
17299                for (String c : rightCols) {
17300                    if (c != null && c.equalsIgnoreCase(keyName)) {
17301                        rightHasKey = true;
17302                        break;
17303                    }
17304                }
17305                if (!rightHasKey) {
17306                    if (kind == JoinKind.USING) {
17307                        throw new SemanticIRBuildException(
17308                                Diagnostic.error(DiagnosticCode.USING_KEY_NOT_DECLARED,
17309                                "USING key '" + keyName + "' is not declared on "
17310                                        + "right-side relation '" + rightAlias
17311                                        + "'; USING requires the key to exist on "
17312                                        + "both sides", rightTable));
17313                    }
17314                    // NATURAL: silently skip — should be unreachable
17315                    // because keys come from the intersection.
17316                    continue;
17317                }
17318                joinRefsOut.add(new ColumnRef(rightAlias, keyName));
17319            }
17320        }
17321    }
17322
17323    /**
17324     * Slice 64 — true iff the given table reference is a CTE with an
17325     * explicit column list (e.g. {@code WITH x(a, b) AS ...}). Slice
17326     * 64 originally used this to defer USING joins against such CTEs;
17327     * slice 103 lifted that deferral by wiring the slice-102 rename
17328     * helper into the SELECT-side CTE walker. The helper is retained
17329     * for {@link #buildUsingScope}'s ambiguity check (defense in depth)
17330     * and may be reused by future call sites that need to discriminate
17331     * the shape.
17332     */
17333    private static boolean hasExplicitCteColumnList(TTable table) {
17334        if (table == null) return false;
17335        TCTE cte = table.getCTE();
17336        return cte != null && cte.getColumnList() != null
17337                && cte.getColumnList().size() > 0;
17338    }
17339
17340    /**
17341     * Slice 65 — read the renamed column names from a CTE's explicit
17342     * column list ({@code WITH x(a, b) AS ...}). Used by
17343     * {@link #buildUsingScope}'s ambiguity check as a defense-in-depth
17344     * complement to {@link #lookupRelationColumnNames}. Slice 65
17345     * originally needed this because the CTE body's StatementGraph
17346     * published inner-projection names; slice 103 lifted that gap by
17347     * applying the slice-102 rename helper on the SELECT side, so the
17348     * in-scope-map path now returns the renamed list too.
17349     */
17350    private static java.util.List<String> explicitCteColumnNames(TTable table) {
17351        if (table == null) return null;
17352        TCTE cte = table.getCTE();
17353        if (cte == null) return null;
17354        if (cte.getColumnList() == null || cte.getColumnList().size() == 0) {
17355            return null;
17356        }
17357        java.util.List<String> names = new java.util.ArrayList<>(cte.getColumnList().size());
17358        for (int i = 0; i < cte.getColumnList().size(); i++) {
17359            TObjectName col = cte.getColumnList().getObjectName(i);
17360            if (col == null) continue;
17361            String n = col.getColumnNameOnly();
17362            if (n == null || n.isEmpty()) continue;
17363            names.add(n);
17364        }
17365        return names.isEmpty() ? null : names;
17366    }
17367
17368    /**
17369     * Slice 64 — look up column names for a FROM-clause relation
17370     * combining the slice-58 base-table catalog and the slice-60
17371     * in-scope CTE/subquery map. Returns {@code null} when neither
17372     * source has column info for the table.
17373     *
17374     * <p>The in-scope map is consulted <b>first</b>: when a CTE or
17375     * FROM-subquery has the same name as a base table in the catalog,
17376     * the scoped definition shadows the catalog (codex diff-review
17377     * round-2 P2 #1 — without this precedence, USING against the CTE
17378     * would see the catalog table's columns and reject a valid join).
17379     *
17380     * <p>Slice 103 — CTEs with an explicit column list are no longer
17381     * rejected upstream. The SELECT-side CTE walker now invokes the
17382     * slice-102 rename helper, so {@code ctePublishedColumns} carries
17383     * the renamed names; {@code addRelationToInScopeMap} reads from
17384     * that map, and this lookup returns the renamed list. (Slice 64's
17385     * older comment said the rename was deferred — that deferral was
17386     * lifted by slice 103.)
17387     */
17388    private static List<String> lookupRelationColumnNames(TTable table,
17389                                                          NameBindingProvider provider) {
17390        String key = effectiveAliasLowerCaseOrNull(table);
17391        if (key != null) {
17392            java.util.Map<String, List<String>> inScope = provider.getInScopeRelationColumns();
17393            if (inScope != null) {
17394                List<String> scoped = inScope.get(key);
17395                if (scoped != null) return scoped;
17396            }
17397        }
17398        return provider.getRelationColumnNames(table);
17399    }
17400
17401    /**
17402     * True when EVERY FROM relation of {@code select} has a known column list
17403     * (catalog metadata via {@link NameBindingProvider#getRelationColumnNames}
17404     * or in-scope derived columns via {@code getInScopeRelationColumns}). Used to
17405     * gate the join-graph {@code COLUMN_BINDING_NON_EXACT} degrade: the degrade
17406     * is justified only when the FROM is catalog-less for at least one endpoint,
17407     * because then an unqualified column that fails to bind <em>might</em> live
17408     * on the unknown side and we cannot prove otherwise. When every endpoint's
17409     * columns are known, the resolver can adjudicate — an unqualified column
17410     * matching no side is a genuine error and must stay fatal.
17411     *
17412     * <p>Returns {@code false} (i.e. not fully known → degrade-eligible) for an
17413     * empty/absent FROM list; callers gate on {@code relations.size() >= 2}
17414     * first, so that branch is not reached in practice.
17415     */
17416    private static boolean fromRelationColumnsFullyKnown(TSelectSqlStatement select,
17417                                                         NameBindingProvider provider) {
17418        if (select == null) {
17419            return false;
17420        }
17421        gudusoft.gsqlparser.nodes.TTableList tables = select.getTables();
17422        if (tables == null || tables.size() == 0) {
17423            return false;
17424        }
17425        for (int i = 0; i < tables.size(); i++) {
17426            TTable t = tables.getTable(i);
17427            if (t == null) {
17428                continue;
17429            }
17430            List<String> cols = lookupRelationColumnNames(t, provider);
17431            if (cols == null || cols.isEmpty()) {
17432                return false;
17433            }
17434        }
17435        return true;
17436    }
17437
17438    /**
17439     * Slice 66 — accumulated row type of the LEFT side of a top-level
17440     * {@code TJoin}. Maintained per top-level TJoin so that mixed
17441     * ON/CROSS/USING/NATURAL chains can be reasoned about against the
17442     * full visible row type (NATURAL JOIN's right operand sees every
17443     * column visible in the accumulated left, not just the immediate
17444     * prior table).
17445     *
17446     * <p>{@link #complete} flips to {@code false} when any contributor
17447     * along the chain has no resolvable catalog. A {@code false}
17448     * {@code complete} blocks subsequent NATURAL JoinItems from
17449     * inferring their shared-key list — they reject with a tuned
17450     * catalog-required diagnostic naming whichever side(s) lack
17451     * catalog metadata.
17452     */
17453    private static final class LeftOutputState {
17454        final java.util.LinkedHashMap<String, List<TTable>> columns = new java.util.LinkedHashMap<>();
17455        boolean complete = true;
17456        final List<String> missingAliases = new ArrayList<>();
17457
17458        void markMissing(TTable t) {
17459            complete = false;
17460            String alias = effectiveAliasOf(t);
17461            if (alias != null && !alias.isEmpty()) {
17462                if (!missingAliases.contains(alias)) {
17463                    missingAliases.add(alias);
17464                }
17465            }
17466        }
17467    }
17468
17469    /**
17470     * Slice 66 — result of {@link #naturalSharedKeys}. Either a SUCCESS
17471     * (with the inferred key list in left-output insertion order) or
17472     * one of three failure kinds:
17473     *
17474     * <ul>
17475     *   <li>{@code INCOMPLETE_LEFT}: at least one prior contributor on
17476     *       the accumulated left side had null/empty catalog;</li>
17477     *   <li>{@code MISSING_RIGHT}: right table has null/empty catalog;</li>
17478     *   <li>{@code BOTH_MISSING}: both above conditions hold.</li>
17479     * </ul>
17480     *
17481     * <p>Failures carry diagnostic aliases so the caller can produce
17482     * a side-specific reject message.
17483     */
17484    private static final class NaturalKeyResult {
17485        enum Kind { SUCCESS, INCOMPLETE_LEFT, MISSING_RIGHT, BOTH_MISSING }
17486        final Kind kind;
17487        final List<String> keys;
17488        final List<String> leftMissingAliases;
17489        final String rightAlias;
17490
17491        private NaturalKeyResult(Kind kind, List<String> keys,
17492                                 List<String> leftMissingAliases,
17493                                 String rightAlias) {
17494            this.kind = kind;
17495            this.keys = keys;
17496            this.leftMissingAliases = leftMissingAliases;
17497            this.rightAlias = rightAlias;
17498        }
17499        static NaturalKeyResult success(List<String> keys) {
17500            return new NaturalKeyResult(Kind.SUCCESS, keys, null, null);
17501        }
17502        static NaturalKeyResult incompleteLeft(List<String> missing) {
17503            return new NaturalKeyResult(Kind.INCOMPLETE_LEFT, null, missing, null);
17504        }
17505        static NaturalKeyResult missingRight(String alias) {
17506            return new NaturalKeyResult(Kind.MISSING_RIGHT, null, null, alias);
17507        }
17508        static NaturalKeyResult bothMissing(List<String> missing, String alias) {
17509            return new NaturalKeyResult(Kind.BOTH_MISSING, null, missing, alias);
17510        }
17511    }
17512
17513    /**
17514     * Slice 66 — seed the {@link LeftOutputState} with the top-left
17515     * table of a top-level TJoin. Used at the start of each TJoin walk.
17516     */
17517    private static void seedLeftOutput(LeftOutputState state, TTable t,
17518                                       NameBindingProvider provider) {
17519        if (t == null) return;
17520        List<String> cols = lookupRelationColumnNames(t, provider);
17521        if (cols == null || cols.isEmpty()) {
17522            state.markMissing(t);
17523            return;
17524        }
17525        for (String c : cols) {
17526            if (c == null || c.isEmpty()) continue;
17527            String colLC = c.toLowerCase(Locale.ROOT);
17528            List<TTable> contributors = state.columns.get(colLC);
17529            if (contributors == null) {
17530                contributors = new ArrayList<>();
17531                state.columns.put(colLC, contributors);
17532            }
17533            contributors.add(t);
17534        }
17535    }
17536
17537    /**
17538     * Slice 66 — append the right table of an ON-shaped or CROSS
17539     * JoinItem into the running {@link LeftOutputState}. Each catalog
17540     * column is added as a new entry (or extends the contributor list
17541     * for an existing same-named entry). Mirrors
17542     * {@link #seedLeftOutput} but additive.
17543     */
17544    /**
17545     * Merge the row type accumulated in {@code src} (a nested joined-table
17546     * operand's own {@link LeftOutputState}) into {@code dst} (the enclosing
17547     * join's running state). Used after recursing into a parenthesized
17548     * {@code <joined_table>} right operand so that a NATURAL join FOLLOWING the
17549     * nested operand at the enclosing level sees the sub-tree's columns, while
17550     * the sub-tree's own inner NATURAL inference ran against a fresh state.
17551     */
17552    private static void mergeLeftOutputState(LeftOutputState dst, LeftOutputState src) {
17553        if (src == null) return;
17554        for (Map.Entry<String, List<TTable>> e : src.columns.entrySet()) {
17555            List<TTable> contributors = dst.columns.get(e.getKey());
17556            if (contributors == null) {
17557                contributors = new ArrayList<>();
17558                dst.columns.put(e.getKey(), contributors);
17559            }
17560            contributors.addAll(e.getValue());
17561        }
17562        if (!src.complete) {
17563            dst.complete = false;
17564            for (String a : src.missingAliases) {
17565                if (!dst.missingAliases.contains(a)) {
17566                    dst.missingAliases.add(a);
17567                }
17568            }
17569        }
17570    }
17571
17572    private static void appendRightToLeftOutput(LeftOutputState state, TTable right,
17573                                                NameBindingProvider provider) {
17574        if (right == null) return;
17575        List<String> cols = lookupRelationColumnNames(right, provider);
17576        if (cols == null || cols.isEmpty()) {
17577            state.markMissing(right);
17578            return;
17579        }
17580        for (String c : cols) {
17581            if (c == null || c.isEmpty()) continue;
17582            String colLC = c.toLowerCase(Locale.ROOT);
17583            List<TTable> contributors = state.columns.get(colLC);
17584            if (contributors == null) {
17585                contributors = new ArrayList<>();
17586                state.columns.put(colLC, contributors);
17587            }
17588            contributors.add(right);
17589        }
17590    }
17591
17592    /**
17593     * Slice 66 — merge the right table of a USING-shaped or
17594     * NATURAL-shaped JoinItem. Columns in {@code mergedKeys} are
17595     * appended to the existing same-named contributor list at their
17596     * original output position (no new slot); other columns are
17597     * appended as new entries (or contributed to an existing same-named
17598     * entry — slice-59 plain-vs-plain duplicate admit).
17599     */
17600    private static void mergeRightIntoLeftOutput(LeftOutputState state, TTable right,
17601                                                 NameBindingProvider provider,
17602                                                 List<String> mergedKeys) {
17603        if (right == null) return;
17604        java.util.Set<String> mergedKeysLC = new HashSet<>();
17605        if (mergedKeys != null) {
17606            for (String k : mergedKeys) {
17607                if (k != null && !k.isEmpty()) {
17608                    mergedKeysLC.add(k.toLowerCase(Locale.ROOT));
17609                }
17610            }
17611        }
17612        List<String> cols = lookupRelationColumnNames(right, provider);
17613        if (cols == null || cols.isEmpty()) {
17614            state.markMissing(right);
17615            return;
17616        }
17617        for (String c : cols) {
17618            if (c == null || c.isEmpty()) continue;
17619            String colLC = c.toLowerCase(Locale.ROOT);
17620            // mergedKeysLC.contains(colLC) — append to existing entry;
17621            // !mergedKeysLC.contains(colLC) && state.columns.containsKey(colLC)
17622            //   — append to existing entry (plain-vs-plain duplicate);
17623            // !state.columns.containsKey(colLC) — new entry.
17624            List<TTable> contributors = state.columns.get(colLC);
17625            if (contributors == null) {
17626                contributors = new ArrayList<>();
17627                state.columns.put(colLC, contributors);
17628            }
17629            contributors.add(right);
17630        }
17631    }
17632
17633    /**
17634     * Slice 66 — infer the NATURAL JOIN shared-column list for the
17635     * current JoinItem. Returns one of four results per §6.1 of the
17636     * slice-66 plan. The shared list uses catalog-declared spelling
17637     * from the FIRST contributor that publishes each key (NATURAL has
17638     * no SQL-written key token, so the catalog form is the only
17639     * source of truth).
17640     */
17641    private static NaturalKeyResult naturalSharedKeys(LeftOutputState leftState,
17642                                                     TTable right,
17643                                                     NameBindingProvider provider) {
17644        List<String> rightCols = lookupRelationColumnNames(right, provider);
17645        boolean rightMissing = (rightCols == null || rightCols.isEmpty());
17646        if (!leftState.complete && rightMissing) {
17647            return NaturalKeyResult.bothMissing(leftState.missingAliases,
17648                    effectiveAliasOf(right));
17649        }
17650        if (!leftState.complete) {
17651            return NaturalKeyResult.incompleteLeft(leftState.missingAliases);
17652        }
17653        if (rightMissing) {
17654            return NaturalKeyResult.missingRight(effectiveAliasOf(right));
17655        }
17656        java.util.Set<String> rightLC = new HashSet<>();
17657        for (String c : rightCols) {
17658            if (c != null && !c.isEmpty()) {
17659                rightLC.add(c.toLowerCase(Locale.ROOT));
17660            }
17661        }
17662        List<String> shared = new ArrayList<>();
17663        for (java.util.Map.Entry<String, List<TTable>> e
17664                : leftState.columns.entrySet()) {
17665            String keyLC = e.getKey();
17666            if (rightLC.contains(keyLC)) {
17667                shared.add(firstCatalogSpelling(e.getValue(), keyLC, provider));
17668            }
17669        }
17670        return NaturalKeyResult.success(shared);
17671    }
17672
17673    /**
17674     * Slice 66 — return the catalog-declared spelling of {@code keyLC}
17675     * from the first contributor in insertion order that publishes the
17676     * key with a non-null spelling. Defensive fallback to {@code keyLC}
17677     * if no contributor exposes the spelling (unreachable in practice
17678     * because contributors are catalogued by construction).
17679     */
17680    private static String firstCatalogSpelling(List<TTable> contributors,
17681                                               String keyLC,
17682                                               NameBindingProvider provider) {
17683        if (contributors != null) {
17684            for (TTable t : contributors) {
17685                List<String> cols = lookupRelationColumnNames(t, provider);
17686                if (cols == null) continue;
17687                for (String c : cols) {
17688                    if (c != null && c.equalsIgnoreCase(keyLC)) {
17689                        return c;
17690                    }
17691                }
17692            }
17693        }
17694        return keyLC;
17695    }
17696
17697    /**
17698     * Slice 66 — diagnostic helper. Joins a list of aliases for the
17699     * NATURAL-required catalog reject message.
17700     */
17701    private static String formatAliasList(List<String> aliases) {
17702        if (aliases == null || aliases.isEmpty()) return "<none>";
17703        StringBuilder sb = new StringBuilder();
17704        for (int i = 0; i < aliases.size(); i++) {
17705            if (i > 0) sb.append(", ");
17706            sb.append("'").append(aliases.get(i)).append("'");
17707        }
17708        return sb.toString();
17709    }
17710
17711    /**
17712     * Slice 66 — turn a {@link NaturalKeyResult} failure into a
17713     * structured diagnostic for the gated reject inside
17714     * {@link #buildRelations}.
17715     */
17716    private static String formatNaturalCatalogReject(NaturalKeyResult r) {
17717        switch (r.kind) {
17718            case INCOMPLETE_LEFT:
17719                return "NATURAL JOIN requires catalog metadata for both sides; "
17720                        + "left-side row type is incomplete due to uncatalogued "
17721                        + "relation(s) " + formatAliasList(r.leftMissingAliases)
17722                        + "; supply a TSQLEnv (or in-scope CTE / FROM-subquery "
17723                        + "body) for the missing relation(s), or rewrite as "
17724                        + "JOIN ... ON";
17725            case MISSING_RIGHT:
17726                return "NATURAL JOIN requires catalog metadata for both sides; "
17727                        + "right-side relation '" + r.rightAlias
17728                        + "' has no resolvable column list; supply a TSQLEnv "
17729                        + "(or in-scope CTE / FROM-subquery body) for this "
17730                        + "relation, or rewrite as JOIN ... ON";
17731            case BOTH_MISSING:
17732                return "NATURAL JOIN requires catalog metadata for both sides; "
17733                        + "left-side row type is incomplete due to uncatalogued "
17734                        + "relation(s) " + formatAliasList(r.leftMissingAliases)
17735                        + " and right-side relation '" + r.rightAlias
17736                        + "' also has no resolvable column list; supply a "
17737                        + "TSQLEnv for the missing relation(s), or rewrite "
17738                        + "as JOIN ... ON";
17739            default:
17740                return "NATURAL JOIN: unexpected result kind " + r.kind;
17741        }
17742    }
17743
17744    /**
17745     * Slice 65 — fail fast when a JOIN ON clause references a USING
17746     * merged key by its bare (unqualified) name. JOIN ON requires
17747     * per-position scope (only relations BEFORE that JoinItem are
17748     * visible), which slice 65 does not yet model; the merged-key
17749     * collector applied by other clauses would over-include later
17750     * relations. Reject the shape so the slice-66+ slice can lift
17751     * with proper per-position scope.
17752     *
17753     * <p>This is the narrowed replacement for slice-64's
17754     * {@code rejectUnqualifiedUsingKeyReferences}, which scanned the
17755     * entire SELECT body. Slice 65 admits unqualified USING-key refs
17756     * in every other clause via the merged-key collector.
17757     *
17758     * <p>Qualified references (e.g. {@code a.k}, {@code b.k}) and
17759     * column references whose names don't match a USING key are
17760     * unaffected.
17761     */
17762    private static void rejectUnqualifiedMergedKeyInJoinOn(TSelectSqlStatement select,
17763                                                           NameBindingProvider provider) {
17764        if (select.joins == null) return;
17765        // Walk each TOP-LEVEL TJoin independently — each comma-FROM
17766        // group has its own scope for JOIN ON purposes (codex slice-65
17767        // diff-review round-4 P2 #1). Within one TJoin, walk JoinItems
17768        // in FROM order and track which merged keys (USING-declared OR
17769        // NATURAL-inferred) have been established. An ON clause is only
17770        // checked against keys that are ALREADY merged at that position;
17771        // a bare `k` in an ON before any USING(k) / NATURAL is just
17772        // resolver2's unqualified-binding case.
17773        //
17774        // Slice 66: NATURAL JoinItems contribute their catalog-inferred
17775        // key list to declaredKeysSoFar. When NATURAL would fail the
17776        // catalog requirement (INCOMPLETE_LEFT / MISSING_RIGHT /
17777        // BOTH_MISSING), the preflight silently skips recording this
17778        // JoinItem's keys — the gated reject in buildRelations will
17779        // fire with a catalog-required diagnostic and the user sees
17780        // that error first.
17781        //
17782        // Identity skip set: USING-clause own TObjectNames are
17783        // declarations not references; never matched against the
17784        // declaredKeysSoFar set since the preflight only walks ON
17785        // conditions. Kept as a defensive no-op.
17786        final java.util.Set<TObjectName> skip =
17787                java.util.Collections.newSetFromMap(
17788                        new java.util.IdentityHashMap<TObjectName, Boolean>());
17789        for (int j = 0; j < select.joins.size(); j++) {
17790            TJoin top = select.joins.getJoin(j);
17791            if (top == null) continue;
17792            TJoinItemList items = top.getJoinItems();
17793            if (items == null) continue;
17794            // Reset per top-level TJoin so independent comma-FROM
17795            // groups don't poison each other's ON clauses.
17796            final java.util.Set<String> declaredKeysSoFar = new java.util.HashSet<>();
17797            LeftOutputState leftState = new LeftOutputState();
17798            seedLeftOutput(leftState, top.getTable(), provider);
17799            for (int i = 0; i < items.size(); i++) {
17800                TJoinItem item = items.getJoinItem(i);
17801                if (item == null) continue;
17802                // Check ON FIRST (uses scope BEFORE this JoinItem), then
17803                // record this JoinItem's USING/NATURAL declarations so
17804                // future siblings see them.
17805                TExpression onCond = item.getOnCondition();
17806                if (onCond != null && !declaredKeysSoFar.isEmpty()) {
17807                    final java.util.Set<String> alreadyDeclared =
17808                            new java.util.HashSet<>(declaredKeysSoFar);
17809                    onCond.acceptChildren(new TParseTreeVisitor() {
17810                        int nestedSelectDepth = 0;
17811
17812                        @Override
17813                        public void preVisit(TSelectSqlStatement nested) {
17814                            nestedSelectDepth++;
17815                        }
17816
17817                        @Override
17818                        public void postVisit(TSelectSqlStatement nested) {
17819                            nestedSelectDepth--;
17820                        }
17821
17822                        @Override
17823                        public void preVisit(TObjectName node) {
17824                            if (nestedSelectDepth > 0) return;
17825                            if (skip.contains(node)) return;
17826                            if (node.getDbObjectType() != EDbObjectType.column) return;
17827                            String name = node.getColumnNameOnly();
17828                            if (name == null || name.isEmpty() || "*".equals(name)) return;
17829                            if (!alreadyDeclared.contains(name.toLowerCase(Locale.ROOT))) return;
17830                            String qualifier = node.getTableString();
17831                            if (qualifier == null || qualifier.isEmpty()) {
17832                                throw new SemanticIRBuildException(
17833                                        Diagnostic.error(DiagnosticCode.UNQUALIFIED_MERGED_KEY_IN_JOIN_ON,
17834                                        "unqualified reference to merged key '"
17835                                                + name + "' inside a JOIN ON condition "
17836                                                + "is deferred to a future slice "
17837                                                + "(per-position scope semantics needed); "
17838                                                + "qualify with a table alias "
17839                                                + "(e.g. a." + name + ") to disambiguate", null));
17840                            }
17841                        }
17842                    });
17843                }
17844                // Record this JoinItem's contribution to declaredKeysSoFar
17845                // and update leftState for NATURAL's accumulated-left
17846                // semantics.
17847                TTable rightTable = item.getTable();
17848                TObjectNameList usingCols = item.getUsingColumns();
17849                if (usingCols != null && usingCols.size() > 0) {
17850                    List<String> usingKeyNames = new ArrayList<>(usingCols.size());
17851                    for (int k = 0; k < usingCols.size(); k++) {
17852                        TObjectName n = usingCols.getObjectName(k);
17853                        if (n == null) continue;
17854                        skip.add(n);
17855                        String name = n.getColumnNameOnly();
17856                        if (name != null && !name.isEmpty()) {
17857                            declaredKeysSoFar.add(name.toLowerCase(Locale.ROOT));
17858                            usingKeyNames.add(name);
17859                        }
17860                    }
17861                    if (rightTable != null) {
17862                        mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames);
17863                    }
17864                } else if (isNaturalJoinType(item.getJoinType()) && rightTable != null) {
17865                    NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider);
17866                    if (r.kind == NaturalKeyResult.Kind.SUCCESS) {
17867                        for (String s : r.keys) {
17868                            if (s != null && !s.isEmpty()) {
17869                                declaredKeysSoFar.add(s.toLowerCase(Locale.ROOT));
17870                            }
17871                        }
17872                        mergeRightIntoLeftOutput(leftState, rightTable, provider, r.keys);
17873                    } else {
17874                        // Catalog-required reject fires upstream in
17875                        // buildRelations. Defensively append for state
17876                        // consistency.
17877                        appendRightToLeftOutput(leftState, rightTable, provider);
17878                    }
17879                } else if (rightTable != null) {
17880                    appendRightToLeftOutput(leftState, rightTable, provider);
17881                }
17882            }
17883        }
17884    }
17885
17886    /**
17887     * Slice 65 — compute the {@link UsingScope} for the current SELECT
17888     * body from its FROM-clause USING joins. Walks every {@link TJoin}
17889     * in {@code select.joins} and for each USING(k) JoinItem, builds
17890     * the per-key equivalence class via DSU-like union over prior
17891     * relations + the right-side relation. Then materializes each
17892     * class by a separate FROM-order pass with identity dedup so
17893     * chained USING joins (`a JOIN b USING(k) JOIN c USING(k)`)
17894     * produce {@code [a, b, c]}, never duplicates.
17895     *
17896     * <p>For each class, builds a {@link UsingScope.MergedKeyEntry}
17897     * with FROM-ordered merged source refs (one per relation that
17898     * publishes the key per catalog / in-scope map; unknown-metadata
17899     * priors emit refs unconditionally, matching slice-64's over-
17900     * approximation policy in {@link #populateUsingJoinRefs}).
17901     *
17902     * <p>Ambiguity is precomputed:
17903     * <ul>
17904     *   <li>{@code entries.size() > 1}: two disconnected USING classes
17905     *       share the same key name.</li>
17906     *   <li>{@code entries.size() == 1} AND a FROM relation outside
17907     *       the class has catalog metadata that declares the key:
17908     *       out-of-class same-named column.</li>
17909     * </ul>
17910     *
17911     * <p>Returns {@link UsingScope#EMPTY} when no USING clauses are
17912     * present in {@code select.joins}.
17913     */
17914    private static UsingScope buildUsingScope(TSelectSqlStatement select,
17915                                              NameBindingProvider provider) {
17916        if (select.joins == null) return UsingScope.EMPTY;
17917        // Slice 86 — delegate to the shared TJoinList-taking helper so
17918        // joined UPDATE (slice 86 buildUpdateUsingScope) can reuse the
17919        // identical scope-build pipeline.
17920        return buildUsingScopeFromJoinList(select.joins, provider);
17921    }
17922
17923    /**
17924     * Slice 86 — compute the {@link UsingScope} for a joined UPDATE's
17925     * FROM clause via {@code update.getJoins()}. Mirrors slice-65
17926     * {@link #buildUsingScope}: USING / NATURAL JoinItems contribute
17927     * merged-key equivalence classes; unqualified merged-key references
17928     * in SET RHS / WHERE / RETURNING resolve to the merged source list.
17929     *
17930     * <p>Returns {@link UsingScope#EMPTY} when no USING/NATURAL JoinItems
17931     * appear in the FROM clause.
17932     */
17933    private static UsingScope buildUpdateUsingScope(TUpdateSqlStatement update,
17934                                                    NameBindingProvider provider) {
17935        if (update == null) return UsingScope.EMPTY;
17936        return buildUsingScopeFromJoinList(update.getJoins(), provider);
17937    }
17938
17939    /**
17940     * Slice 86 — shared {@link UsingScope} computation extracted from
17941     * slice-65 {@link #buildUsingScope}. Takes the {@link TJoinList}
17942     * directly so it can be invoked from both SELECT
17943     * ({@link #buildUsingScope}) and joined UPDATE
17944     * ({@link #buildUpdateUsingScope}).
17945     *
17946     * <p>Behavior identical to slice 65/66: per-key DSU union over prior
17947     * relations + right-side relation per top-level {@link TJoin};
17948     * disconnected comma-FROM groups keep their own per-key components;
17949     * NATURAL JoinItems infer shared keys against accumulated left row
17950     * type via {@link LeftOutputState}; ambiguity detection walks all
17951     * FROM relations for out-of-class same-named columns.
17952     */
17953    private static UsingScope buildUsingScopeFromJoinList(TJoinList joins,
17954                                                          NameBindingProvider provider) {
17955        if (joins == null) return UsingScope.EMPTY;
17956        // Pass 1: per-key DSU. For each USING(k) or NATURAL JoinItem,
17957        // union the prior relations PUBLISHING the key (catalog-narrowed
17958        // per codex slice-66 round-1 P1 #1) with the right-side relation,
17959        // scoped to the enclosing top-level TJoin (chained merges within
17960        // one TJoin transitively connect through DSU). Disconnected
17961        // top-level TJoins (comma-FROM) keep their own per-key components.
17962        // Slice 66 maintains a LeftOutputState alongside the loop so
17963        // NATURAL JoinItems can infer their shared-key list against the
17964        // accumulated left row type.
17965        java.util.Map<String, java.util.List<java.util.List<TTable>>> perKeyComponents =
17966                new java.util.LinkedHashMap<>();
17967        // Track the SQL-written spelling of each merged key (the first
17968        // occurrence in FROM order). For USING keys this is the
17969        // SQL-written USING-clause case (slice-64 contract); for
17970        // NATURAL keys this is the catalog-declared spelling from the
17971        // first contributor.
17972        java.util.Map<String, String> originalSpellingByKey = new java.util.HashMap<>();
17973        // Catalog-less NATURAL JOIN degrade: ordered (FROM-order) list of
17974        // left-side relation aliases for NATURAL joins whose shared-key set
17975        // could not be computed. Lets unqualified references to a merged
17976        // NATURAL key resolve deterministically to the left side instead of
17977        // failing as COLUMN_BINDING_NON_EXACT (GSP R6 degrade-not-fail).
17978        java.util.List<String> naturalDegradeAliases = new java.util.ArrayList<>();
17979        // The degrade only applies when the FROM is PURELY NATURAL-connected —
17980        // a single top-level TJoin whose every join item is NATURAL. A mixed
17981        // FROM (plain ON/CROSS/USING item, or a comma-separated second TJoin)
17982        // introduces an independent relation, so an unqualified reference there
17983        // is genuinely ambiguous and MUST still reject (doc §5.3: "do not apply
17984        // this special-case rule to non-NATURAL joins"). Cleared below if any
17985        // non-NATURAL linkage is seen.
17986        boolean pureNatural = (joins.size() == 1);
17987        for (int jx = 0; jx < joins.size(); jx++) {
17988            TJoin top = joins.getJoin(jx);
17989            if (top == null) continue;
17990            TJoinItemList items = top.getJoinItems();
17991            if (items == null) continue;
17992            TTable topTable = top.getTable();
17993            // Slice 66: per-TJoin LeftOutputState for NATURAL inference.
17994            LeftOutputState leftState = new LeftOutputState();
17995            seedLeftOutput(leftState, topTable, provider);
17996            // Per-key in-progress chain for THIS top-level TJoin.
17997            java.util.Map<String, java.util.List<TTable>> inProgressByKey =
17998                    new java.util.HashMap<>();
17999            for (int i = 0; i < items.size(); i++) {
18000                TJoinItem item = items.getJoinItem(i);
18001                if (item == null) continue;
18002                TTable rightTable = item.getTable();
18003                if (rightTable == null) continue;
18004
18005                // Determine merged keys for this JoinItem and the
18006                // emitted spelling per key. Three cases:
18007                //   USING:   keys = syntactic usingCols; spelling = USING-clause text.
18008                //   NATURAL: keys = catalog intersection (when SUCCESS);
18009                //            spelling = catalog spelling.
18010                //   ON/CROSS/other: skip — append to leftState only.
18011                List<String> keyNames;
18012                java.util.Map<String, String> spellingByKeyLC = new java.util.HashMap<>();
18013                TObjectNameList usingCols = item.getUsingColumns();
18014                if (usingCols != null && usingCols.size() > 0) {
18015                    // USING introduces an explicit (non-NATURAL) merge — the
18016                    // FROM is no longer purely NATURAL, so disable the degrade.
18017                    pureNatural = false;
18018                    keyNames = new java.util.ArrayList<>(usingCols.size());
18019                    for (int k = 0; k < usingCols.size(); k++) {
18020                        TObjectName keyNode = usingCols.getObjectName(k);
18021                        if (keyNode == null) continue;
18022                        String keyName = keyNode.getColumnNameOnly();
18023                        if (keyName == null || keyName.isEmpty()) continue;
18024                        keyNames.add(keyName);
18025                        spellingByKeyLC.put(keyName.toLowerCase(Locale.ROOT), keyName);
18026                    }
18027                } else if (isNaturalJoinType(item.getJoinType())) {
18028                    NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider);
18029                    if (r.kind != NaturalKeyResult.Kind.SUCCESS) {
18030                        // Catalog-required reject already fired (or will
18031                        // fire) inside buildRelations. Defensively skip
18032                        // this JoinItem in the scope build; it does NOT
18033                        // contribute to the merged-key scope. Record the
18034                        // left-side relation so unqualified references to a
18035                        // merged NATURAL key degrade rather than reject.
18036                        String leftAlias = effectiveAliasOf(topTable);
18037                        if (leftAlias != null && !leftAlias.isEmpty()
18038                                && !naturalDegradeAliases.contains(leftAlias)) {
18039                            naturalDegradeAliases.add(leftAlias);
18040                        }
18041                        appendRightToLeftOutput(leftState, rightTable, provider);
18042                        continue;
18043                    }
18044                    keyNames = r.keys;
18045                    for (String s : keyNames) {
18046                        if (s != null && !s.isEmpty()) {
18047                            spellingByKeyLC.put(s.toLowerCase(Locale.ROOT), s);
18048                        }
18049                    }
18050                } else {
18051                    // ON / CROSS / other — no merged-key contribution. This
18052                    // join item introduces an independent relation, so an
18053                    // unqualified reference is genuinely ambiguous: disable the
18054                    // NATURAL degrade for the whole FROM.
18055                    pureNatural = false;
18056                    appendRightToLeftOutput(leftState, rightTable, provider);
18057                    continue;
18058                }
18059
18060                // Prior relations for this JoinItem in FROM order:
18061                // topTable + items[0..i-1].getTable().
18062                java.util.List<TTable> priorRelations = new java.util.ArrayList<>();
18063                if (topTable != null) priorRelations.add(topTable);
18064                for (int p = 0; p < i; p++) {
18065                    TJoinItem prev = items.getJoinItem(p);
18066                    if (prev != null && prev.getTable() != null) {
18067                        priorRelations.add(prev.getTable());
18068                    }
18069                }
18070                for (String keyName : keyNames) {
18071                    if (keyName == null || keyName.isEmpty()) continue;
18072                    String keyLC = keyName.toLowerCase(Locale.ROOT);
18073                    // Record the first emitted spelling we see for this
18074                    // key. USING uses SQL-written spelling; NATURAL uses
18075                    // catalog-declared spelling.
18076                    if (!originalSpellingByKey.containsKey(keyLC)) {
18077                        originalSpellingByKey.put(keyLC, spellingByKeyLC.get(keyLC));
18078                    }
18079                    java.util.List<TTable> chain = inProgressByKey.get(keyLC);
18080                    if (chain == null) {
18081                        chain = new java.util.ArrayList<>();
18082                        inProgressByKey.put(keyLC, chain);
18083                    }
18084                    // Slice 66 catalog-narrowed union (codex round-1 P1 #1):
18085                    // for each prior relation, include in this key's
18086                    // equivalence class only if (a) catalog is unknown
18087                    // (over-approximate; slice-64 fallback), or (b)
18088                    // catalog declares the key. Skip if catalog is
18089                    // known and the key is proven absent.
18090                    for (TTable prior : priorRelations) {
18091                        if (containsByIdentity(chain, prior)) continue;
18092                        List<String> priorCols = lookupRelationColumnNames(prior, provider);
18093                        if (priorCols == null) {
18094                            chain.add(prior);
18095                            continue;
18096                        }
18097                        boolean priorPublishes = false;
18098                        for (String pc : priorCols) {
18099                            if (pc != null && pc.equalsIgnoreCase(keyLC)) {
18100                                priorPublishes = true;
18101                                break;
18102                            }
18103                        }
18104                        if (priorPublishes) {
18105                            chain.add(prior);
18106                        }
18107                    }
18108                    if (!containsByIdentity(chain, rightTable)) {
18109                        chain.add(rightTable);
18110                    }
18111                }
18112                // After the merged-key bookkeeping, merge right into
18113                // the leftState so subsequent NATURAL JoinItems see
18114                // the accumulated row type.
18115                mergeRightIntoLeftOutput(leftState, rightTable, provider, keyNames);
18116            }
18117            // Flush this TJoin's in-progress chains as one component
18118            // per key.
18119            for (java.util.Map.Entry<String, java.util.List<TTable>> e :
18120                    inProgressByKey.entrySet()) {
18121                java.util.List<java.util.List<TTable>> bucket = perKeyComponents.get(e.getKey());
18122                if (bucket == null) {
18123                    bucket = new java.util.ArrayList<>();
18124                    perKeyComponents.put(e.getKey(), bucket);
18125                }
18126                bucket.add(e.getValue());
18127            }
18128        }
18129        // Mixed FROM (any non-NATURAL linkage seen): drop the degrade fallback
18130        // so genuinely-ambiguous unqualified references still reject.
18131        if (!pureNatural) {
18132            naturalDegradeAliases.clear();
18133        }
18134        if (perKeyComponents.isEmpty()) {
18135            // No merged-key scope, but a catalog-less NATURAL JOIN may still
18136            // need the degrade fallback for unqualified merged-key refs.
18137            return naturalDegradeAliases.isEmpty()
18138                    ? UsingScope.EMPTY
18139                    : new UsingScope(
18140                            java.util.Collections.<String, java.util.List<UsingScope.MergedKeyEntry>>emptyMap(),
18141                            java.util.Collections.<String, String>emptyMap(),
18142                            naturalDegradeAliases);
18143        }
18144        // Pass 2: materialize EquivalenceClass + MergedKeyEntry per
18145        // component. FROM-order is already preserved by Pass 1's
18146        // accumulation order (priorRelations + rightTable).
18147        java.util.Map<String, java.util.List<UsingScope.MergedKeyEntry>> entriesByName =
18148                new java.util.LinkedHashMap<>();
18149        for (java.util.Map.Entry<String, java.util.List<java.util.List<TTable>>> e :
18150                perKeyComponents.entrySet()) {
18151            String keyLC = e.getKey();
18152            // ColumnRef-emit spelling: SQL-written USING-clause spelling
18153            // (matches slice-64 populateUsingJoinRefs). Falls back to
18154            // keyLC if no spelling was recorded (defensive).
18155            String emitKeyName = originalSpellingByKey.containsKey(keyLC)
18156                    ? originalSpellingByKey.get(keyLC)
18157                    : keyLC;
18158            java.util.List<UsingScope.MergedKeyEntry> entries = new java.util.ArrayList<>();
18159            for (java.util.List<TTable> componentMembers : e.getValue()) {
18160                if (componentMembers.isEmpty()) continue;
18161                UsingScope.EquivalenceClass cls = new UsingScope.EquivalenceClass(
18162                        keyLC, componentMembers);
18163                java.util.List<ColumnRef> sources = new java.util.ArrayList<>();
18164                java.util.Set<String> seenAliases = new java.util.HashSet<>();
18165                for (TTable t : componentMembers) {
18166                    String effAlias = effectiveAliasOf(t);
18167                    if (effAlias == null || effAlias.isEmpty()) continue;
18168                    String aliasKey = effAlias.toLowerCase(Locale.ROOT);
18169                    if (seenAliases.contains(aliasKey)) continue;
18170                    java.util.List<String> cols = lookupRelationColumnNames(t, provider);
18171                    if (cols == null) {
18172                        // Metadata-unknown: emit ref (over-approximate).
18173                        sources.add(new ColumnRef(effAlias, emitKeyName));
18174                        seenAliases.add(aliasKey);
18175                        continue;
18176                    }
18177                    for (String c : cols) {
18178                        if (c != null && c.equalsIgnoreCase(keyLC)) {
18179                            sources.add(new ColumnRef(effAlias, emitKeyName));
18180                            seenAliases.add(aliasKey);
18181                            break;
18182                        }
18183                    }
18184                }
18185                if (!sources.isEmpty()) {
18186                    entries.add(new UsingScope.MergedKeyEntry(cls, sources));
18187                }
18188            }
18189            if (!entries.isEmpty()) {
18190                entriesByName.put(keyLC, entries);
18191            }
18192        }
18193        if (entriesByName.isEmpty()) {
18194            return naturalDegradeAliases.isEmpty()
18195                    ? UsingScope.EMPTY
18196                    : new UsingScope(
18197                            java.util.Collections.<String, java.util.List<UsingScope.MergedKeyEntry>>emptyMap(),
18198                            java.util.Collections.<String, String>emptyMap(),
18199                            naturalDegradeAliases);
18200        }
18201        // Pass 3: precompute ambiguity per key.
18202        java.util.Map<String, String> ambiguityByName = new java.util.HashMap<>();
18203        java.util.List<TTable> allFromRelations = walkAllFromRelationsFromJoinList(joins);
18204        for (java.util.Map.Entry<String, java.util.List<UsingScope.MergedKeyEntry>> e :
18205                entriesByName.entrySet()) {
18206            String keyLC = e.getKey();
18207            java.util.List<UsingScope.MergedKeyEntry> entries = e.getValue();
18208            if (entries.size() > 1) {
18209                ambiguityByName.put(keyLC,
18210                        "multiple disconnected USING(" + keyLC + ") equivalence "
18211                                + "classes appear in this FROM (their merged "
18212                                + "columns share the same key name)");
18213                continue;
18214            }
18215            // Single class. Walk all FROM relations; if any out-of-class
18216            // relation is catalog-known to publish the key, mark ambiguous.
18217            UsingScope.EquivalenceClass cls = entries.get(0).getEquivClass();
18218            java.util.IdentityHashMap<TTable, Boolean> inClass = new java.util.IdentityHashMap<>();
18219            for (TTable m : cls.getMembers()) inClass.put(m, Boolean.TRUE);
18220            for (TTable r : allFromRelations) {
18221                if (inClass.containsKey(r)) continue;
18222                // Slice 65 diff-review round-3 P2 #2 (slice 103 update):
18223                // post-slice-103 both branches return the same data —
18224                // `lookupRelationColumnNames` consults the in-scope map
18225                // populated from `ctePublishedColumns`, which now holds
18226                // the renamed names. The discriminator is retained as a
18227                // defense-in-depth path for any call site that bypasses
18228                // the SELECT-side CTE walker but still wants to detect
18229                // renamed-key collisions; the slice-103 path falls into
18230                // the `lookupRelationColumnNames` branch and gets the
18231                // same renamed list.
18232                java.util.List<String> cols;
18233                if (hasExplicitCteColumnList(r)) {
18234                    cols = explicitCteColumnNames(r);
18235                } else {
18236                    cols = lookupRelationColumnNames(r, provider);
18237                }
18238                if (cols == null) continue;          // unknown → trust writer
18239                for (String c : cols) {
18240                    if (c != null && c.equalsIgnoreCase(keyLC)) {
18241                        String outAlias = effectiveAliasOf(r);
18242                        ambiguityByName.put(keyLC,
18243                                "the USING(" + keyLC + ") merged column collides "
18244                                        + "with column '" + keyLC + "' on relation '"
18245                                        + (outAlias != null ? outAlias : "<unnamed>")
18246                                        + "' which is not part of the USING equivalence class");
18247                        break;
18248                    }
18249                }
18250                if (ambiguityByName.containsKey(keyLC)) break;
18251            }
18252        }
18253        return new UsingScope(entriesByName, ambiguityByName, naturalDegradeAliases);
18254    }
18255
18256    private static boolean containsByIdentity(java.util.List<TTable> list, TTable t) {
18257        for (TTable x : list) {
18258            if (x == t) return true;
18259        }
18260        return false;
18261    }
18262
18263    /**
18264     * Slice 65 — every FROM-clause relation reachable directly from
18265     * {@code select.joins} (every {@code top.getTable()} + every
18266     * {@code joinItem.getTable()}). Used by {@link #buildUsingScope}
18267     * to detect out-of-equivalence-class same-named columns.
18268     */
18269    private static java.util.List<TTable> walkAllFromRelations(TSelectSqlStatement select) {
18270        if (select == null) return new java.util.ArrayList<>();
18271        return walkAllFromRelationsFromJoinList(select.joins);
18272    }
18273
18274    /**
18275     * Slice 86 — shared {@link TJoinList}-taking walker for ambiguity
18276     * detection inside {@link #buildUsingScopeFromJoinList}. Used by both
18277     * SELECT ({@link #walkAllFromRelations}) and joined UPDATE
18278     * ({@link #buildUpdateUsingScope}).
18279     */
18280    private static java.util.List<TTable> walkAllFromRelationsFromJoinList(TJoinList joins) {
18281        java.util.List<TTable> out = new java.util.ArrayList<>();
18282        if (joins == null) return out;
18283        for (int j = 0; j < joins.size(); j++) {
18284            TJoin top = joins.getJoin(j);
18285            if (top == null) continue;
18286            if (top.getTable() != null) out.add(top.getTable());
18287            TJoinItemList items = top.getJoinItems();
18288            if (items == null) continue;
18289            for (int i = 0; i < items.size(); i++) {
18290                TJoinItem item = items.getJoinItem(i);
18291                if (item != null && item.getTable() != null) {
18292                    out.add(item.getTable());
18293                }
18294            }
18295        }
18296        return out;
18297    }
18298
18299    /**
18300     * Slice 64 — true iff any TJoinItem in {@code select.joins}
18301     * carries a non-empty USING list. Used by {@link #tryExpandStar}
18302     * to defer bare {@code *} over USING JOIN to S65 (merged-key
18303     * output naming).
18304     */
18305    private static boolean hasUsingInFromClause(TSelectSqlStatement select) {
18306        if (select.joins == null) return false;
18307        for (int j = 0; j < select.joins.size(); j++) {
18308            TJoin top = select.joins.getJoin(j);
18309            if (top == null) continue;
18310            TJoinItemList items = top.getJoinItems();
18311            if (items == null) continue;
18312            for (int i = 0; i < items.size(); i++) {
18313                TJoinItem item = items.getJoinItem(i);
18314                if (item != null
18315                        && item.getUsingColumns() != null
18316                        && item.getUsingColumns().size() > 0) {
18317                    return true;
18318                }
18319            }
18320        }
18321        return false;
18322    }
18323
18324    /**
18325     * Slice 66 — true iff any JoinItem is NATURAL AND its inferred
18326     * shared-column list is non-empty (catalog-resolved on both sides
18327     * and the intersection contains at least one column).
18328     *
18329     * <p>Routes bare {@code *} expansion through
18330     * {@link #expandBareStarOverUsing} when NATURAL contributes merged
18331     * keys. NATURAL with empty intersection or with INCOMPLETE_LEFT /
18332     * MISSING_RIGHT / BOTH_MISSING returns false for THIS JoinItem
18333     * (codex slice-66 round-4 P2 #3) — the bare-* path then falls
18334     * through to per-relation expansion, which is correct for an
18335     * empty-intersection NATURAL (Cartesian, no dedup needed). The
18336     * catalog-required reject for NATURAL fires upstream inside
18337     * {@link #buildRelations} before bare-* runs.
18338     *
18339     * <p>The walk maintains its own per-top-level-TJoin
18340     * {@link LeftOutputState} (so NATURAL inference against accumulated
18341     * left works the same way as in {@link #buildUsingScope} and
18342     * {@link #buildRelations}).
18343     */
18344    private static boolean hasNaturalJoinMergedKeysInFromClause(
18345            TSelectSqlStatement select, NameBindingProvider provider) {
18346        if (select.joins == null) return false;
18347        for (int j = 0; j < select.joins.size(); j++) {
18348            TJoin top = select.joins.getJoin(j);
18349            if (top == null) continue;
18350            TJoinItemList items = top.getJoinItems();
18351            if (items == null) continue;
18352            LeftOutputState leftState = new LeftOutputState();
18353            seedLeftOutput(leftState, top.getTable(), provider);
18354            for (int i = 0; i < items.size(); i++) {
18355                TJoinItem item = items.getJoinItem(i);
18356                if (item == null) continue;
18357                TTable rightTable = item.getTable();
18358                if (rightTable == null) continue;
18359                if (isNaturalJoinType(item.getJoinType())) {
18360                    NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider);
18361                    if (r.kind == NaturalKeyResult.Kind.SUCCESS
18362                            && r.keys != null && !r.keys.isEmpty()) {
18363                        return true;
18364                    }
18365                    appendRightToLeftOutput(leftState, rightTable, provider);
18366                    continue;
18367                }
18368                TObjectNameList usingCols = item.getUsingColumns();
18369                if (usingCols != null && usingCols.size() > 0) {
18370                    List<String> usingKeyNames = new ArrayList<>(usingCols.size());
18371                    for (int k = 0; k < usingCols.size(); k++) {
18372                        TObjectName usingKey = usingCols.getObjectName(k);
18373                        if (usingKey == null) continue;
18374                        String keyName = usingKey.getColumnNameOnly();
18375                        if (keyName != null && !keyName.isEmpty()) {
18376                            usingKeyNames.add(keyName);
18377                        }
18378                    }
18379                    mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames);
18380                } else {
18381                    appendRightToLeftOutput(leftState, rightTable, provider);
18382                }
18383            }
18384        }
18385        return false;
18386    }
18387
18388    /**
18389     * Two relations sharing the same effective alias would make
18390     * {@link ColumnRef#getRelationAlias()} ambiguous in the IR. Resolver2
18391     * may already flag column references in this case, but the IR-level
18392     * invariant still needs to hold.
18393     */
18394    private static void rejectDuplicateAliases(List<RelationSource> relations) {
18395        Set<String> seen = new HashSet<>();
18396        for (RelationSource r : relations) {
18397            if (!seen.add(r.getAlias())) {
18398                throw new SemanticIRBuildException(
18399                        Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS,
18400                        "duplicate relation alias '" + r.getAlias()
18401                                + "' is not supported (would make ColumnRef ambiguous)", null));
18402            }
18403        }
18404    }
18405
18406    private static RelationSource buildRelation(TTable table, NameBindingProvider provider,
18407                                                boolean allowFromSubqueries) {
18408        return buildRelation(table, provider, allowFromSubqueries,
18409                RelationSource.NO_INSTANCE_ID);
18410    }
18411
18412    private static RelationSource buildRelation(TTable table, NameBindingProvider provider,
18413                                                boolean allowFromSubqueries, int instanceId) {
18414        // Reject FROM-subqueries when the caller did not extract them as
18415        // separate statements. After slice 18 the still-uncovered scopes
18416        // are scalar bodies (slice-11 boundary), set-op branches (slice-16
18417        // boundary), and set-op CTE bodies (build()'s set-op CTE dispatch
18418        // passes allowFromSubqueries=false to each branch).
18419        if (table.getTableType() == gudusoft.gsqlparser.ETableSource.subquery
18420                && !allowFromSubqueries) {
18421            // Slice 74: use effectiveAliasOf so anonymous subqueries
18422            // surface their synth name in the diagnostic instead of the
18423            // empty-string the prior `getAliasName() == null` ternary
18424            // produced.
18425            String bodyAlias = effectiveAliasOf(table);
18426            throw new SemanticIRBuildException(
18427                    Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_BODY_CONTEXT_NOT_SUPPORTED,
18428                    "FROM-clause subquery '" + (bodyAlias == null || bodyAlias.isEmpty() ? "<anonymous>" : bodyAlias)
18429                            + "' inside a scalar body, set-op branch, or set-op CTE body is not supported yet", table));
18430        }
18431        RelationBinding binding = provider.bindRelation(table);
18432        if (binding == null) {
18433            throw new SemanticIRBuildException(
18434                    Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED,
18435                    "could not bind table " + safeName(table) + " (only base tables and in-scope CTEs are supported)", table));
18436        }
18437        // Effective alias: prefer the SQL-written alias, then the slice-74
18438        // synthetic alias for anonymous FROM-subqueries, then the table
18439        // name (mirrors effectiveAliasOf so RelationSource.alias and
18440        // ColumnRef.relationAlias stay aligned).
18441        String alias = effectiveAliasOf(table);
18442        // Slice 164 (S3): attach the FROM-clause table-reference span
18443        // (includes alias, e.g. "t1 a"). Null-safe via SourceSpan.of.
18444        // Slice 179 (R4): carry the stable FROM-order instanceId.
18445        return new RelationSource(alias, binding, SourceSpan.of(table), instanceId);
18446    }
18447
18448    private static String safeName(TTable t) {
18449        try {
18450            return t.getName();
18451        } catch (RuntimeException e) {
18452            return "<unnamed>";
18453        }
18454    }
18455
18456    // -----------------------------------------------------------------
18457    // Slice 167 (join-analysis S6) — ordered, chain-correct structured
18458    // JoinGraph (GAP 1). Walks the same TJoinList the flat join refs come
18459    // from, but stops flattening: each TJoinItem becomes a JoinEntity, and
18460    // comma-separated top-level TJoins become IMPLICIT_CROSS entities.
18461    //
18462    // Left-deep chaining: the right endpoint is always the newly added
18463    // RELATION; the left endpoint is the first RELATION for join order 0,
18464    // else the JOIN_RESULT accumulated by the prior join. No predicate
18465    // decomposition here (that is slice 168). The flat joinColumnRefs are
18466    // untouched.
18467    // -----------------------------------------------------------------
18468    /**
18469     * R8 — append predicate-derived semi-join entities to a FROM-clause
18470     * {@link JoinGraph}. Each {@link SemiJoinFact} becomes a
18471     * {@link SemanticJoinType#SEMI} / {@link SemanticJoinType#ANTI_SEMI}
18472     * {@link JoinEntity} whose left endpoint is the outer relation owning
18473     * the correlated outer column (resolved from {@code relations} by
18474     * alias; falls back to the first FROM relation) and whose right
18475     * endpoint is the lifted subquery's {@link JoinEndpointKind#SUBQUERY}
18476     * block. Orders continue after the FROM joins. Returns the input graph
18477     * unchanged when there are no facts.
18478     */
18479    private static JoinGraph appendSemiJoins(JoinGraph base,
18480                                             List<SemiJoinFact> semiFacts,
18481                                             List<RelationSource> relations) {
18482        if (semiFacts == null || semiFacts.isEmpty()) {
18483            return base;
18484        }
18485        List<JoinEntity> joins = new ArrayList<>(base.getJoins());
18486        int order = joins.size();
18487        for (SemiJoinFact f : semiFacts) {
18488            JoinEndpoint left = outerEndpointFor(f.outerAliases, relations, joins);
18489            if (left == null) {
18490                // No FROM relation to anchor the outer side — skip rather
18491                // than fabricate an endpoint (honest omission).
18492                continue;
18493            }
18494            JoinEndpoint right = JoinEndpoint.subquery(f.innerStatementIndex, f.innerLabel);
18495            joins.add(new JoinEntity(f.polarity, left, right, order++,
18496                    JoinSourceSyntax.SEMI, /*natural=*/ false,
18497                    /*usingColumns=*/ null, f.conditions, f.span,
18498                    f.conditionText, /*lateral=*/ false));
18499        }
18500        return joins.isEmpty() ? JoinGraph.EMPTY : new JoinGraph(joins);
18501    }
18502
18503    /**
18504     * R8 — left endpoint for a semi-join. When the correlation references a
18505     * single outer relation, that relation is the endpoint. When it
18506     * references several (or none can be matched but FROM joins exist), the
18507     * endpoint is the accumulated outer rowset (a {@link JoinEndpointKind#JOIN_RESULT}
18508     * over the last FROM join, carrying every FROM alias) — codex R8 [P2]:
18509     * a single-relation endpoint would be wrong when the semi-join's
18510     * conditions span multiple outer relations. Falls back to the first
18511     * FROM relation; returns {@code null} only when there is no FROM
18512     * relation to anchor.
18513     */
18514    private static JoinEndpoint outerEndpointFor(List<String> outerAliases,
18515                                                 List<RelationSource> relations,
18516                                                 List<JoinEntity> priorJoins) {
18517        if (relations == null || relations.isEmpty()) {
18518            return null;
18519        }
18520        // Multiple distinct outer relations → the joined outer rowset.
18521        if (outerAliases != null && countMatchingRelations(outerAliases, relations) > 1
18522                && priorJoins != null && !priorJoins.isEmpty()) {
18523            List<String> allAliases = new ArrayList<>();
18524            for (RelationSource rs : relations) {
18525                if (rs.getAlias() != null && !rs.getAlias().isEmpty()) {
18526                    allAliases.add(rs.getAlias());
18527                }
18528            }
18529            int lastOrder = priorJoins.get(priorJoins.size() - 1).getOrder();
18530            return JoinEndpoint.joinResult(lastOrder, allAliases);
18531        }
18532        // Single outer relation: match it by alias, else the first FROM relation.
18533        RelationSource chosen = null;
18534        if (outerAliases != null) {
18535            for (String a : outerAliases) {
18536                for (RelationSource rs : relations) {
18537                    if (rs.getAlias() != null && rs.getAlias().equalsIgnoreCase(a)) {
18538                        chosen = rs;
18539                        break;
18540                    }
18541                }
18542                if (chosen != null) break;
18543            }
18544        }
18545        if (chosen == null) {
18546            chosen = relations.get(0);
18547        }
18548        String alias = chosen.getAlias();
18549        if (alias == null || alias.isEmpty()) {
18550            return null;
18551        }
18552        String qn = chosen.getBinding() == null
18553                ? null : chosen.getBinding().getQualifiedName();
18554        return JoinEndpoint.relation(alias, qn, chosen.getInstanceId());
18555    }
18556
18557    /** R8 — count how many FROM relations are named by {@code outerAliases}. */
18558    private static int countMatchingRelations(List<String> outerAliases,
18559                                              List<RelationSource> relations) {
18560        int n = 0;
18561        for (RelationSource rs : relations) {
18562            if (rs.getAlias() == null) continue;
18563            for (String a : outerAliases) {
18564                if (rs.getAlias().equalsIgnoreCase(a)) {
18565                    n++;
18566                    break;
18567                }
18568            }
18569        }
18570        return n;
18571    }
18572
18573    private static JoinGraph buildJoinGraph(TSelectSqlStatement select,
18574                                            NameBindingProvider provider) {
18575        if (select == null || select.getJoins() == null || select.getJoins().size() == 0) {
18576            return JoinGraph.EMPTY;
18577        }
18578        List<JoinEntity> entities = new ArrayList<JoinEntity>();
18579        JoinEndpoint currentLeft = null;
18580        List<String> accumulated = new ArrayList<String>();
18581        int order = 0;
18582        boolean firstRelationSeen = false;
18583        // Slice 179 (R4): same FROM-order ordinal as buildRelations (driver
18584        // first, then each join-item right table) so endpoint instanceIds
18585        // align with RelationSource instanceIds by construction.
18586        int relInstanceId = 0;
18587
18588        for (int j = 0; j < select.getJoins().size(); j++) {
18589            TJoin topJoin = select.getJoins().getJoin(j);
18590            if (topJoin == null) continue;
18591            TTable driver = topJoin.getTable();
18592            if (driver != null) {
18593                String dAlias = effectiveAliasOf(driver);
18594                String dQn = relationQnOf(driver);
18595                if (!firstRelationSeen) {
18596                    currentLeft = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId++);
18597                    accumulated.add(currentLeft.getAlias());
18598                    firstRelationSeen = true;
18599                } else {
18600                    // Comma-FROM: implicit cross between the accumulated
18601                    // left result and this new top-level relation. A
18602                    // `FROM m, LATERAL (...)` operand is a lateral cross — mark
18603                    // the entity lateral (and surface LATERAL syntax) so a
18604                    // consumer does not flag it as an accidental cartesian.
18605                    boolean driverLateral = isLateralTable(driver);
18606                    JoinEndpoint right = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId++);
18607                    entities.add(new JoinEntity(SemanticJoinType.IMPLICIT_CROSS,
18608                            currentLeft, right, order,
18609                            driverLateral ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.COMMA,
18610                            false, null, null, SourceSpan.of(driver), null, driverLateral));
18611                    accumulated.add(right.getAlias());
18612                    currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated));
18613                    order++;
18614                }
18615            } else if (topJoin.getJoin() != null) {
18616                // Parenthesized joined-table as the top-level driver operand,
18617                // e.g. `FROM (a JOIN b ON …) JOIN c ON …`. Recurse to emit the
18618                // sub-tree's entities and obtain its result endpoint, then treat
18619                // it as this position's left input (or comma-cross partner).
18620                int[] o = {order};
18621                int[] rid = {relInstanceId};
18622                JoinEndpoint sub = buildJoinGraphForJoin(topJoin.getJoin(), provider, entities, o, rid);
18623                order = o[0];
18624                relInstanceId = rid[0];
18625                if (sub == null) return JoinGraph.EMPTY;
18626                if (!firstRelationSeen) {
18627                    currentLeft = sub;
18628                    accumulated.addAll(aliasesOf(sub));
18629                    firstRelationSeen = true;
18630                } else {
18631                    entities.add(new JoinEntity(SemanticJoinType.IMPLICIT_CROSS,
18632                            currentLeft, sub, order, JoinSourceSyntax.COMMA,
18633                            false, null, null, SourceSpan.of(topJoin), null));
18634                    accumulated.addAll(aliasesOf(sub));
18635                    currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated));
18636                    order++;
18637                }
18638            }
18639            TJoinItemList items = topJoin.getJoinItems();
18640            if (items == null) continue;
18641            for (int i = 0; i < items.size(); i++) {
18642                TJoinItem item = items.getJoinItem(i);
18643                if (item == null) continue;
18644                TTable right = item.getTable();
18645                JoinEndpoint rightEp;
18646                if (right != null) {
18647                    String rAlias = effectiveAliasOf(right);
18648                    String rQn = relationQnOf(right);
18649                    rightEp = JoinEndpoint.relation(safeAlias(rAlias, rQn), rQn, relInstanceId++);
18650                } else if (item.getJoin() != null) {
18651                    // Parenthesized joined-table as the right operand,
18652                    // e.g. `FROM a JOIN (b JOIN c ON …) ON …`. Recurse to emit
18653                    // the sub-tree's inner entities and use its result endpoint
18654                    // as the right side of this outer join.
18655                    int[] o = {order};
18656                    int[] rid = {relInstanceId};
18657                    rightEp = buildJoinGraphForJoin(item.getJoin(), provider, entities, o, rid);
18658                    order = o[0];
18659                    relInstanceId = rid[0];
18660                    if (rightEp == null) return JoinGraph.EMPTY;
18661                } else {
18662                    // Genuinely neither a table nor a nested join — skip safely
18663                    // (no endpoint guesswork).
18664                    continue;
18665                }
18666                if (currentLeft == null) {
18667                    // A join item with no established left input — never guess a
18668                    // left endpoint; surface the graph as empty.
18669                    return JoinGraph.EMPTY;
18670                }
18671                SemanticJoinType jt = mapSemanticJoinType(item.getJoinType());
18672                boolean natural = isNaturalJoinType(item.getJoinType());
18673                // Lateral via the APPLY join type (SQL Server) OR a LATERAL
18674                // right operand (ANSI / PostgreSQL `CROSS JOIN LATERAL (...)`,
18675                // `JOIN LATERAL (...) ON ...`, `LEFT JOIN LATERAL (...) ON ...`).
18676                boolean lateral = isLateralJoinType(item.getJoinType())
18677                        || isLateralTable(item.getTable());
18678                List<String> using = usingColumnNames(item);
18679                // Slice 168 (S7): decompose this join's ON condition into
18680                // resolved predicate trees and attach them to the entity.
18681                // CROSS/OUTER APPLY carry no ON (correlation is inside the
18682                // right operand), so onPredicates stays null and conditions
18683                // stay empty — the correlation is preserved by the child
18684                // StatementGraph the right derived table produces.
18685                List<Predicate> onPredicates = (item.getOnCondition() != null)
18686                        ? PredicateTreeBuilder.build(item.getOnCondition(), provider)
18687                        : null;
18688                // Slice 177 (R1): verbatim ON-condition text (null when no ON).
18689                String conditionText = verbatimText(item.getOnCondition());
18690                // A condition-less lateral join (APPLY, CROSS JOIN LATERAL) is
18691                // self-described by sourceSyntax=LATERAL plus the isLateral flag
18692                // so a consumer never mistakes its empty condition for a buggy
18693                // cartesian INNER. A lateral join that DOES carry an explicit
18694                // condition (`JOIN LATERAL ... ON`, or a USING/NATURAL form)
18695                // keeps EXPLICIT syntax — the user wrote an explicit join
18696                // condition; its laterality rides on the orthogonal isLateral
18697                // flag only.
18698                boolean hasExplicitCondition = item.getOnCondition() != null
18699                        || (using != null && !using.isEmpty()) || natural;
18700                JoinSourceSyntax syntax = (lateral && !hasExplicitCondition)
18701                        ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.EXPLICIT;
18702                // R7: a USING join's TJoinItem ends at the last USING column
18703                // name, not the closing ')', so SourceSpan.of(item) drops the
18704                // ')'. Extend the span end to one-past the closing ')' for
18705                // USING joins. ON / NATURAL / CROSS items already reach their
18706                // true clause end, so they keep SourceSpan.of(item).
18707                SourceSpan joinSpan = usingJoinSpan(item);
18708                entities.add(new JoinEntity(jt, currentLeft, rightEp, order,
18709                        syntax, natural, using, onPredicates,
18710                        joinSpan, conditionText, lateral));
18711                accumulated.addAll(aliasesOf(rightEp));
18712                currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated));
18713                order++;
18714            }
18715        }
18716        return new JoinGraph(entities);
18717    }
18718
18719    /**
18720     * Recursive join-graph builder for a parenthesized / nested
18721     * {@code <joined_table>} sub-tree (a {@link TJoin} reached via
18722     * {@link TJoinItem#getJoin()} or {@link TJoin#getJoin()}). Emits the
18723     * sub-tree's {@link JoinEntity}s into {@code entities}, advances the shared
18724     * {@code order} and {@code relInstanceId} counters (one-element arrays so the
18725     * caller sees the updates), and returns the sub-tree's result endpoint to be
18726     * used as a left/right endpoint of the enclosing join. Returns {@code null}
18727     * when an operand is genuinely neither a table nor a nested join, so the
18728     * caller surfaces the graph as empty rather than guessing.
18729     *
18730     * <p>Traversal order (driver first, then items left-to-right, recursing
18731     * inline) is identical to {@link #appendJoinRelations}, so endpoint
18732     * instanceIds align with RelationSource instanceIds by construction.
18733     */
18734    private static JoinEndpoint buildJoinGraphForJoin(TJoin join, NameBindingProvider provider,
18735            List<JoinEntity> entities, int[] order, int[] relInstanceId) {
18736        if (join == null) return null;
18737        JoinEndpoint currentLeft;
18738        List<String> accumulated = new ArrayList<String>();
18739        TTable driver = join.getTable();
18740        if (driver != null) {
18741            String dAlias = effectiveAliasOf(driver);
18742            String dQn = relationQnOf(driver);
18743            currentLeft = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId[0]++);
18744            accumulated.add(currentLeft.getAlias());
18745        } else if (join.getJoin() != null) {
18746            currentLeft = buildJoinGraphForJoin(join.getJoin(), provider, entities, order, relInstanceId);
18747            if (currentLeft == null) return null;
18748            accumulated.addAll(aliasesOf(currentLeft));
18749        } else {
18750            return null;
18751        }
18752        TJoinItemList items = join.getJoinItems();
18753        for (int i = 0; items != null && i < items.size(); i++) {
18754            TJoinItem item = items.getJoinItem(i);
18755            if (item == null) continue;
18756            JoinEndpoint rightEp;
18757            TTable right = item.getTable();
18758            if (right != null) {
18759                String rAlias = effectiveAliasOf(right);
18760                String rQn = relationQnOf(right);
18761                rightEp = JoinEndpoint.relation(safeAlias(rAlias, rQn), rQn, relInstanceId[0]++);
18762            } else if (item.getJoin() != null) {
18763                rightEp = buildJoinGraphForJoin(item.getJoin(), provider, entities, order, relInstanceId);
18764                if (rightEp == null) return null;
18765            } else {
18766                return null;
18767            }
18768            SemanticJoinType jt = mapSemanticJoinType(item.getJoinType());
18769            boolean natural = isNaturalJoinType(item.getJoinType());
18770            boolean lateral = isLateralJoinType(item.getJoinType())
18771                    || isLateralTable(item.getTable());
18772            List<String> using = usingColumnNames(item);
18773            List<Predicate> onPredicates = (item.getOnCondition() != null)
18774                    ? PredicateTreeBuilder.build(item.getOnCondition(), provider)
18775                    : null;
18776            String conditionText = verbatimText(item.getOnCondition());
18777            boolean hasExplicitCondition = item.getOnCondition() != null
18778                    || (using != null && !using.isEmpty()) || natural;
18779            JoinSourceSyntax syntax = (lateral && !hasExplicitCondition)
18780                    ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.EXPLICIT;
18781            SourceSpan joinSpan = usingJoinSpan(item);
18782            entities.add(new JoinEntity(jt, currentLeft, rightEp, order[0],
18783                    syntax, natural, using, onPredicates,
18784                    joinSpan, conditionText, lateral));
18785            accumulated.addAll(aliasesOf(rightEp));
18786            currentLeft = JoinEndpoint.joinResult(order[0], new ArrayList<String>(accumulated));
18787            order[0]++;
18788        }
18789        return currentLeft;
18790    }
18791
18792    /**
18793     * FROM-order aliases contributed by a join endpoint: the full contributing
18794     * list for a join result, or the single alias for a relation / subquery
18795     * endpoint. Used to extend the accumulated alias list when a nested join
18796     * sub-tree is folded into an enclosing join.
18797     */
18798    private static List<String> aliasesOf(JoinEndpoint ep) {
18799        if (ep == null) return new ArrayList<String>();
18800        List<String> contrib = ep.getContributingAliases();
18801        if (contrib != null && !contrib.isEmpty()) {
18802            return new ArrayList<String>(contrib);
18803        }
18804        List<String> single = new ArrayList<String>();
18805        if (ep.getAlias() != null && !ep.getAlias().isEmpty()) {
18806            single.add(ep.getAlias());
18807        }
18808        return single;
18809    }
18810
18811    /**
18812     * R7 — compute the {@link SourceSpan} for a join entity, extending a
18813     * {@code USING (...)} join's span to include the closing {@code ')'}.
18814     *
18815     * <p>For a {@code USING} join the {@link TJoinItem}'s end token is the
18816     * last USING column name (e.g. {@code k} in {@code USING (id, k)}), so
18817     * {@link SourceSpan#of(TParseTreeNode)} ends one token early and the
18818     * slice drops the trailing {@code ')'}. Here we advance the span end to
18819     * one-past the closing {@code ')'} that follows the USING column list.
18820     *
18821     * <p>ON / NATURAL / CROSS / APPLY items already reach their true clause
18822     * end (the ON condition expression reaches the clause end; the others
18823     * carry no parenthesised key list), so they fall through to the plain
18824     * {@code SourceSpan.of(item)}.
18825     */
18826    private static SourceSpan usingJoinSpan(TJoinItem item) {
18827        if (item == null) return null;
18828        TObjectNameList using = item.getUsingColumns();
18829        TSourceToken start = item.getStartToken();
18830        if (using == null || using.size() == 0 || start == null) {
18831            return SourceSpan.of(item);
18832        }
18833        TSourceToken end = using.getEndToken();
18834        if (end == null) {
18835            return SourceSpan.of(item);
18836        }
18837        // Advance to the closing ')' that follows the USING column list. The
18838        // list's end token is the last column name; the next solid token is
18839        // the ')'. Scan a small bounded window so a stray whitespace/comment
18840        // layout never wedges the walk, and never run past it.
18841        if (!")".equals(end.getAstext())) {
18842            TSourceToken t = end.nextSolidToken();
18843            int guard = 0;
18844            while (t != null && guard++ < 8 && !")".equals(t.getAstext())) {
18845                t = t.nextSolidToken();
18846            }
18847            if (t != null && ")".equals(t.getAstext())) {
18848                end = t;
18849            }
18850        }
18851        return SourceSpan.of(start, end);
18852    }
18853
18854    /**
18855     * Slice 177 (R1) — reconstruct the verbatim source substring spanned by
18856     * a node, by concatenating the token chain from start to end WITHOUT
18857     * inserting separators (so original whitespace / comments are
18858     * preserved). Returns null when the node or its boundary tokens are
18859     * null. Used to populate {@code JoinEntity.conditionText} so consumers
18860     * don't re-implement span-slicing for the common "show me the ON text"
18861     * case.
18862     */
18863    private static String verbatimText(TParseTreeNode node) {
18864        if (node == null) return null;
18865        TSourceToken s = node.getStartToken();
18866        TSourceToken e = node.getEndToken();
18867        if (s == null || e == null) return null;
18868        StringBuilder sb = new StringBuilder();
18869        TSourceToken c = s;
18870        int guard = 0;
18871        boolean reachedEnd = false;
18872        while (c != null && guard++ < 2_000_000) {
18873            if (c.astext != null) sb.append(c.astext);
18874            if (c == e) {
18875                reachedEnd = true;
18876                break;
18877            }
18878            c = c.getNextTokenInChain();
18879        }
18880        // If the end token was not reachable from the start (or the guard
18881        // tripped), the accumulated prefix would be truncated/unrelated SQL.
18882        // Return null (unavailable) rather than silently-wrong verbatim text.
18883        if (!reachedEnd) return null;
18884        return sb.length() == 0 ? null : sb.toString();
18885    }
18886
18887    /** Endpoint alias: the effective alias, falling back to the qualified name. */
18888    private static String safeAlias(String alias, String qn) {
18889        if (alias != null && !alias.isEmpty()) return alias;
18890        if (qn != null && !qn.isEmpty()) return qn;
18891        return "<anonymous>";
18892    }
18893
18894    /** Bare relation name for an endpoint's qualifiedName; null when unavailable. */
18895    private static String relationQnOf(TTable t) {
18896        if (t == null) return null;
18897        TObjectName n = t.getTableName();
18898        if (n != null) {
18899            String s = n.toString();
18900            if (s != null && !s.isEmpty()) return s;
18901        }
18902        return null;
18903    }
18904
18905    /** USING column names (empty for non-USING joins). */
18906    private static List<String> usingColumnNames(TJoinItem item) {
18907        List<String> out = new ArrayList<String>();
18908        if (item.getUsingColumns() != null) {
18909            for (int u = 0; u < item.getUsingColumns().size(); u++) {
18910                TObjectName c = item.getUsingColumns().getObjectName(u);
18911                if (c != null) {
18912                    String name = c.getColumnNameOnly();
18913                    if (name == null || name.isEmpty()) name = c.toString();
18914                    if (name != null && !name.isEmpty()) out.add(name);
18915                }
18916            }
18917        }
18918        return out;
18919    }
18920
18921    /**
18922     * Map the parser's {@link EJoinType} to the stable
18923     * {@link SemanticJoinType}. NATURAL variants map to the underlying
18924     * join type ({@code natural_left} -&gt; LEFT); the NATURAL semantics
18925     * are carried by {@code JoinEntity.isNatural()}. Bare {@code natural}
18926     * maps to {@link SemanticJoinType#NATURAL}. {@code crossapply} /
18927     * {@code outerapply} degrade to INNER / LEFT with the lateral nature
18928     * carried by {@code JoinEntity.isLateral()}. Shapes the semantic
18929     * builder does not admit (nested / semi / anti / vendor extensions)
18930     * map to {@link SemanticJoinType#UNSUPPORTED} — never guessed.
18931     */
18932    private static SemanticJoinType mapSemanticJoinType(EJoinType jt) {
18933        if (jt == null) return SemanticJoinType.UNSUPPORTED;
18934        switch (jt) {
18935            case inner:
18936            case join:
18937            case straight:
18938            case natural_inner:
18939                return SemanticJoinType.INNER;
18940            case left:
18941            case leftouter:
18942            case natural_left:
18943            case natural_leftouter:
18944                return SemanticJoinType.LEFT;
18945            case right:
18946            case rightouter:
18947            case natural_right:
18948            case natural_rightouter:
18949                return SemanticJoinType.RIGHT;
18950            case full:
18951            case fullouter:
18952            case natural_full:
18953            case natural_fullouter:
18954                return SemanticJoinType.FULL;
18955            case cross:
18956                return SemanticJoinType.CROSS;
18957            case crossapply:
18958                // SQL Server CROSS APPLY: inner lateral join (drops left
18959                // rows with no right match). Lateral nature carried by
18960                // JoinEntity.isLateral().
18961                return SemanticJoinType.INNER;
18962            case outerapply:
18963                // SQL Server OUTER APPLY: left lateral join (keeps left
18964                // rows, right side nullable).
18965                return SemanticJoinType.LEFT;
18966            case natural:
18967                return SemanticJoinType.NATURAL;
18968            default:
18969                return SemanticJoinType.UNSUPPORTED;
18970        }
18971    }
18972
18973    // -----------------------------------------------------------------
18974    // Slice 58 / 59 — catalog-backed SELECT * expansion.
18975    //
18976    // The hook in buildOutputColumns calls tryExpandStar(rc, select,
18977    // provider, isPredicateBody, stmtName) for any result column whose
18978    // columnNameOnly is "*" (and as defense in depth for any
18979    // EExpressionType.list_t expression). tryExpandStar returns a
18980    // StarExpansionResult that is either EXPANDED (with a list of
18981    // OutputColumns) or one of several reasoned rejection kinds. The
18982    // hook then either appends the expanded columns or throws a
18983    // structured SemanticIRBuildException whose message is unique per
18984    // kind so external callers can pattern-match without parsing a
18985    // generic "not supported yet" string.
18986    //
18987    // Scope (slice 58):
18988    //   - single base-table FROM (1 join, no join items)
18989    //   - bare `*` or qualified `t.*`
18990    //   - catalog provided via NameBindingProvider#getRelationColumnNames
18991    //
18992    // Slice 59 lift:
18993    //   - multi-relation FROM is now supported when a single top-level
18994    //     TJoin carries one or more explicit JOIN clauses (joinItems).
18995    //     Each FROM relation must individually satisfy slice-58 rules
18996    //     (binding kind TABLE, catalog declares columns). Bare `*`
18997    //     concatenates per-relation expansions in FROM order; qualified
18998    //     `t.*` selects the one relation whose effective alias matches.
18999    //   - qualifier matching is now effective-alias only
19000    //     (alias if present, else table name) — case-insensitive. This
19001    //     unifies the rule across single- and multi-relation paths;
19002    //     `SELECT employees.* FROM employees e` rejects because the
19003    //     effective alias is `e`, not `employees`.
19004    //   - star expansion is rejected inside synthetic body contexts
19005    //     (scalar-subquery / set-op-branch / predicate-subquery) via
19006    //     SYNTHETIC_BODY_CONTEXT; the slice-58 path silently allowed
19007    //     this for catalog-equipped builds even though a multi-column
19008    //     expansion would corrupt scalar-body shape downstream.
19009    //
19010    // Slice 60 lift:
19011    //   - CTE star and FROM-subquery star (`a.*` and bare `*` over a CTE
19012    //     or FROM-clause subquery alias) are now supported via the
19013    //     in-scope-relation-columns map carried on the provider. The
19014    //     map is populated at each consuming-SELECT call site in build()
19015    //     and extractFromSubqueriesAsStatements before the consumer's
19016    //     buildOutputColumns runs; tryExpandStar reads it for CTE /
19017    //     SUBQUERY bindings. Explicit CTE column lists
19018    //     (`WITH a(x, y) AS ...`) stay rejected because the CTE body's
19019    //     StatementGraph publishes inner-projection names, not the
19020    //     explicit list, and emitLineageForStatement would point at
19021    //     non-existent body outputs. Lifting that path needs either
19022    //     body-output renaming or a published-name → body-name lineage
19023    //     map; deferred to a future slice.
19024    //
19025    // Slice 62 lift:
19026    //   - Comma-FROM (multiple top-level TJoin elements parsed from
19027    //     `FROM a, b, c`) is now admitted at the outer / CTE-body /
19028    //     FROM-subquery-body call sites. {@link #tryExpandStar} walks
19029    //     every top-level TJoin and accumulates relations in FROM
19030    //     order; bare `*` concatenates per-relation expansions and
19031    //     qualified `t.*` selects the matching effective-alias.
19032    //     Synthetic body contexts (scalar / set-op-branch / set-op-CTE
19033    //     / predicate) still reject comma-FROM via the gated reject
19034    //     in buildRelations and the slice-62 reject inside
19035    //     preflightExistsInnerShape.
19036    //
19037    // Out of scope (slice 60+):
19038    //   - SELECT * EXCEPT/REPLACE (BigQuery extensions; no slice scheduled)
19039    //   - Explicit CTE column list star expansion (slice 61+)
19040    // -----------------------------------------------------------------
19041
19042    enum StarExpansionKind {
19043        EXPANDED,
19044        PREDICATE_BODY_GUARD,
19045        // Defensive catch-all for malformed FROM lists: missing top-level
19046        // TJoin, null table on a top-level TJoin or a join item, or
19047        // empty {@code select.joins}. Slice 62 made comma-FROM admit
19048        // here (the walk iterates every top-level TJoin), so reaching
19049        // this kind indicates a parse-tree anomaly rather than a comma-
19050        // FROM rejection.
19051        MULTI_RELATION_FROM,
19052        NON_BASE_TABLE_RELATION,
19053        QUALIFIER_NOT_FOUND,
19054        // Slice 59: a qualifier matches 2+ relations (case-insensitive
19055        // effective-alias collision). Real SQL never reaches this case
19056        // unless `rejectDuplicateAliases` permitted a case-only collision
19057        // (it is case-sensitive at SemanticIRBuilder.java:5621).
19058        QUALIFIER_AMBIGUOUS,
19059        NO_CATALOG_OR_UNKNOWN_TABLE,
19060        // Slice 59: star expansion in synthetic body contexts
19061        // (scalar-subquery, set-op-branch, predicate-subquery) is rejected
19062        // because multi-column expansion would violate the body's shape
19063        // contract (e.g. scalar bodies must project exactly one column).
19064        SYNTHETIC_BODY_CONTEXT,
19065        // Slice 60: CTE has an explicit column list
19066        // (`WITH a(x, y) AS ...`). Deferred to a future slice because
19067        // the CTE body's StatementGraph publishes inner-projection
19068        // names, not the explicit list, and lineage emission cannot
19069        // bridge that without either body-output renaming or a
19070        // published-name → body-name map.
19071        EXPLICIT_CTE_COLUMN_LIST_DEFERRED,
19072        // Slice 60: CTE / SUBQUERY binding's published-column map
19073        // lookup returned null or empty. This indicates a builder
19074        // invariant failure (the body should have been built and
19075        // registered before the consumer's buildOutputColumns runs);
19076        // user SQL cannot reach this kind under normal builds — only
19077        // fabricated providers or a missed plumbing path would. The
19078        // diagnostic names the binding kind and qualified name so
19079        // regressions are loud, not silent.
19080        NO_INSCOPE_RELATION_COLUMNS
19081    }
19082
19083    static final class StarExpansionResult {
19084        final StarExpansionKind kind;
19085        final List<OutputColumn> columns;
19086        final String qualifier;
19087        final String detail;
19088
19089        private StarExpansionResult(StarExpansionKind kind,
19090                                    List<OutputColumn> columns,
19091                                    String qualifier,
19092                                    String detail) {
19093            this.kind = kind;
19094            this.columns = columns;
19095            this.qualifier = qualifier;
19096            this.detail = detail;
19097        }
19098
19099        static StarExpansionResult expanded(List<OutputColumn> cols) {
19100            return new StarExpansionResult(StarExpansionKind.EXPANDED, cols, null, null);
19101        }
19102
19103        static StarExpansionResult reject(StarExpansionKind kind) {
19104            return new StarExpansionResult(kind, null, null, null);
19105        }
19106
19107        static StarExpansionResult reject(StarExpansionKind kind, String qualifier, String detail) {
19108            return new StarExpansionResult(kind, null, qualifier, detail);
19109        }
19110    }
19111
19112    /**
19113     * Effective alias for a FROM-clause {@link TTable}: the SQL-written
19114     * alias if present, else the slice-74 synthetic alias for unaliased
19115     * FROM-subquery TTables (position-keyed via
19116     * {@link FromSubqueryNaming#synthAliasFor}), else the table name.
19117     * Mirrors the rule used by {@link #buildRelation} so
19118     * {@link ColumnRef#getRelationAlias()} stays aligned with what the
19119     * lineage emitter expects.
19120     */
19121    private static String effectiveAliasOf(TTable t) {
19122        if (t == null) return null;
19123        String alias = t.getAliasName();
19124        if (alias != null && !alias.isEmpty()) return alias;
19125        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
19126            return FromSubqueryNaming.synthAliasFor(t);
19127        }
19128        return t.getName();
19129    }
19130
19131    /**
19132     * Slice 58 / 59 — attempt to expand a {@code SELECT *} or
19133     * {@code SELECT alias.*} result column using the catalog exposed via
19134     * {@link NameBindingProvider#getRelationColumnNames(TTable)}.
19135     *
19136     * <p>Slice 58 supported only single-base-table FROM. Slice 59 lifts
19137     * the multi-relation case to JOIN forms (single top-level TJoin with
19138     * explicit JOIN clauses). Comma-FROM stays rejected by
19139     * {@code buildRelations}.
19140     *
19141     * <p>Returns {@link StarExpansionKind#EXPANDED} with one
19142     * {@link OutputColumn} per catalog-declared column on success.
19143     * Otherwise returns a reasoned rejection so the caller can throw a
19144     * shape-specific {@link SemanticIRBuildException}.
19145     */
19146    /**
19147     * True when some FROM operand of {@code select} is a parenthesized / nested
19148     * {@code <joined_table>}: a {@link TJoin} reached via {@link TJoin#getJoin()}
19149     * (nested driver) or {@link TJoinItem#getJoin()} (nested right operand) whose
19150     * {@code getTable()} is null. Used to scope the MULTI_RELATION_FROM
19151     * star-expansion degrade to the genuinely-analyzable nested-join shape — a
19152     * null FROM table WITHOUT a nested join, or a null top-level {@link TJoin},
19153     * is a broken invariant and stays fatal.
19154     */
19155    private static boolean hasNestedJoinOperand(TSelectSqlStatement select) {
19156        if (select == null || select.joins == null) {
19157            return false;
19158        }
19159        for (int j = 0; j < select.joins.size(); j++) {
19160            TJoin topJoin = select.joins.getJoin(j);
19161            if (topJoin == null) {
19162                continue;
19163            }
19164            if (topJoin.getTable() == null && topJoin.getJoin() != null) {
19165                return true;
19166            }
19167            TJoinItemList items = topJoin.getJoinItems();
19168            for (int i = 0; items != null && i < items.size(); i++) {
19169                TJoinItem item = items.getJoinItem(i);
19170                if (item != null && item.getTable() == null && item.getJoin() != null) {
19171                    return true;
19172                }
19173            }
19174        }
19175        return false;
19176    }
19177
19178    private static StarExpansionResult tryExpandStar(TResultColumn rc,
19179                                                     TSelectSqlStatement select,
19180                                                     NameBindingProvider provider,
19181                                                     boolean isPredicateBody,
19182                                                     String stmtName) {
19183        if (isPredicateBody) {
19184            // Defensive: the active rejection lives in
19185            // preflightExistsInnerShape (~line 3880) and fires before
19186            // this code runs. Slice-24 EXISTS-with-* tests pin that path.
19187            return StarExpansionResult.reject(StarExpansionKind.PREDICATE_BODY_GUARD);
19188        }
19189        // Slice 59: reject star expansion in synthetic body contexts.
19190        // Scalar-subquery bodies must project exactly one column;
19191        // set-op-branch bodies must keep per-branch column-count parity;
19192        // predicate-subquery bodies are constant or column-ref shapes
19193        // (slice 23/24/27). Multi-column expansion would corrupt all
19194        // three. The preflight at SemanticIRBuilder.java:1007 only
19195        // checks AST result-column count/name, not "*", so without this
19196        // guard a catalog-equipped scalar body `SELECT * FROM small`
19197        // would silently emit multiple OutputColumns.
19198        if (stmtName != null
19199                && (isScalarSyntheticName(stmtName)
19200                        || isSetOpBranchSyntheticName(stmtName)
19201                        || isPredicateSubquerySyntheticName(stmtName))) {
19202            return StarExpansionResult.reject(
19203                    StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null,
19204                    "star expansion is not supported inside synthetic body '"
19205                            + stmtName + "' (scalar, set-op branch, or predicate body)");
19206        }
19207        // Extract qualifier (empty string for bare `*`, alias/name for `t.*`).
19208        String qualifier = "";
19209        TExpression expr = rc.getExpr();
19210        if (expr != null && expr.getObjectOperand() != null) {
19211            String q = expr.getObjectOperand().getTableString();
19212            if (q != null && !q.isEmpty()) {
19213                qualifier = q;
19214            }
19215        }
19216        // FROM-clause shape gate. Slice 59 supported a single top-level
19217        // TJoin with zero or more explicit JOIN clauses. Slice 62 lifts
19218        // comma-FROM to multi-TJoin: walk every top-level TJoin in
19219        // {@code select.joins} (a comma-FROM list parses as multiple
19220        // top-level TJoins) and accumulate every relation in FROM order.
19221        if (select.joins == null || select.joins.size() == 0) {
19222            return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM);
19223        }
19224        List<TTable> fromRelations = new ArrayList<>();
19225        for (int j = 0; j < select.joins.size(); j++) {
19226            TJoin topJoin = select.joins.getJoin(j);
19227            if (topJoin == null) {
19228                return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM);
19229            }
19230            TTable leftTable = topJoin.getTable();
19231            if (leftTable == null) {
19232                return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM);
19233            }
19234            fromRelations.add(leftTable);
19235            TJoinItemList items = topJoin.getJoinItems();
19236            if (items == null) continue;
19237            for (int i = 0; i < items.size(); i++) {
19238                TJoinItem item = items.getJoinItem(i);
19239                TTable rightTable = item.getTable();
19240                if (rightTable == null) {
19241                    return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM);
19242                }
19243                fromRelations.add(rightTable);
19244            }
19245        }
19246        // Slice 65 / 66: bare `*` over a USING / NATURAL JOIN collapses
19247        // merged keys. For each FROM relation in order, walk catalog/
19248        // in-scope columns; emit one OutputColumn per merged key (sources
19249        // = merged ref list) and one per non-merged column. Qualified
19250        // `t.*` is unaffected (single-relation path, no merged-key dedup).
19251        if (qualifier.isEmpty()
19252                && (hasUsingInFromClause(select)
19253                        || hasNaturalJoinMergedKeysInFromClause(select, provider))) {
19254            return expandBareStarOverUsing(select, provider, fromRelations);
19255        }
19256        // Qualified `t.*`: pick the (unique) FROM relation whose
19257        // effective alias matches the qualifier (case-insensitive).
19258        // Effective alias = `alias != null && !alias.isEmpty() ? alias :
19259        // tableName`, matching buildRelation at line 5649. Slice 58's
19260        // alias-OR-name match (line 5785 before slice 59) is replaced
19261        // here so `SELECT employees.* FROM employees e` rejects
19262        // (qualifier=`employees` ≠ effective alias `e`), consistent
19263        // with standard SQL correlation-name semantics.
19264        if (!qualifier.isEmpty()) {
19265            List<TTable> matches = new ArrayList<>();
19266            for (TTable t : fromRelations) {
19267                String ea = effectiveAliasOf(t);
19268                if (ea != null && ea.equalsIgnoreCase(qualifier)) {
19269                    matches.add(t);
19270                }
19271            }
19272            if (matches.isEmpty()) {
19273                return StarExpansionResult.reject(
19274                        StarExpansionKind.QUALIFIER_NOT_FOUND, qualifier, null);
19275            }
19276            if (matches.size() > 1) {
19277                StringBuilder names = new StringBuilder();
19278                for (int i = 0; i < matches.size(); i++) {
19279                    if (i > 0) names.append(", ");
19280                    names.append(effectiveAliasOf(matches.get(i)));
19281                }
19282                return StarExpansionResult.reject(
19283                        StarExpansionKind.QUALIFIER_AMBIGUOUS, qualifier,
19284                        "matches " + matches.size() + " FROM-clause relations: "
19285                                + names);
19286            }
19287            return expandSingleRelation(matches.get(0), provider, qualifier);
19288        }
19289        // Bare `*`: expand every FROM relation in order. Fail fast on
19290        // the first relation that does not satisfy the slice-58 rules
19291        // (binding kind TABLE, catalog declares columns); the caller
19292        // sees the per-relation rejection kind and detail. No partial
19293        // outputs are returned.
19294        List<OutputColumn> all = new ArrayList<>();
19295        for (TTable t : fromRelations) {
19296            StarExpansionResult one = expandSingleRelation(t, provider, "");
19297            if (one.kind != StarExpansionKind.EXPANDED) {
19298                return one;
19299            }
19300            all.addAll(one.columns);
19301        }
19302        return StarExpansionResult.expanded(all);
19303    }
19304
19305    /**
19306     * Slice 58 / 59 — pure per-relation star expander. Applies the
19307     * base-table-only + catalog rules and builds one
19308     * {@link OutputColumn} per catalog-declared column with a
19309     * {@link ColumnRef} whose {@code relationAlias} is the effective
19310     * alias of {@code target}. Returns {@link StarExpansionKind#EXPANDED}
19311     * on success, otherwise a tuned rejection.
19312     *
19313     * <p>The {@code qualifier} parameter is the SQL-written qualifier
19314     * for qualified `t.*` (empty for bare `*`); it is plumbed back into
19315     * the rejection result so the caller can include it in
19316     * user-visible diagnostics.
19317     */
19318    private static StarExpansionResult expandSingleRelation(TTable target,
19319                                                            NameBindingProvider provider,
19320                                                            String qualifier) {
19321        // The TTable's tableType cannot distinguish CTE from base table —
19322        // CTE references arrive as ETableSource.objectname. Use the
19323        // provider's bindRelation to get the resolved RelationKind.
19324        RelationBinding binding = provider.bindRelation(target);
19325        if (binding == null) {
19326            String diagAlias = effectiveAliasOf(target);
19327            return StarExpansionResult.reject(
19328                    StarExpansionKind.NON_BASE_TABLE_RELATION, qualifier,
19329                    "FROM source '"
19330                            + (diagAlias != null ? diagAlias : "<unnamed>")
19331                            + "' could not be bound (only base tables, in-scope CTEs, and FROM-subqueries are supported)");
19332        }
19333        RelationKind kind = binding.getKind();
19334        if (kind == RelationKind.TABLE) {
19335            // Slice 58 catalog-backed path. Returns null when no
19336            // catalog, when the catalog doesn't declare this table,
19337            // or when the table has no columns.
19338            List<String> columnNames = provider.getRelationColumnNames(target);
19339            if (columnNames == null || columnNames.isEmpty()) {
19340                String diagAlias = effectiveAliasOf(target);
19341                return StarExpansionResult.reject(
19342                        StarExpansionKind.NO_CATALOG_OR_UNKNOWN_TABLE, qualifier,
19343                        diagAlias);
19344            }
19345            return buildExpansionFromColumnNames(target, columnNames);
19346        }
19347        if (kind == RelationKind.CTE) {
19348            // Slice 60 + Slice 103: key by EFFECTIVE ALIAS in the consuming
19349            // SELECT, not by CTE name. This avoids a collision when a
19350            // FROM-subquery alias equals a visible CTE name and the
19351            // CTE is referenced under a different alias (codex
19352            // diff-review): `WITH a AS (...) SELECT c.*, a.* FROM a c
19353            // JOIN (SELECT ...) a ON ...` — both 'a' (CTE) and 'a'
19354            // (subquery alias) live in the FROM clause; effective
19355            // aliases are 'c' and 'a' respectively, so per-relation
19356            // entries cannot overwrite each other.
19357            //
19358            // Slice 103 — explicit CTE column lists (WITH a(x, y) AS ...)
19359            // are no longer rejected here. The slice-102 rename helper now
19360            // runs on the SELECT-side CTE walker too; the in-scope map
19361            // populated by addRelationToInScopeMap reads from
19362            // ctePublishedColumns, which the helper has populated with the
19363            // renamed names. Star expansion just falls through to the
19364            // in-scope lookup below; the renamed list comes back.
19365            String lookupKey = effectiveAliasLowerCaseOrNull(target);
19366            List<String> cteColumns = (lookupKey == null) ? null
19367                    : provider.getInScopeRelationColumns().get(lookupKey);
19368            if (cteColumns == null || cteColumns.isEmpty()) {
19369                String diagAlias = effectiveAliasOf(target);
19370                return StarExpansionResult.reject(
19371                        StarExpansionKind.NO_INSCOPE_RELATION_COLUMNS,
19372                        qualifier,
19373                        "CTE '" + diagAlias + "' has no published columns in "
19374                                + "the in-scope map (builder invariant: the "
19375                                + "CTE body should have been built and "
19376                                + "registered before this consumer ran)");
19377            }
19378            return buildExpansionFromColumnNames(target, cteColumns);
19379        }
19380        if (kind == RelationKind.SUBQUERY) {
19381            // Slice 60: same effective-alias keying as the CTE branch
19382            // above (codex diff-review). For a subquery the effective
19383            // alias IS the alias the SQL writer wrote (preflight
19384            // rejects anonymous subqueries), so this branch is also
19385            // unambiguous under the alias-collision example.
19386            String lookupKey = effectiveAliasLowerCaseOrNull(target);
19387            List<String> subColumns = (lookupKey == null) ? null
19388                    : provider.getInScopeRelationColumns().get(lookupKey);
19389            if (subColumns == null || subColumns.isEmpty()) {
19390                String diagAlias = effectiveAliasOf(target);
19391                return StarExpansionResult.reject(
19392                        StarExpansionKind.NO_INSCOPE_RELATION_COLUMNS,
19393                        qualifier,
19394                        "FROM-clause subquery '"
19395                                + (diagAlias != null ? diagAlias : "<unnamed>")
19396                                + "' has no published columns in the in-scope "
19397                                + "map (builder invariant: the subquery body "
19398                                + "should have been extracted and registered "
19399                                + "before this consumer ran)");
19400            }
19401            return buildExpansionFromColumnNames(target, subColumns);
19402        }
19403        // OUTER_REFERENCE / UNION / UNKNOWN: keep the slice-58 / 59
19404        // rejection contract. None of these arrive on a slice-60
19405        // FROM-clause relation via the current builder paths
19406        // (OUTER_REFERENCE bindings live only on RelationSource for
19407        // correlated scalar lookup; UNION is a set-op branch concept,
19408        // not a FROM-clause relation). Defensive catch-all.
19409        String diagAlias = effectiveAliasOf(target);
19410        String detail;
19411        switch (kind) {
19412            case OUTER_REFERENCE:
19413                detail = "OUTER_REFERENCE star expansion is not supported (relation '"
19414                        + diagAlias + "')";
19415                break;
19416            case UNION:
19417            case UNKNOWN:
19418            default:
19419                detail = "FROM source '" + diagAlias
19420                        + "' must be a base table, CTE, or FROM-subquery (got kind="
19421                        + kind + ")";
19422                break;
19423        }
19424        return StarExpansionResult.reject(
19425                StarExpansionKind.NON_BASE_TABLE_RELATION, qualifier, detail);
19426    }
19427
19428    /**
19429     * Slice 60 — shared helper that turns a column-name list into the
19430     * star-expansion OutputColumn list with one {@link ColumnRef} per
19431     * column whose {@code relationAlias} is the effective alias of the
19432     * target table (alias if present, else the table name). Used by all
19433     * three slice-58 / 59 / 60 paths.
19434     */
19435    private static StarExpansionResult buildExpansionFromColumnNames(
19436            TTable target, List<String> columnNames) {
19437        String alias = effectiveAliasOf(target);
19438        List<OutputColumn> outputs = new ArrayList<>(columnNames.size());
19439        for (String colName : columnNames) {
19440            ColumnRef ref = new ColumnRef(alias, colName);
19441            outputs.add(new OutputColumn(
19442                    colName,
19443                    /*derived=*/ false,
19444                    /*aggregate=*/ false,
19445                    Collections.singletonList(ref),
19446                    /*windowSpec=*/ null));
19447        }
19448        return StarExpansionResult.expanded(outputs);
19449    }
19450
19451    /**
19452     * Slice 65 — bare {@code *} over a USING JOIN: deduplicate the
19453     * merged key within each equivalence class. For each FROM relation
19454     * in order:
19455     * <ul>
19456     *   <li>look up columns via the existing
19457     *       {@link #lookupRelationColumnNames} (catalog + in-scope map);
19458     *       reject with {@link StarExpansionKind#NO_CATALOG_OR_UNKNOWN_TABLE}
19459     *       when null (we can't dedup without knowing what's there);</li>
19460     *   <li>for each column, check
19461     *       {@link UsingScope#entryContaining(String, TTable)};
19462     *       if a class contains this relation, emit a single
19463     *       merged-source {@link OutputColumn} the first time the class
19464     *       is seen and skip duplicates from later class members;</li>
19465     *   <li>otherwise emit a plain single-source OutputColumn.</li>
19466     * </ul>
19467     *
19468     * <p>Duplicate-output guard fires ONLY when the conflicting names
19469     * involve a USING-merged entry (merged-vs-plain or two disconnected
19470     * merged classes for the same key). Plain duplicates from
19471     * non-USING multi-relation expansion remain admitted (slice-59
19472     * behavior).
19473     *
19474     * <p>Output column order is <b>left-table order with USING-key
19475     * dedup within each equivalence class</b>: the merged column
19476     * appears at the position of its first member in FROM order. E.g.
19477     * {@code a(id, k), b(k, name)} → {@code [id, k, name]}.
19478     *
19479     * <p>This is INTENTIONALLY DIFFERENT from the ANSI/PostgreSQL
19480     * physical column order (which puts USING columns first, then
19481     * remaining left, then remaining right — would yield
19482     * {@code [k, id, name]}). The Semantic IR is not a query
19483     * executor; the order it surfaces is a lineage-tracking
19484     * presentation choice. Left-table order:
19485     * <ol>
19486     *   <li>matches the slice-65 roadmap resume protocol
19487     *       ({@code docs/designs/sql-semantic-governance-unified-roadmap.md}
19488     *       §13.1) which fixed this order before implementation;</li>
19489     *   <li>keeps the merged column physically adjacent to its
19490     *       left-side neighbors, matching how lineage tooling
19491     *       traditionally renders combined JOIN output;</li>
19492     *   <li>does not depend on USING-clause ordering (which is a
19493     *       syntactic choice, not a semantic one).</li>
19494     * </ol>
19495     * Codex diff-review round 5 flagged this as P2 (non-ANSI). The
19496     * choice was confirmed in plan-review and is locked by
19497     * {@code bareStarOverUsingLeftPositionPreserved} so any future
19498     * change to ANSI order is a deliberate observable contract
19499     * change, not a silent fix.
19500     */
19501    private static StarExpansionResult expandBareStarOverUsing(
19502            TSelectSqlStatement select,
19503            NameBindingProvider provider,
19504            List<TTable> fromRelations) {
19505        UsingScope scope = provider.getUsingScope();
19506        if (scope.isEmpty()) {
19507            // The slice-65 caller checks hasUsingInFromClause before
19508            // routing here, so an empty scope here means buildUsingScope
19509            // computed empty entries (unreachable in practice). Fall
19510            // back to a generic reject so the caller surfaces a
19511            // structured diagnostic.
19512            return StarExpansionResult.reject(
19513                    StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null,
19514                    "bare * over JOIN ... USING reached the merged-key expander "
19515                            + "with an empty UsingScope (builder invariant failure)");
19516        }
19517        LinkedHashSet<String> emittedNamesLC = new LinkedHashSet<>();
19518        Set<String> mergedNamesLC = new HashSet<>();
19519        java.util.IdentityHashMap<UsingScope.EquivalenceClass, Boolean> emittedClasses =
19520                new java.util.IdentityHashMap<>();
19521        List<OutputColumn> outputs = new ArrayList<>();
19522        for (TTable t : fromRelations) {
19523            // Slice 103 lifted the explicit-CTE-column-list deferral:
19524            // populateUsingJoinRefs no longer rejects, and
19525            // lookupRelationColumnNames returns the renamed names from
19526            // the in-scope map (populated by the slice-102 rename
19527            // helper that the SELECT-side CTE walker now invokes).
19528            List<String> cols = lookupRelationColumnNames(t, provider);
19529            if (cols == null) {
19530                return StarExpansionResult.reject(
19531                        StarExpansionKind.NO_CATALOG_OR_UNKNOWN_TABLE, null,
19532                        effectiveAliasOf(t));
19533            }
19534            for (String c : cols) {
19535                if (c == null) continue;
19536                String keyLC = c.toLowerCase(Locale.ROOT);
19537                UsingScope.MergedKeyEntry entry = scope.entryContaining(keyLC, t);
19538                if (entry != null) {
19539                    // USING-merged column. Dedup per class.
19540                    if (emittedClasses.containsKey(entry.getEquivClass())) {
19541                        continue;
19542                    }
19543                    emittedClasses.put(entry.getEquivClass(), Boolean.TRUE);
19544                    OutputColumn cand = new OutputColumn(
19545                            c, /*derived=*/ false, /*aggregate=*/ false,
19546                            entry.getSources(), /*windowSpec=*/ null);
19547                    StarExpansionResult dup = appendMergedAwareOrReject(
19548                            outputs, emittedNamesLC, mergedNamesLC, cand, /*isMerged=*/ true);
19549                    if (dup != null) return dup;
19550                } else {
19551                    // Plain column. Slice-59 behavior: duplicate plain
19552                    // names are admitted. Codex round-5: the merged-aware
19553                    // guard fires only when ONE side is merged.
19554                    OutputColumn cand = new OutputColumn(
19555                            c, /*derived=*/ false, /*aggregate=*/ false,
19556                            Collections.singletonList(new ColumnRef(effectiveAliasOf(t), c)),
19557                            /*windowSpec=*/ null);
19558                    StarExpansionResult dup = appendMergedAwareOrReject(
19559                            outputs, emittedNamesLC, mergedNamesLC, cand, /*isMerged=*/ false);
19560                    if (dup != null) return dup;
19561                }
19562            }
19563        }
19564        return StarExpansionResult.expanded(outputs);
19565    }
19566
19567    /**
19568     * Slice 65 — duplicate-output helper for
19569     * {@link #expandBareStarOverUsing}. Fires the merged-vs-non-merged
19570     * collision guard. Returns a {@link StarExpansionResult} when the
19571     * caller should reject; returns {@code null} when the candidate is
19572     * appended successfully.
19573     */
19574    private static StarExpansionResult appendMergedAwareOrReject(
19575            List<OutputColumn> outputs,
19576            LinkedHashSet<String> emittedNamesLC,
19577            Set<String> mergedNamesLC,
19578            OutputColumn cand,
19579            boolean isMerged) {
19580        String nameLC = cand.getName().toLowerCase(Locale.ROOT);
19581        boolean alreadyEmitted = emittedNamesLC.contains(nameLC);
19582        boolean alreadyMerged = mergedNamesLC.contains(nameLC);
19583        // Reject only when at least one side is a USING-merged entry.
19584        // Plain-vs-plain duplicates remain admitted (slice-59 behavior).
19585        if (alreadyEmitted && (isMerged || alreadyMerged)) {
19586            return StarExpansionResult.reject(
19587                    StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null,
19588                    "bare * over JOIN ... USING produces ambiguous output "
19589                            + "column '" + cand.getName() + "': a USING-merged "
19590                            + "entry and a same-named column from outside the "
19591                            + "USING equivalence class collide (or two disconnected "
19592                            + "USING classes share the same key name); qualify "
19593                            + "with t.* per relation or rename a column to "
19594                            + "disambiguate");
19595        }
19596        emittedNamesLC.add(nameLC);
19597        if (isMerged) mergedNamesLC.add(nameLC);
19598        outputs.add(cand);
19599        return null;
19600    }
19601
19602    /**
19603     * Slice 60 — read the published column names for an already-built
19604     * statement (CTE body or FROM-subquery body) from its
19605     * {@link StatementGraph#getOutputColumns()}. Used by {@code build()}
19606     * and {@code extractFromSubqueriesAsStatements} to populate the
19607     * in-scope map before each consuming SELECT's
19608     * {@code buildOutputColumns} runs.
19609     */
19610    private static List<String> outputColumnNames(StatementGraph body) {
19611        List<OutputColumn> cols = body.getOutputColumns();
19612        List<String> names = new ArrayList<>(cols.size());
19613        for (OutputColumn c : cols) names.add(c.getName());
19614        return Collections.unmodifiableList(names);
19615    }
19616
19617    /**
19618     * Slice 60 — effective alias of a TTable lower-cased, or null when
19619     * the table has neither an alias nor a name. The alias-collision
19620     * fix (codex diff-review) replaced CTE-name / subquery-alias
19621     * keying with effective-alias keying; this helper centralises the
19622     * lookup-key computation.
19623     */
19624    private static String effectiveAliasLowerCaseOrNull(TTable t) {
19625        String alias = effectiveAliasOf(t);
19626        if (alias == null || alias.isEmpty()) return null;
19627        return alias.toLowerCase(Locale.ROOT);
19628    }
19629
19630    /**
19631     * Slice 60 — build a per-consumer effective-alias-keyed map of
19632     * "FROM-clause relation alias → published column names" by walking
19633     * the consumer's direct FROM/JOIN list (single top-level TJoin,
19634     * left table + each joinItem.getTable()).
19635     *
19636     * <p>Each CTE-bound relation contributes its effective alias →
19637     * {@code ctePublishedColumns.get(cteName.toLowerCase())}. Each
19638     * FROM-subquery contributes its alias → {@code
19639     * outputColumnNames(stmts.get(subqueryAliasToIndex.get(alias)))}.
19640     * Base-table relations are skipped because their star expansion
19641     * uses the catalog path (TSQLEnv); adding them here would force
19642     * dialect-specific catalog walks before catalog access is required.
19643     *
19644     * <p>The codex diff-review found that a single name-keyed map
19645     * collides when a FROM-subquery alias equals a visible CTE name
19646     * (`WITH a AS (...) ... FROM a c JOIN (SELECT ...) a ...`). Keying
19647     * by effective alias (which is unique per FROM clause —
19648     * {@link #preflightDirectFromList} rejects duplicates) closes the
19649     * collision class.
19650     *
19651     * @param consumer              the SELECT whose FROM list to walk
19652     * @param consumerProvider      provider used only for bindRelation
19653     *                              (CTE vs TABLE discrimination)
19654     * @param ctePublishedColumns   CTE-name → columns lookup
19655     *                              populated as CTE bodies are built
19656     * @param subqueryAliasToIndex  this consumer's own subquery alias
19657     *                              → stmts index lookup
19658     * @param stmts                 already-built statement list
19659     * @return mutable effective-alias-keyed in-scope map for this
19660     *         consumer; callers wrap it via
19661     *         {@code provider.withInScopeRelationColumns(map)}
19662     */
19663    private static Map<String, List<String>> buildEffectiveAliasInScopeMap(
19664            TSelectSqlStatement consumer,
19665            NameBindingProvider consumerProvider,
19666            Map<String, List<String>> ctePublishedColumns,
19667            Map<String, Integer> subqueryAliasToIndex,
19668            List<StatementGraph> stmts) {
19669        Map<String, List<String>> result = new HashMap<>();
19670        if (consumer.joins == null) return result;
19671        for (TJoin join : consumer.joins) {
19672            addRelationToInScopeMap(join.getTable(), consumerProvider,
19673                    ctePublishedColumns, subqueryAliasToIndex, stmts, result);
19674            TJoinItemList items = join.getJoinItems();
19675            if (items == null) continue;
19676            for (int i = 0; i < items.size(); i++) {
19677                TJoinItem item = items.getJoinItem(i);
19678                if (item == null) continue;
19679                addRelationToInScopeMap(item.getTable(), consumerProvider,
19680                        ctePublishedColumns, subqueryAliasToIndex, stmts, result);
19681            }
19682        }
19683        return result;
19684    }
19685
19686    private static void addRelationToInScopeMap(
19687            TTable t,
19688            NameBindingProvider consumerProvider,
19689            Map<String, List<String>> ctePublishedColumns,
19690            Map<String, Integer> subqueryAliasToIndex,
19691            List<StatementGraph> stmts,
19692            Map<String, List<String>> result) {
19693        if (t == null) return;
19694        // Slice 137 — a PIVOT / UNPIVOT source can itself be a FROM-subquery
19695        // (or CTE): `FROM (SELECT ...) [alias] PIVOT(...)`. Unwrap the
19696        // pivoted_table to its source relation so the source's published
19697        // columns are registered under the SAME effective alias that
19698        // lookupRelationColumnNames resolves in expandPivot/UnpivotBareStar.
19699        // The source-subquery alias is already registered in
19700        // subqueryAliasToIndex by the slice-136 processDirectSubqueryTable
19701        // pre-pass (which performs the same unwrap), so the subquery branch
19702        // below finds its statement index. A base-table pivot source falls
19703        // through unchanged: bindRelation returns TABLE → no in-scope entry,
19704        // and the slice-133 catalog path (getRelationColumnNames) handles it.
19705        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
19706                && t.getPivotedTable() != null
19707                && !t.getPivotedTable().getRelations().isEmpty()) {
19708            TTable src = t.getPivotedTable().getRelations().get(0);
19709            if (src == null) return;
19710            t = src;
19711        }
19712        String key = effectiveAliasLowerCaseOrNull(t);
19713        if (key == null) return;
19714        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
19715            Integer idx = subqueryAliasToIndex.get(key);
19716            if (idx != null) {
19717                result.put(key, outputColumnNames(stmts.get(idx)));
19718            }
19719            return;
19720        }
19721        // objectname (base-table OR CTE reference). Use bindRelation
19722        // to discriminate; base tables don't need an in-scope entry
19723        // (slice 58 catalog path handles them via getRelationColumnNames).
19724        RelationBinding b = consumerProvider.bindRelation(t);
19725        if (b == null) return;
19726        if (b.getKind() == RelationKind.CTE) {
19727            String cteName = t.getName();
19728            if (cteName == null) return;
19729            List<String> cols = ctePublishedColumns.get(cteName.toLowerCase(Locale.ROOT));
19730            if (cols != null && !cols.isEmpty()) {
19731                result.put(key, cols);
19732            }
19733        }
19734        // For TABLE, OUTER_REFERENCE, UNION, UNKNOWN bindings the
19735        // in-scope map is intentionally not populated; the
19736        // base-table catalog path or rejection path applies.
19737    }
19738
19739    /**
19740     * Build the {@link OutputColumn} list. Slice 4 lifts the
19741     * simple-object-name / single-source restriction: any expression with at
19742     * least one column reference is accepted, and the column is marked
19743     * {@link OutputColumn#isDerived()} when the expression is anything
19744     * other than a direct column reference. Slice 61 also admits
19745     * canonical constant-only projections (zero column refs) outside
19746     * scalar-subquery bodies, using alias-or-expression text naming.
19747     */
19748    private static List<OutputColumn> buildOutputColumns(TSelectSqlStatement select,
19749                                                         NameBindingProvider provider,
19750                                                         boolean allowScalarProjectionSubqueries,
19751                                                         boolean allowWindowProjection,
19752                                                         boolean isPredicateBody,
19753                                                         String stmtName) {
19754        TResultColumnList rcl = select.getResultColumnList();
19755        if (rcl == null || rcl.size() == 0) {
19756            throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS, "SELECT has no projected columns", select));
19757        }
19758        // Slice 23/24/27: predicate-body short-circuit. The preflight
19759        // (§4.4 / slice-24 §4.1.1 / slice-27 §4.1) already validated that
19760        // the inner SELECT projects exactly one column, of an admitted
19761        // shape — constant (slice 23), simple column ref (slice 24), or
19762        // expression / function call / CASE / aggregate over inner
19763        // columns (slice 27). Discriminate on the shape:
19764        //
19765        // - Constant: bypass the regular result-column loop (which would
19766        //   reject empty-source non-aggregate projections via the
19767        //   "no column refs" guard at line ~4397) and emit one synthetic
19768        //   OutputColumn with empty sources. The synthesised name
19769        //   `<predicate_subquery_<i>>_const_0` guarantees no collision
19770        //   with real column names.
19771        //
19772        // - Slice-24 column ref (simple_object_name_t with name): fall
19773        //   through to the normal loop; effectiveOutputName(rc) returns
19774        //   the column name. OutputColumn carries name, derived=false,
19775        //   aggregate=false, sources=[ColumnRef(...)].
19776        //
19777        // - Slice-27 expression / function / CASE / aggregate without
19778        //   alias: synthesise the OutputColumn here. The normal loop's
19779        //   {@link #effectiveOutputName} would throw on rc with neither
19780        //   alias nor column name (a slice-6 invariant for OUTER
19781        //   projections); for predicate bodies the OutputColumn name is
19782        //   internal scaffolding only — no consumer references it
19783        //   externally — so a synthetic name is sound. For aggregate-over-
19784        //   constants (COUNT(*), SUM(1)) sources is empty and aggregate=true
19785        //   matches the line-4397 guard's intent. The slice-24 projector
19786        //   pass walks OutputColumn.sources to base-column terminals and
19787        //   emits JOIN canonical edges (zero terminals → zero edges,
19788        //   multi-source → multiple edges).
19789        if (isPredicateBody) {
19790            TResultColumn rc0 = rcl.getResultColumn(0);
19791            if (rc0.getExpr() != null && isConstantExpression(rc0.getExpr())) {
19792                String synthName = (stmtName != null ? stmtName : "<predicate_subquery_?>")
19793                        + "_const_0";
19794                return Collections.singletonList(new OutputColumn(
19795                        synthName, /*derived=*/ true, /*aggregate=*/ false,
19796                        Collections.<ColumnRef>emptyList(), /*windowSpec=*/ null));
19797            }
19798            // Slice 27 + Slice 32: synthesise the OutputColumn for any
19799            // slice-27/31-admitted predicate-body projection EXCEPT the
19800            // slice-24 simple_object_name_t shape. Slice 27 fired this
19801            // branch only when both alias AND columnNameOnly were absent
19802            // (missingName=true); slice 32 widens it to also fire when
19803            // alias is present, so aliased Oracle / MSSQL plain
19804            // {@code LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY ...) AS lst}
19805            // is admitted (the slice-31 boundary lifted by slice 32).
19806            //
19807            // The simple_object_name_t exclusion is intentional. That
19808            // shape MUST keep falling through to the normal loop, where
19809            // {@link #effectiveOutputName} returns the column name (or
19810            // alias if present), {@code derived=false}, and
19811            // {@code sources=[ColumnRef(...)]} — the slice-24 baseline.
19812            // Per {@code TResultColumn.getColumnNameOnly()}, only
19813            // {@code simple_object_name_t}, {@code typecast_t}, and
19814            // {@code sqlserver_proprietary_column_alias_t} populate
19815            // columnNameOnly; function_t / case_t / pure-binary all
19816            // return empty, so the cascade below is alias > _proj_0
19817            // (no columnNameOnly intermediate).
19818            if (rc0.getExpr() != null
19819                    && rc0.getExpr().getExpressionType() != EExpressionType.simple_object_name_t) {
19820                String alias = rc0.getColumnAlias();
19821                String name;
19822                if (alias != null && !alias.isEmpty()) {
19823                    // Slice 32 widening: aliased projection. Use the
19824                    // alias as the OutputColumn name.
19825                    name = alias;
19826                } else {
19827                    // Slice 27 carryover: unaliased non-column-ref
19828                    // projection. Synthesise a stable name. The synth
19829                    // name is used internally by
19830                    // {@link gudusoft.gsqlparser.ir.semantic.diff.SemanticIRProjector}
19831                    // (line ~161 — BFS start key keyed by
19832                    // {@code stmtOutputKey(idx, out.getName())}) to walk
19833                    // predicate-body lineage to base columns; uniqueness
19834                    // within the single-column predicate body is
19835                    // sufficient for that walk. {@code _proj_0} is also
19836                    // exposed by the JSON exporter but is not externally
19837                    // referenced by callers — only the inner JOIN
19838                    // canonical edges (target.column omitted; role=JOIN)
19839                    // are visible to consumers.
19840                    name = (stmtName != null ? stmtName : "<predicate_subquery_?>")
19841                            + "_proj_0";
19842                }
19843                boolean aggregate = isAggregateFunction(rc0.getExpr());
19844                // Slice 43 / 44: PG (slice 43) and Snowflake (slice 44)
19845                // hypothetical-set ordered-set aggregates ({@code rank} /
19846                // {@code dense_rank} / {@code percent_rank} /
19847                // {@code cume_dist}) via direct {@code fn.getWithinGroup()}
19848                // attachment do not satisfy
19849                // {@link #isHypotheticalSetWithinGroupCall} (which requires
19850                // a non-null windowDef) and are not in the regular
19851                // {@link #AGGREGATE_FUNCTION_NAMES} whitelist. Inside the
19852                // predicate-body branch they are admitted as aggregates
19853                // when the slice-43 / 44 vendor-gated shape predicate
19854                // fires — contained here (NOT folded into
19855                // {@code isAggregateFunction}) so the carve-out cannot
19856                // accidentally lift the top-level PG / Snowflake case
19857                // (whose dlineage XML is structurally identical to the
19858                // OVER form — see Slice43Test / Slice44Test javadoc).
19859                if (!aggregate
19860                        && rc0.getExpr().getExpressionType() == EExpressionType.function_t
19861                        && isDirectAttachmentHypotheticalSetCall(
19862                                rc0.getExpr().getFunctionCall(), select.dbvendor)) {
19863                    aggregate = true;
19864                }
19865                // Slice 28: FILTER-aware collector excludes column refs inside
19866                // FILTER (WHERE ...) subtrees so OutputColumn.sources matches
19867                // dlineage's lineage-relationship view (FILTER predicate refs
19868                // absent from fdd / fdr).
19869                // Slice 31: also excludes column refs inside Oracle / MSSQL
19870                // {@code fn.windowDef.withinGroup} (the WITHIN GROUP ORDER BY)
19871                // so plain {@code LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY x.region)}
19872                // emits sources=[x.id] only — matching dlineage's omission of the
19873                // WITHIN GROUP ORDER BY ref from {@code fdr clause="on"} sources
19874                // (probe Q1 in {@code /tmp/probe31}). Slice 32 reuses the
19875                // same collector unchanged.
19876                List<ColumnRef> sources = collectColumnRefsExcludingFilterAndWithinGroupClauses(rc0, provider);
19877                if (sources.isEmpty() && !aggregate) {
19878                    // Non-aggregate with no inner column refs: should be
19879                    // covered by the constant short-circuit above. If we
19880                    // reach here, fall through to the normal loop's
19881                    // line-4397 guard for a conservative tuned message.
19882                } else {
19883                    return Collections.singletonList(new OutputColumn(
19884                            name, /*derived=*/ true, aggregate,
19885                            sources, /*windowSpec=*/ null));
19886                }
19887            }
19888            // simple_object_name_t falls through (slice-24 carryover):
19889            // the normal loop produces derived=false /
19890            // sources=[ColumnRef(...)] using effectiveOutputName.
19891        }
19892        // Slice 19 (alias-bound PARTITION BY discriminator): the resolver
19893        // synthesises EXACT_MATCH bindings for PARTITION BY <name> when no
19894        // schema metadata is available (TableNamespace.resolveColumn
19895        // inferred_from_usage fallback), even when <name> is a SELECT-list
19896        // alias on a calculated expression. The discriminator is exposed
19897        // by NameBindingProvider#isCalculatedProjectionAliasFallback and
19898        // consulted in buildWindowPartitionRefs / buildWindowOrderRefs;
19899        // see Slice13Test#partitionByExpressionAliasIsRejectedAsAliasBound
19900        // and the shadowing-with-metadata companion. Slice 19 prefers
19901        // conservative rejection in the no-metadata case; with TSQLEnv
19902        // declaring the shadowed column, ColumnSource#hasDefiniteEvidence
19903        // returns true and the discriminator falls through.
19904        List<OutputColumn> out = new ArrayList<>(rcl.size());
19905        for (int i = 0; i < rcl.size(); i++) {
19906            TResultColumn rc = rcl.getResultColumn(i);
19907            if (rc.getExpr() == null) {
19908                throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NULL_EXPRESSION, "result column " + rc + " has null expression", rc));
19909            }
19910            EExpressionType type = rc.getExpr().getExpressionType();
19911            // Slice 58: catalog-backed star expansion for a single base
19912            // table. Star projections were rejected by slices 1-57 with
19913            // "SELECT * / list expansions are deferred"; slice 58 lifts
19914            // the single-base-table case when a catalog is available via
19915            // NameBindingProvider#getRelationColumnNames(TTable). Bare
19916            // `*` and qualified `t.*` both arrive here as
19917            // simple_object_name_t with rc.getColumnNameOnly() == "*"
19918            // (probed; see slice-58 plan); the prior EExpressionType.list_t
19919            // branch is dead defense for stars in practice but stays in
19920            // case a future grammar variant routes them differently.
19921            String colNameOnly = rc.getColumnNameOnly();
19922            if ("*".equals(colNameOnly) || type == EExpressionType.list_t) {
19923                StarExpansionResult exp = tryExpandStar(rc, select, provider,
19924                        isPredicateBody, stmtName);
19925                switch (exp.kind) {
19926                    case EXPANDED:
19927                        out.addAll(exp.columns);
19928                        continue;
19929                    case PREDICATE_BODY_GUARD:
19930                        // Defensive; preflightExistsInnerShape at line ~3880
19931                        // rejects SELECT * in EXISTS earlier with a tuned
19932                        // message. This branch only fires if a future call
19933                        // site enters buildOutputColumns with isPredicateBody
19934                        // and a star still present.
19935                        throw new SemanticIRBuildException(
19936                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_PREDICATE_BODY,
19937                                "result column " + rc + " is a star expansion (SELECT *) "
19938                                        + "inside a predicate body; not supported yet", rc));
19939                    case SYNTHETIC_BODY_CONTEXT:
19940                        // Slice 59: star expansion is rejected inside a
19941                        // synthetic body (scalar-subquery / set-op-branch /
19942                        // predicate-subquery). Multi-column expansion would
19943                        // violate the body's shape contract; the slice-58
19944                        // path could silently produce this for
19945                        // catalog-equipped builds.
19946                        throw new SemanticIRBuildException(
19947                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_SYNTHETIC_BODY,
19948                                "result column " + rc + " is a star expansion (SELECT *); "
19949                                        + (exp.detail != null ? exp.detail
19950                                                : "star expansion is not supported inside a synthetic body"), rc));
19951                    case MULTI_RELATION_FROM: {
19952                        // GSP R6 degrade (star-expansion-multi-relation-from-
19953                        // degrade.md): tryExpandStar returns this kind when a
19954                        // FROM operand is a parenthesized / nested <joined_table>
19955                        // (TJoin.getTable() / TJoinItem.getTable() is null), so
19956                        // the single-relation expander cannot pick one relation.
19957                        // The parenthesized-join recursion (buildRelations /
19958                        // buildJoinGraph) already builds a complete multi-relation
19959                        // join graph for exactly this FROM shape, so failing here
19960                        // would discard a fully-analyzable program. DEGRADE just
19961                        // like the NO_CATALOG_OR_UNKNOWN_TABLE arm below: record a
19962                        // non-fatal warning, emit the star as an UNEXPANDED MARKER
19963                        // output column (no sources — never fabricate names), and
19964                        // continue. Synthetic bodies are caught earlier as
19965                        // SYNTHETIC_BODY_CONTEXT (set-op parity unaffected);
19966                        // catalog-present flat expansion still returns EXPANDED.
19967                        //
19968                        // Scope the degrade to the genuinely-analyzable nested-join
19969                        // shape: a null FROM table caused by a parenthesized
19970                        // <joined_table> operand, for which buildRelations /
19971                        // buildJoinGraph already built the graph. A
19972                        // MULTI_RELATION_FROM with NO nested operand (the
19973                        // defensive topJoin==null path, or a null FROM table with
19974                        // no nested join) is a broken invariant with no graph to
19975                        // preserve — keep it fatal rather than fabricating a
19976                        // valid-looking program with a sourceless marker.
19977                        if (!hasNestedJoinOperand(select)) {
19978                            throw new SemanticIRBuildException(
19979                                    Diagnostic.error(DiagnosticCode.STAR_EXPANSION_MULTI_RELATION_FROM,
19980                                    "result column " + rc + " is a star expansion (SELECT *); "
19981                                            + "FROM source could not be determined", rc));
19982                        }
19983                        recordBuildWarning(Diagnostic.warn(
19984                                DiagnosticCode.STAR_EXPANSION_MULTI_RELATION_FROM,
19985                                "result column " + rc + " is a star expansion (SELECT *) "
19986                                        + "over a parenthesized / nested join — left "
19987                                        + "unexpanded (catalog-less degrade)", rc));
19988                        String mrStarQualifier = null;
19989                        if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) {
19990                            mrStarQualifier = rc.getExpr().getObjectOperand().getTableString();
19991                        }
19992                        String mrStarName = (mrStarQualifier != null && !mrStarQualifier.isEmpty())
19993                                ? mrStarQualifier + ".*" : "*";
19994                        out.add(new OutputColumn(mrStarName, /*derived=*/ false,
19995                                /*aggregate=*/ false,
19996                                Collections.<ColumnRef>emptyList(),
19997                                /*windowSpec=*/ null));
19998                        continue;
19999                    }
20000                    case NON_BASE_TABLE_RELATION:
20001                        throw new SemanticIRBuildException(
20002                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_NON_BASE_TABLE,
20003                                "result column " + rc + " is a star expansion (SELECT *); "
20004                                        + (exp.detail != null ? exp.detail
20005                                                : "FROM source must be a base table"), rc));
20006                    case QUALIFIER_NOT_FOUND:
20007                        throw new SemanticIRBuildException(
20008                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_QUALIFIER_NOT_FOUND,
20009                                "result column " + rc + " (qualified star "
20010                                        + (exp.qualifier == null ? "?" : exp.qualifier)
20011                                        + ".*) does not match any FROM-clause relation", rc));
20012                    case QUALIFIER_AMBIGUOUS:
20013                        // Slice 59: 2+ FROM relations have the same
20014                        // effective alias. Real SQL never reaches this
20015                        // unless rejectDuplicateAliases:~5621 (case-
20016                        // sensitive) allowed a case-only collision.
20017                        throw new SemanticIRBuildException(
20018                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_QUALIFIER_AMBIGUOUS,
20019                                "result column " + rc + " (qualified star "
20020                                        + (exp.qualifier == null ? "?" : exp.qualifier)
20021                                        + ".*) is ambiguous: "
20022                                        + (exp.detail != null ? exp.detail
20023                                                : "multiple FROM-clause relations match"), rc));
20024                    case NO_CATALOG_OR_UNKNOWN_TABLE: {
20025                        // GSP R6 degrade (star-expansion-no-catalog-degrade.md):
20026                        // a catalog-less SELECT * / t.* cannot be expanded to
20027                        // real column names, but the join structure is fixed by
20028                        // the FROM / ON clauses and does NOT depend on star
20029                        // expansion. Rather than discarding the whole statement
20030                        // graph (relations + join graph + scope), DEGRADE:
20031                        // record a non-fatal warning and emit the star as an
20032                        // UNEXPANDED MARKER output column (no sources — never
20033                        // fabricate column names), then continue the build.
20034                        //
20035                        // Catalog-present expansion is unaffected (it returns
20036                        // EXPANDED above). Synthetic bodies (scalar / set-op
20037                        // branch / predicate) are caught earlier as
20038                        // SYNTHETIC_BODY_CONTEXT and stay fatal, so set-op
20039                        // column-count parity is never relaxed by this degrade.
20040                        // Mirrors the NATURAL (NATURAL_CATALOG_REQUIRED) and
20041                        // USING (COLUMN_BINDING_NON_EXACT) catalog-less degrades.
20042                        String relationLabel;
20043                        if (exp.qualifier != null && !exp.qualifier.isEmpty()) {
20044                            relationLabel = exp.qualifier;
20045                        } else if (exp.detail != null && !exp.detail.isEmpty()) {
20046                            relationLabel = exp.detail;
20047                        } else {
20048                            relationLabel = "the FROM relation";
20049                        }
20050                        recordBuildWarning(Diagnostic.warn(
20051                                DiagnosticCode.STAR_EXPANSION_NO_CATALOG,
20052                                "result column " + rc + " is a star expansion (SELECT *); "
20053                                        + "requires catalog with column declarations for "
20054                                        + relationLabel
20055                                        + " — left unexpanded (catalog-less degrade)", rc));
20056                        // Unexpanded-star marker: the star's own text (`*` or
20057                        // `t.*`), no sources. Derived=false so the empty-source
20058                        // RESULT_COLUMN_NO_COLUMN_REFS guard does not apply
20059                        // (that guard targets derived no-ref expressions).
20060                        String starQualifier = null;
20061                        if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) {
20062                            starQualifier = rc.getExpr().getObjectOperand().getTableString();
20063                        }
20064                        String starName = (starQualifier != null && !starQualifier.isEmpty())
20065                                ? starQualifier + ".*" : "*";
20066                        out.add(new OutputColumn(starName, /*derived=*/ false,
20067                                /*aggregate=*/ false,
20068                                Collections.<ColumnRef>emptyList(),
20069                                /*windowSpec=*/ null));
20070                        continue;
20071                    }
20072                    case EXPLICIT_CTE_COLUMN_LIST_DEFERRED:
20073                        // Slice 103 lifted the explicit-CTE-column-list
20074                        // deferral: the SELECT-side CTE walker now runs
20075                        // the slice-102 rename helper, so the in-scope
20076                        // map publishes the renamed columns and
20077                        // expandSingleRelation returns EXPANDED instead
20078                        // of falling into this arm. The case is kept
20079                        // declared-but-unreached for API stability and
20080                        // exhaustive-switch coverage (slice 71/72/82/86
20081                        // /95/96/97/98/99/100/101/102 precedent). If a
20082                        // future call path re-introduces the kind, the
20083                        // throw still fires with a faithful diagnostic.
20084                        throw new SemanticIRBuildException(
20085                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_EXPLICIT_CTE_COLUMN_LIST,
20086                                "result column " + rc + " is a star expansion (SELECT *); "
20087                                        + (exp.detail != null ? exp.detail
20088                                                : "star expansion through an explicit CTE column list is deferred to a future slice"), rc));
20089                    case NO_INSCOPE_RELATION_COLUMNS:
20090                        // Slice 60: builder invariant failure — a CTE
20091                        // or FROM-subquery body was not registered in
20092                        // the provider's in-scope-relation-columns map
20093                        // before this consumer ran. User SQL cannot
20094                        // reach this kind under normal build()
20095                        // execution; reaching it indicates a missing
20096                        // call site is not narrowing the provider
20097                        // before invoking buildOutputColumns.
20098                        throw new SemanticIRBuildException(
20099                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_NO_INSCOPE_RELATION_COLUMNS,
20100                                "result column " + rc + " is a star expansion (SELECT *); "
20101                                        + (exp.detail != null ? exp.detail
20102                                                : "in-scope CTE/subquery column map is empty for this relation (builder invariant failure)"), rc));
20103                    // No `default`: switch is intentionally exhaustive
20104                    // over StarExpansionKind. The post-switch throw
20105                    // below is the actual runtime guard if a future
20106                    // enum value is added without updating this
20107                    // switch.
20108                }
20109                throw new SemanticIRBuildException(
20110                        Diagnostic.error(DiagnosticCode.STAR_EXPANSION_UNHANDLED_KIND,
20111                        "result column " + rc + " is a star expansion (SELECT *); "
20112                                + "unhandled StarExpansionKind=" + exp.kind, rc));
20113            }
20114            // Top-level scalar subquery in projection (slice 11). When the
20115            // caller permits it (allowScalarProjectionSubqueries=true), the
20116            // outer caller has already extracted the inner SELECT as its
20117            // own statement via extractScalarSubqueriesAsStatements; here
20118            // we just construct the OutputColumn shell with empty sources
20119            // and let emitLineageForStatement wire the
20120            // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge.
20121            if (type == EExpressionType.subquery_t) {
20122                if (!allowScalarProjectionSubqueries) {
20123                    throw new SemanticIRBuildException(
20124                            Diagnostic.error(DiagnosticCode.NESTED_SCALAR_SUBQUERY_IN_PROJECTION,
20125                            "nested scalar subquery in projection (inside another "
20126                                    + "scalar subquery body or FROM-clause subquery body) "
20127                                    + "is not supported yet", rc));
20128                }
20129                String alias = rc.getColumnAlias();
20130                if (alias == null || alias.isEmpty()) {
20131                    throw new SemanticIRBuildException(
20132                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_ALIAS_REQUIRED,
20133                            "scalar subquery projection must have an alias", rc));
20134                }
20135                out.add(new OutputColumn(alias, /*derived=*/ true,
20136                        /*aggregate=*/ false,
20137                        Collections.<ColumnRef>emptyList(),
20138                        /*windowSpec=*/ null));
20139                continue;
20140            }
20141            // Slice 13: detect top-level window function before deep scans
20142            // so the embedded-window rejecter can identity-skip the
20143            // legitimate top-level window function call.
20144            boolean topLevelWindow = isTopLevelWindowProjection(rc.getExpr());
20145            // Slice 33: detect Oracle / MSSQL plain WITHIN-GROUP-only
20146            // aggregate at the projection root. When admitted, the root
20147            // function carries fn.windowDef!=null but is the legitimate
20148            // top-level form — the slice-13 invariant rejecters
20149            // (isTopLevelWindowProjection / rejectWindowFunctions /
20150            // rejectEmbeddedWindowFunction) keep their strict wd!=null
20151            // check unchanged; this local boolean is what discriminates
20152            // them. The admission helper combines:
20153            //   - isWithinGroupOnlyWindowDef (no OVER, no KEEP DENSE_RANK)
20154            //   - explicit EDbVendor gate (Oracle / MSSQL only — mirrors
20155            //     the slice-31 predicate-body gate at line ~3860)
20156            //   - function name in AGGREGATE_FUNCTION_NAMES whitelist
20157            // PG / Snowflake / DB2 / SparkSQL produce direct fn.withinGroup
20158            // (windowDef=null) and never reach this admission helper; their
20159            // top-level WG already builds today via the normal aggregate
20160            // path (with pre-existing AGGREGATION_MISMATCH divergence on
20161            // the dlineage projector side that slice 33 deliberately does
20162            // not address — see the slice-30 rationale on
20163            // ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES for why a name-only
20164            // projector override is unsafe across the dual-form aggregates
20165            // SUM / MIN / MAX / LISTAGG that have OVER (PARTITION BY)
20166            // forms on Oracle).
20167            TFunctionCall slice33RootFn = rc.getExpr().getExpressionType() == EExpressionType.function_t
20168                    ? rc.getExpr().getFunctionCall()
20169                    : null;
20170            boolean slice33TopLevelWG = isAdmittedTopLevelWithinGroupAggregate(
20171                    slice33RootFn, select.dbvendor);
20172            boolean slice35TopLevelDirectWG = isAdmittedTopLevelDirectWithinGroupAggregate(
20173                    slice33RootFn, select.dbvendor);
20174            // Reject scalar subqueries embedded inside larger projection
20175            // expressions (slice 11 + codex round-2 MUST 7). Catches both
20176            // top-level subquery_t hidden under a wrapping expression
20177            // (e.g. UPPER((SELECT ...)) — though the parser sometimes
20178            // strips the wrap) AND predicate subqueries that don't surface
20179            // as subquery_t (EXISTS in projection, IN-projection).
20180            // Slice 9/10 deep-scan pattern.
20181            rejectEmbeddedSubqueryInProjection(rc.getExpr(), rc);
20182            // Slice 13: reject window functions embedded inside larger
20183            // projection expressions (e.g. `ROW_NUMBER() OVER (...) + 1`,
20184            // `UPPER(LAG(...) OVER (...))`). The helper identity-skips
20185            // the legitimate top-level window function call when
20186            // `topLevelWindow=true`.
20187            //
20188            // Slice 33: also identity-skip the top-level WITHIN-GROUP-only
20189            // aggregate root. TFunctionCall.acceptChildren preVisits the
20190            // root function (TFunctionCall.java:1528), so without
20191            // skipTopLevel=true the visitor would catch the slice-33-
20192            // admitted root (fn.windowDef!=null). Embedded WG inside
20193            // UPPER / CASE still rejects because the visitor finds a
20194            // non-root function whose windowDef!=null — the inner
20195            // function is not == identity to the root, so the skip
20196            // doesn't apply.
20197            rejectEmbeddedWindowFunction(rc.getExpr(), rc, topLevelWindow || slice33TopLevelWG);
20198            // Slice 33/35 fast path: WITHIN-GROUP-only aggregate — fall
20199            // through to the normal aggregate path. Oracle / MSSQL use the
20200            // windowDef attachment (slice 33); PostgreSQL direct attachment
20201            // is already on the normal aggregate path but shares the
20202            // unaliased expression-text fallback below (slice 35).
20203            if (slice33TopLevelWG || slice35TopLevelDirectWG) {
20204                // No special branch — fall through to the plain aggregate
20205                // / expression / column path below.
20206            } else if (topLevelWindow) {
20207                if (!allowWindowProjection) {
20208                    throw new SemanticIRBuildException(
20209                            Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_AS_PROJECTION_NOT_SUPPORTED,
20210                            "result column " + rc + " is a window function; not supported "
20211                                    + "inside this body (e.g. scalar-subquery body)", rc));
20212                }
20213                out.add(buildWindowOutputColumn(rc, select, provider));
20214                continue;
20215            }
20216            // Plain aggregate / expression / column path. The
20217            // rejectWindowFunctions call below is now defensive — the
20218            // top-level-window fast path above intercepts legitimate
20219            // windows, and rejectEmbeddedWindowFunction caught any
20220            // descendant window functions.
20221            //
20222            // Slice 33: skip rejectWindowFunctions for the slice-33-
20223            // admitted shape. The root function has windowDef!=null but
20224            // is the legitimate top-level form; calling
20225            // rejectWindowFunctions here would reject it via the
20226            // strict-wd!=null check (kept unchanged per slice-31
20227            // invariant).
20228            if (!slice33TopLevelWG && !slice35TopLevelDirectWG) {
20229                rejectWindowFunctions(rc.getExpr(), rc);
20230            }
20231            boolean derived = (type != EExpressionType.simple_object_name_t);
20232            boolean aggregate = isAggregateFunction(rc.getExpr());
20233            // Slice 28: FILTER-aware collector excludes column refs inside
20234            // FILTER (WHERE ...) subtrees so OutputColumn.sources matches
20235            // dlineage's lineage-relationship view (FILTER predicate refs
20236            // absent from fdd / fdr).
20237            // Slice 31: also excludes column refs inside Oracle / MSSQL
20238            // {@code fn.windowDef.withinGroup} so plain WITHIN GROUP
20239            // aggregates emit sources from function args only. Defense-
20240            // in-depth here: the slice-31 lift only admits Oracle / MSSQL
20241            // plain WITHIN GROUP at the unaliased predicate-body
20242            // short-circuit (line ~5216) — the strict
20243            // {@link #rejectWindowFunctions} call above keeps top-level
20244            // windowDef-bearing projections rejected outside the
20245            // predicate-body context, so this collector reduces to the
20246            // slice-28 FILTER-only variant in practice today.
20247            List<ColumnRef> sources = collectColumnRefsExcludingFilterAndWithinGroupClauses(rc, provider);
20248            if (sources.isEmpty() && !aggregate) {
20249                boolean canonicalConstant = isConstantExpression(rc.getExpr());
20250                boolean inScalarBody = isScalarSyntheticName(stmtName);
20251                if (canonicalConstant && !inScalarBody) {
20252                    // Slice 61: constant-only projection lift. Predicate
20253                    // bodies still use the earlier slice-23 short-circuit,
20254                    // while scalar-subquery bodies intentionally keep the
20255                    // slice-11/20 invariant that scalar body projections
20256                    // must have a column source.
20257                    String alias = rc.getColumnAlias();
20258                    String name = (alias != null && !alias.isEmpty())
20259                            ? alias
20260                            : rc.getExpr().toString();
20261                    out.add(new OutputColumn(name, /*derived=*/ true,
20262                            /*aggregate=*/ false,
20263                            Collections.<ColumnRef>emptyList(),
20264                            /*windowSpec=*/ null));
20265                    continue;
20266                }
20267                // Join-structure degrade (GSP R6): a plain unqualified column
20268                // that could not be bound under a fully-built join graph (its
20269                // COLUMN_BINDING_NON_EXACT was downgraded to a warning by
20270                // rejectNonExactBindings) is emitted with no sources rather
20271                // than aborting the whole analysis. The join structure is
20272                // intact; only this column's source side is unknown without a
20273                // catalog (honest: not fabricated). Both anchors qualify:
20274                //   - a JOIN ... USING merged-key scope, OR
20275                //   - an explicit ON / CROSS / comma join graph
20276                //     (hasJoinStructureAnchor()).
20277                // A qualified miss never reaches here — it already aborted
20278                // fatally in the column collector (rejectNonExactBindings with
20279                // allRejectsUnqualified=false). Derived expressions
20280                // (e.g. UPPER('lit')) keep the !derived guard and stay fatal,
20281                // as do scalar bodies (they must carry a column source).
20282                if (!derived && !inScalarBody
20283                        && (provider.getUsingScope().hasMergedKeys()
20284                                || provider.hasJoinStructureAnchor())) {
20285                    String alias = rc.getColumnAlias();
20286                    String name = (alias != null && !alias.isEmpty())
20287                            ? alias
20288                            : rc.getExpr().toString();
20289                    out.add(new OutputColumn(name, /*derived=*/ false,
20290                            /*aggregate=*/ false,
20291                            Collections.<ColumnRef>emptyList(),
20292                            /*windowSpec=*/ null));
20293                    continue;
20294                }
20295                throw new SemanticIRBuildException(
20296                        Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NO_COLUMN_REFS,
20297                        "result column " + rc + " has no column references "
20298                                + "and is not a constant or aggregate expression "
20299                                + "(e.g. UPPER('literal') / CAST / current_date - not supported yet)", rc));
20300            }
20301            // Slice 34: when the slice-33-admitted top-level Oracle / MSSQL
20302            // WITHIN-GROUP-only aggregate has no alias, fall back to the
20303            // parser's expression text. {@code effectiveOutputName} would
20304            // throw "neither alias nor column name" because
20305            // {@code function_t} returns "" from getColumnNameOnly().
20306            // Probe-verified that {@code rc.getExpr().toString()} byte-
20307            // matches dlineage's <select_list> column name attribute on
20308            // Oracle / MSSQL for this shape, so canonical SELECT-edge
20309            // outputName remains in parity with no projector change.
20310            // Gated tightly on slice33TopLevelWG so unrelated unaliased
20311            // shapes (function calls / CASE / expressions outside the
20312            // slice-33 admit set) keep failing loudly via
20313            // effectiveOutputName until each is probed and admitted
20314            // explicitly. See Slice34Test.
20315            String name;
20316            if (slice33TopLevelWG || slice35TopLevelDirectWG) {
20317                String alias = rc.getColumnAlias();
20318                name = (alias != null && !alias.isEmpty())
20319                        ? alias
20320                        : rc.getExpr().toString();
20321            } else {
20322                name = effectiveOutputName(rc);
20323            }
20324            out.add(new OutputColumn(name, derived, aggregate, sources, /*windowSpec=*/ null));
20325        }
20326        return out;
20327    }
20328
20329    /**
20330     * Reject scalar subqueries embedded inside larger projection
20331     * expressions (slice 11). Catches:
20332     *
20333     * <ul>
20334     *   <li>{@code SELECT UPPER((SELECT MAX(salary) AS m FROM employees))
20335     *       AS x FROM ...} — scalar nested inside a function call.</li>
20336     *   <li>{@code SELECT EXISTS (SELECT 1 FROM employees) AS has_emp
20337     *       FROM ...} — EXISTS doesn't surface as
20338     *       {@link EExpressionType#subquery_t} but carries
20339     *       {@code getSubQuery() != null} (slice-9 round-3 lesson).</li>
20340     *   <li>Other in-expression subqueries that
20341     *       {@link #collectColumnRefs} would otherwise descend into.</li>
20342     * </ul>
20343     *
20344     * <p>Only top-level {@code subquery_t} projections are extracted as
20345     * separate statements (handled in
20346     * {@link #extractScalarSubqueriesAsStatements}); embedded subqueries
20347     * remain rejected because the IR doesn't yet model the "expression
20348     * over subquery result" shape.
20349     */
20350    private static void rejectEmbeddedSubqueryInProjection(TExpression expr, TResultColumn rc) {
20351        if (expr == null) return;
20352        final boolean[] found = {false};
20353        expr.acceptChildren(new TParseTreeVisitor() {
20354            @Override
20355            public void preVisit(TExpression e) {
20356                if (found[0]) return;
20357                if (e.getExpressionType() == EExpressionType.subquery_t
20358                        || e.getSubQuery() != null) {
20359                    found[0] = true;
20360                }
20361            }
20362        });
20363        if (!found[0]) {
20364            // Top-level expression itself may carry a subquery (e.g. EXISTS
20365            // at the projection root, where rc.getExpr() is exists_t with
20366            // non-null getSubQuery() but is NOT subquery_t — so the
20367            // top-level subquery_t branch above didn't extract it).
20368            if (expr.getExpressionType() != EExpressionType.subquery_t
20369                    && expr.getSubQuery() != null) {
20370                found[0] = true;
20371            }
20372        }
20373        if (found[0]) {
20374            throw new SemanticIRBuildException(
20375                    Diagnostic.error(DiagnosticCode.RESULT_COLUMN_SCALAR_SUBQUERY_EMBEDDED,
20376                    "result column " + rc + " contains a scalar subquery embedded "
20377                            + "in a larger projection expression; not supported yet "
20378                            + "(only top-level scalar subquery projections are extracted)", rc));
20379        }
20380    }
20381
20382    /**
20383     * Detect whether an expression contains an aggregate function call
20384     * anywhere in its subtree. Slice 6 uses a name whitelist via
20385     * {@link #AGGREGATE_FUNCTION_NAMES}. Walking recursively means
20386     * {@code SUM(salary) + 1} and {@code COUNT(*) + 1} are both classified
20387     * as aggregate (and thus permitted with empty sources via
20388     * {@link #buildOutputColumns}). Wrapped in a helper so slice 7+ can
20389     * swap in deeper detection (e.g. vendor-specific function classification
20390     * on TFunctionCall) without touching call sites.
20391     *
20392     * <p>Note on aggregate literals like {@code COUNT(1)} or {@code SUM(1)}:
20393     * the visitor finds no column refs, so {@code sources=[]}. Slice 6
20394     * permits these as aggregates with no lineage edges; consumers must
20395     * read {@link OutputColumn#isAggregate()} to know the value is
20396     * row-collapsing without column lineage.
20397     */
20398    private static boolean isAggregateFunction(TExpression expr) {
20399        if (expr == null) return false;
20400        // Slice 13: short-circuit for top-level window function. The
20401        // upstream `rejectEmbeddedWindowFunction` has already rejected any
20402        // embedded window functions, but this short-circuit ensures
20403        // `AVG(salary) OVER (...)` is never classified as an aggregate
20404        // even if it somehow slips past the upstream guard.
20405        //
20406        // Slice 31: discriminate WITHIN-GROUP-only windowDef (Oracle /
20407        // MSSQL plain WITHIN GROUP attachment without OVER) so
20408        // `LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY x.region)` stays
20409        // classified as an aggregate. Uses {@link #isWindowDefBearingFunction}
20410        // — only this check and {@link #containsWindowFunction} are
20411        // lifted; every other slice-13 invariant rejecter is unchanged.
20412        if (expr.getExpressionType() == EExpressionType.function_t) {
20413            TFunctionCall rootFn = expr.getFunctionCall();
20414            // Slice 42: hypothetical-set ordered-set aggregate root
20415            // (Oracle / MSSQL {@code RANK(100) WITHIN GROUP (ORDER BY x)})
20416            // — short-circuit aggregate=true. The shape predicate
20417            // {@link #isHypotheticalSetWithinGroupCall} requires WITHIN-
20418            // GROUP-only windowDef AND a name in
20419            // {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES}, so PG direct
20420            // attachment ({@code fn.getWindowDef()==null}) and OVER-
20421            // bearing forms cannot fire it.
20422            if (isHypotheticalSetWithinGroupCall(rootFn)) {
20423                return true;
20424            }
20425            if (isWindowDefBearingFunction(rootFn)) {
20426                return false;
20427            }
20428        }
20429        final boolean[] found = {false};
20430        expr.acceptChildren(new TParseTreeVisitor() {
20431            @Override
20432            public void preVisit(TFunctionCall fn) {
20433                if (found[0]) return;
20434                // Slice 13 codex round-2 SHOULD 3: skip windowed function
20435                // calls inside the visitor too, defensively. Upstream
20436                // rejection should already have fired, but this removes
20437                // overlap risk for `sum/count/avg`.
20438                //
20439                // Slice 31: same WITHIN-GROUP-only carve-out as the root
20440                // short-circuit above so an Oracle / MSSQL plain WITHIN
20441                // GROUP aggregate nested inside CASE/UPPER (slice-27
20442                // admit) is still picked up as aggregate.
20443                //
20444                // Slice 42: hypothetical-set ordered-set aggregate carve-
20445                // out — descendants matching the shape predicate count
20446                // as aggregate (defense-in-depth; the slice-13 embedded-
20447                // window rejecter already fires on inner WG-bearing
20448                // calls, so this branch is mostly unreachable today).
20449                if (isHypotheticalSetWithinGroupCall(fn)) {
20450                    found[0] = true;
20451                    return;
20452                }
20453                if (isWindowDefBearingFunction(fn)) return;
20454                if (fn.getFunctionName() == null) return;
20455                String name = fn.getFunctionName().toString();
20456                if (name == null || name.isEmpty()) return;
20457                if (AGGREGATE_FUNCTION_NAMES.contains(name.toLowerCase(Locale.ROOT))) {
20458                    found[0] = true;
20459                }
20460            }
20461        });
20462        // The root expression itself is not visited by acceptChildren — only
20463        // its children. If the root is the function call (the common case
20464        // for `SUM(salary)` with no enclosing arithmetic), check it too.
20465        if (!found[0] && expr.getExpressionType() == EExpressionType.function_t) {
20466            TFunctionCall fn = expr.getFunctionCall();
20467            if (fn != null && fn.getFunctionName() != null) {
20468                String name = fn.getFunctionName().toString();
20469                if (name != null && !name.isEmpty()
20470                        && AGGREGATE_FUNCTION_NAMES.contains(name.toLowerCase(Locale.ROOT))) {
20471                    found[0] = true;
20472                }
20473            }
20474        }
20475        return found[0];
20476    }
20477
20478    /**
20479     * Reject window-function projections like {@code AVG(salary) OVER (...)}.
20480     * In the GSP AST these still parse as {@code function_t} with a
20481     * non-null {@code TFunctionCall.getWindowDef()}, but their semantics
20482     * are row-preserving (analytic), not row-collapsing (aggregate). Slice
20483     * 6 owns plain GROUP BY aggregation only; window functions deserve
20484     * their own slice.
20485     */
20486    private static void rejectWindowFunctions(TExpression expr, TResultColumn rc) {
20487        if (expr == null) return;
20488        final boolean[] found = {false};
20489        expr.acceptChildren(new TParseTreeVisitor() {
20490            @Override
20491            public void preVisit(TFunctionCall fn) {
20492                if (found[0]) return;
20493                if (fn.getWindowDef() != null) found[0] = true;
20494            }
20495        });
20496        if (!found[0] && expr.getExpressionType() == EExpressionType.function_t) {
20497            TFunctionCall fn = expr.getFunctionCall();
20498            if (fn != null && fn.getWindowDef() != null) found[0] = true;
20499        }
20500        if (found[0]) {
20501            throw new SemanticIRBuildException(
20502                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_USED_NOT_SUPPORTED,
20503                    "result column " + rc + " uses a window function (OVER (...)); not supported yet", rc));
20504        }
20505    }
20506
20507    /**
20508     * Slice 13: detect whether the projection root is a top-level
20509     * window-function call. Returns {@code true} iff
20510     * {@code expr.getExpressionType() == function_t} AND the function
20511     * call carries a non-null {@code TWindowDef}. The result drives
20512     * three things in {@link #buildOutputColumns}:
20513     *
20514     * <ul>
20515     *   <li>The {@code skipTopLevel} arg to
20516     *       {@link #rejectEmbeddedWindowFunction} so the legitimate
20517     *       top-level window call is identity-skipped during embedded
20518     *       detection.</li>
20519     *   <li>The fast-path dispatch into
20520     *       {@link #buildWindowOutputColumn} when window projections
20521     *       are allowed.</li>
20522     *   <li>The scalar-body / future-context rejection when window
20523     *       projections are forbidden in the surrounding context
20524     *       (slice-13 {@code allowWindowProjection=false}).</li>
20525     * </ul>
20526     */
20527    private static boolean isTopLevelWindowProjection(TExpression expr) {
20528        if (expr == null) return false;
20529        if (expr.getExpressionType() != EExpressionType.function_t) return false;
20530        TFunctionCall fn = expr.getFunctionCall();
20531        return fn != null && fn.getWindowDef() != null;
20532    }
20533
20534    /**
20535     * Slice 13: reject window functions embedded inside a larger
20536     * projection expression (mirrors slice 11's
20537     * {@link #rejectEmbeddedSubqueryInProjection}). The {@code skipTopLevel}
20538     * flag is set when the caller has identified
20539     * {@code expr} as a legitimate top-level window-function projection
20540     * — without identity-skipping that exact {@code TFunctionCall} the
20541     * visitor would reject every valid top-level window.
20542     *
20543     * <p>Visitor-only (no post-visitor fallback): unlike
20544     * {@code subquery_t} which can be wrapped in expression types that
20545     * do not surface as {@code subquery_t}, a window function is always
20546     * reachable through {@code TExpression.acceptChildren} →
20547     * {@code TFunctionCall.preVisit}. Codex round-3 MUST 1.
20548     */
20549    private static void rejectEmbeddedWindowFunction(TExpression expr,
20550                                                     TResultColumn rc,
20551                                                     boolean skipTopLevel) {
20552        if (expr == null) return;
20553        final TFunctionCall topLevelFn =
20554                skipTopLevel
20555                        && expr.getExpressionType() == EExpressionType.function_t
20556                        ? expr.getFunctionCall()
20557                        : null;
20558        final boolean[] found = {false};
20559        expr.acceptChildren(new TParseTreeVisitor() {
20560            @Override
20561            public void preVisit(TFunctionCall fn) {
20562                if (found[0]) return;
20563                if (fn == topLevelFn) return;            // identity-skip
20564                if (fn.getWindowDef() != null) found[0] = true;
20565            }
20566        });
20567        if (found[0]) {
20568            throw new SemanticIRBuildException(
20569                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_EMBEDDED_NOT_SUPPORTED,
20570                    "result column " + rc + " contains a window function embedded "
20571                            + "in a larger projection expression; not supported yet "
20572                            + "(only top-level window-function projections are supported)", rc));
20573        }
20574    }
20575
20576    /**
20577     * Lower-cased function names accepted as window functions in slice 13.
20578     * Includes every name in {@link #AGGREGATE_FUNCTION_NAMES} (aggregates
20579     * can be windowed: {@code SUM(...) OVER (...)}, {@code AVG(...) OVER (...)},
20580     * etc.) plus the analytic-only names. New analytic functions must be
20581     * added here explicitly to avoid silent acceptance of an unfamiliar
20582     * window function whose semantics the slice does not yet model.
20583     *
20584     * <p>Slice 30 exception: {@code mode} is added to
20585     * {@link #AGGREGATE_FUNCTION_NAMES} for the WITHIN GROUP path but
20586     * REMOVED from this allowlist via {@code s.remove("mode")} below —
20587     * {@code mode()} has no documented window form in any GSP-supported
20588     * vendor and the explicit removal keeps {@code mode() OVER (...)}
20589     * (which the PostgreSQL parser accepts) rejected by
20590     * {@code buildWindowOutputColumn}.
20591     */
20592    private static final Set<String> WINDOW_FUNCTION_NAMES;
20593    static {
20594        Set<String> s = new HashSet<>();
20595        // Aggregate names that can be windowed.
20596        s.addAll(AGGREGATE_FUNCTION_NAMES);
20597        // Slice 30: mode is an ordered-set-only aggregate; remove from the
20598        // window allowlist (it was added to AGGREGATE_FUNCTION_NAMES for the
20599        // WITHIN GROUP path but never appears as a real window function in
20600        // any GSP-supported vendor — see Slice30Test.pgModeOverStillRejected
20601        // AtOuterProjection for the lock-in).
20602        s.remove("mode");
20603        // Analytic-only window functions.
20604        s.add("row_number");
20605        s.add("rank");
20606        s.add("dense_rank");
20607        s.add("lag");
20608        s.add("lead");
20609        s.add("ntile");
20610        s.add("first_value");
20611        s.add("last_value");
20612        s.add("percent_rank");
20613        s.add("cume_dist");
20614        s.add("nth_value");
20615        WINDOW_FUNCTION_NAMES = Collections.unmodifiableSet(s);
20616    }
20617
20618    /**
20619     * Slice 13: build an {@link OutputColumn} for a top-level
20620     * window-function projection. Caller must have already verified
20621     * {@link #isTopLevelWindowProjection(TExpression)}, run the
20622     * {@link #rejectEmbeddedSubqueryInProjection} and
20623     * {@link #rejectEmbeddedWindowFunction} guards, and confirmed the
20624     * surrounding body permits window projections (i.e., the
20625     * {@code !allowWindowProjection} fast-path in
20626     * {@link #buildOutputColumns} did not fire).
20627     *
20628     * <p>The constructed {@link OutputColumn} carries:
20629     * <ul>
20630     *   <li>{@code derived = true} (window functions are computed)</li>
20631     *   <li>{@code aggregate = false} (window functions are
20632     *       row-preserving — see slice-13 §14)</li>
20633     *   <li>{@code sources} = column refs from the function args only
20634     *       (PARTITION BY / OVER ORDER BY refs are excluded so that
20635     *       canonical SELECT lineage matches dlineage's
20636     *       function-arg-only SELECT BFS)</li>
20637     *   <li>{@code windowSpec = WindowSpec(partitionRefs, orderRefs, frame)}
20638     *       (slice 22 — frame may be null when the SQL has no
20639     *       {@code ROWS}/{@code RANGE}/{@code GROUPS BETWEEN ...} clause)</li>
20640     * </ul>
20641     */
20642    private static OutputColumn buildWindowOutputColumn(TResultColumn rc,
20643                                                        TSelectSqlStatement enclosingSelect,
20644                                                        NameBindingProvider provider) {
20645        TFunctionCall fn = rc.getExpr().getFunctionCall();
20646        TWindowDef wd = fn.getWindowDef();
20647
20648        // 1. Function-name allowlist (codex round-1 MUST 3).
20649        String fnName = fn.getFunctionName() == null ? null : fn.getFunctionName().toString();
20650        if (fnName == null || !WINDOW_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) {
20651            throw new SemanticIRBuildException(
20652                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_UNSUPPORTED,
20653                    "result column " + rc + " uses unsupported window function '"
20654                            + fnName + "'; supported names are " + WINDOW_FUNCTION_NAMES, rc));
20655        }
20656
20657        // 2. Reject vendor-specific function-level surfaces (codex round-1 MUST 4).
20658        if (fn.getFilterClause() != null) {
20659            throw new SemanticIRBuildException(
20660                    Diagnostic.error(DiagnosticCode.WINDOW_FILTER_NOT_SUPPORTED,
20661                    "result column " + rc + " uses FILTER (WHERE ...) on a "
20662                            + "window function; not supported yet", rc));
20663        }
20664        if (fn.getWithinGroup() != null) {
20665            throw new SemanticIRBuildException(
20666                    Diagnostic.error(DiagnosticCode.WINDOW_WITHIN_GROUP_NOT_SUPPORTED,
20667                    "result column " + rc + " uses WITHIN GROUP on a "
20668                            + "window function; not supported yet", rc));
20669        }
20670        if (fn.getOrderByList() != null && fn.getOrderByList().size() > 0) {
20671            throw new SemanticIRBuildException(
20672                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_LEVEL_ORDER_BY_NOT_SUPPORTED,
20673                    "result column " + rc + " uses function-level ORDER BY "
20674                            + "(LISTAGG-style); not supported yet", rc));
20675        }
20676        if (fn.getSortClause() != null) {
20677            throw new SemanticIRBuildException(
20678                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_LEVEL_SORT_NOT_SUPPORTED,
20679                    "result column " + rc + " uses function-level SORT clause; "
20680                            + "not supported yet", rc));
20681        }
20682
20683        // 3. Reject vendor-specific window-def surfaces (codex round-1 MUSTs 5, 7).
20684        if (wd.getName() != null) {
20685            throw new SemanticIRBuildException(
20686                    Diagnostic.error(DiagnosticCode.WINDOW_NAMED_WINDOW_DECLARATION_NOT_SUPPORTED,
20687                    "result column " + rc + " declares a named window "
20688                            + "(WINDOW name AS); not supported yet", rc));
20689        }
20690        if (wd.getReferenceName() != null) {
20691            throw new SemanticIRBuildException(
20692                    Diagnostic.error(DiagnosticCode.WINDOW_NAMED_WINDOW_REFERENCE_NOT_SUPPORTED,
20693                    "result column " + rc + " references a named window via "
20694                            + "OVER name; not supported yet", rc));
20695        }
20696        if (wd.getWithinGroup() != null) {
20697            throw new SemanticIRBuildException(
20698                    Diagnostic.error(DiagnosticCode.WINDOW_WITHIN_GROUP_INSIDE_PROJECTION_NOT_SUPPORTED,
20699                    "result column " + rc + " uses WITHIN GROUP inside the "
20700                            + "OVER clause; not supported yet", rc));
20701        }
20702        if (wd.getKeepDenseRankClause() != null) {
20703            throw new SemanticIRBuildException(
20704                    Diagnostic.error(DiagnosticCode.WINDOW_KEEP_DENSE_RANK_NOT_SUPPORTED,
20705                    "result column " + rc + " uses KEEP DENSE_RANK FIRST/LAST; "
20706                            + "not supported yet", rc));
20707        }
20708        if (wd.getDistributeBy() != null) {
20709            throw new SemanticIRBuildException(
20710                    Diagnostic.error(DiagnosticCode.WINDOW_DISTRIBUTE_BY_NOT_SUPPORTED,
20711                    "result column " + rc + " uses Hive DISTRIBUTE BY in window; "
20712                            + "not supported yet", rc));
20713        }
20714        if (wd.getClusterBy() != null) {
20715            throw new SemanticIRBuildException(
20716                    Diagnostic.error(DiagnosticCode.WINDOW_CLUSTER_BY_NOT_SUPPORTED,
20717                    "result column " + rc + " uses Hive CLUSTER BY in window; "
20718                            + "not supported yet", rc));
20719        }
20720        if (wd.getSortBy() != null) {
20721            throw new SemanticIRBuildException(
20722                    Diagnostic.error(DiagnosticCode.WINDOW_SORT_BY_NOT_SUPPORTED,
20723                    "result column " + rc + " uses Hive SORT BY in window; "
20724                            + "not supported yet", rc));
20725        }
20726        // Slice 22: frame clauses are now built into WindowSpec.frame; the
20727        // slice-13 wholesale rejection is gone. Frame build happens AFTER
20728        // empty-OVER reject below so a frame-only OVER (...) fails on
20729        // empty-OVER first (the more user-tuned error message).
20730
20731        // 4. Reject empty OVER () (slice-13 boundary; dlineage parity —
20732        // empty OVER () is byte-identical to a plain aggregate in the XML).
20733        TPartitionClause pc = wd.getPartitionClause();
20734        TOrderBy ob = wd.getOrderBy();
20735        boolean hasPartitionBy = pc != null
20736                && pc.getExpressionList() != null
20737                && pc.getExpressionList().size() > 0;
20738        boolean hasOverOrderBy = ob != null
20739                && ob.getItems() != null
20740                && ob.getItems().size() > 0;
20741        if (!hasPartitionBy && !hasOverOrderBy) {
20742            throw new SemanticIRBuildException(
20743                    Diagnostic.error(DiagnosticCode.WINDOW_EMPTY_OVER_NOT_SUPPORTED,
20744                    "result column " + rc + " uses empty OVER (); not supported yet "
20745                            + "(dlineage XML cannot discriminate from a plain aggregate)", rc));
20746        }
20747
20748        // 5. Reject Hive PARTITION BY ... SORT (...).
20749        if (pc != null && pc.getSortedColumns() != null && pc.getSortedColumns().size() > 0) {
20750            throw new SemanticIRBuildException(
20751                    Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_SORT_NOT_SUPPORTED,
20752                    "result column " + rc + " uses Hive PARTITION BY ... SORT (...); "
20753                            + "not supported yet", rc));
20754        }
20755
20756        // 6. Build PARTITION BY refs.
20757        List<ColumnRef> partitionRefs = hasPartitionBy
20758                ? buildWindowPartitionRefs(pc, rc, enclosingSelect, provider)
20759                : new ArrayList<ColumnRef>();
20760
20761        // 7. Build OVER ORDER BY refs.
20762        List<ColumnRef> orderRefs = hasOverOrderBy
20763                ? buildWindowOrderRefs(ob, rc, enclosingSelect, provider)
20764                : new ArrayList<ColumnRef>();
20765
20766        // 8. Build frame (slice 22). Null when the SQL has no ROWS/RANGE/
20767        // GROUPS clause inside OVER (...).
20768        WindowFrame frame = wd.getWindowFrame() == null
20769                ? null
20770                : buildWindowFrame(wd.getWindowFrame(), rc);
20771
20772        // 9. Build sources from args only — PARTITION BY / OVER ORDER BY
20773        // refs must NOT leak into OutputColumn.sources because canonical
20774        // SELECT lineage on the dlineage side only walks fdd edges
20775        // (function args), not fdr edges (PARTITION BY / OVER ORDER BY).
20776        List<ColumnRef> sources = (fn.getArgs() == null || fn.getArgs().size() == 0)
20777                ? new ArrayList<ColumnRef>()
20778                : collectColumnRefs(fn.getArgs(), provider);
20779
20780        // 10. Construct OutputColumn. aggregate=false ALWAYS for window
20781        // functions (row-preserving). The OutputColumn ctor enforces
20782        // the windowSpec!=null AND aggregate=false invariant.
20783        String name = effectiveOutputName(rc);
20784        return new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ false,
20785                sources, new WindowSpec(partitionRefs, orderRefs, frame));
20786    }
20787
20788    /**
20789     * Slice 22: build a {@link WindowFrame} from a parser
20790     * {@link TWindowFrame}. Frame information is presentation-only
20791     * (dlineage XML harvests no frame data — see
20792     * {@code DataFlowAnalyzer.java:20558-20575}); this helper captures
20793     * the surface shape into the IR for governance consumers without
20794     * touching the canonical lineage model.
20795     *
20796     * <p>Direct field access via {@link TWindowFrame#getStartBoundary()} /
20797     * {@link TWindowFrame#getEndBoundary()}; visitors are NOT used because
20798     * {@code TWindowFrame.acceptChildren()} doesn't recurse into the
20799     * boundaries (codex round-1 SHOULD 3).
20800     *
20801     * <p>Order of guards (codex round-2 SHOULD 2): EXCLUDE first so the
20802     * error message is tuned to the actual surface; then null-guard the
20803     * boundary type (defensive — current parsers always pass it); then
20804     * map the {@code EBoundaryType} via an exhaustive switch
20805     * (slice-14 process lesson #17 — no catch-all); then check the
20806     * {@code boundaryNumber} expression type and reject non-constant
20807     * offsets (codex round-1 SHOULD 1 — PG {@code simple_object_name_t}
20808     * and ANSI {@code parenthesis_t} are reachable).
20809     *
20810     * <p>Null guards on the frame's {@link ELimitRowType} and
20811     * {@code startBoundary} fields are defensive / forward-compat: every
20812     * vendor grammar surveyed (codex round-4 NOTE 1) passes these
20813     * arguments together when constructing a {@code TWindowFrame}, so the
20814     * guards are unexercised by current parsers but protect against
20815     * future parser drift.
20816     */
20817    private static WindowFrame buildWindowFrame(TWindowFrame wf, TResultColumn rc) {
20818        // Defensive null guards (codex round-2 MUST 2; codex round-4
20819        // SHOULD 1 — labelled DEFENSIVE / FORWARD-COMPAT).
20820        if (wf.getLimitRowType() == null) {
20821            throw new SemanticIRBuildException(
20822                    Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_LIMIT_ROW_TYPE,
20823                    "result column " + rc + " has a frame with null limitRowType "
20824                            + "(forward-compat / unexpected parser shape); not supported", rc));
20825        }
20826        if (wf.getStartBoundary() == null) {
20827            throw new SemanticIRBuildException(
20828                    Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_START_BOUNDARY,
20829                    "result column " + rc + " has a frame with null start boundary "
20830                            + "(forward-compat / unexpected parser shape); not supported", rc));
20831        }
20832        WindowFrame.Unit unit = mapFrameUnit(wf.getLimitRowType());
20833        FrameBound start = buildFrameBound(wf.getStartBoundary(), rc, /*end=*/ false);
20834        FrameBound end = wf.getEndBoundary() == null
20835                ? null
20836                : buildFrameBound(wf.getEndBoundary(), rc, /*end=*/ true);
20837        return new WindowFrame(unit, start, end);
20838    }
20839
20840    /**
20841     * Slice 22: map the parser's {@link ELimitRowType} to the IR's
20842     * {@link WindowFrame.Unit}. Exhaustive switch (slice-14 process
20843     * lesson #17 — no catch-all); a future enum addition fails closed.
20844     */
20845    private static WindowFrame.Unit mapFrameUnit(ELimitRowType type) {
20846        switch (type) {
20847            case Rows:
20848                return WindowFrame.Unit.ROWS;
20849            case Range:
20850                return WindowFrame.Unit.RANGE;
20851            case Groups:
20852                return WindowFrame.Unit.GROUPS;
20853            default:
20854                throw new SemanticIRBuildException(
20855                        Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNSUPPORTED_LIMIT_ROW_TYPE,
20856                        "unsupported window frame limitRowType: " + type, null));
20857        }
20858    }
20859
20860    /**
20861     * Slice 22: build a {@link FrameBound} from a parser
20862     * {@link TWindowFrameBoundary}. The {@code end} parameter is for
20863     * error messages only (start vs end disambiguation).
20864     *
20865     * <p>Per-bound check order: EXCLUDE → boundaryType-null → kind switch
20866     * → boundaryNumber shape (codex round-2 SHOULD 2 + slice-22 invariant).
20867     */
20868    private static FrameBound buildFrameBound(TWindowFrameBoundary boundary,
20869                                              TResultColumn rc,
20870                                              boolean end) {
20871        String which = end ? "end" : "start";
20872
20873        // (a) EXCLUDE first (codex round-1 MUST 2 + Netezza probe).
20874        // Netezza populates getExclusionClause() on the END boundary for
20875        // EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS; rejecting here
20876        // surfaces the unsupported clause with a tuned message rather
20877        // than letting the offset-shape check fire on an unrelated
20878        // surface.
20879        if (boundary.getExclusionClause() != null) {
20880            throw new SemanticIRBuildException(
20881                    Diagnostic.error(DiagnosticCode.WINDOW_FRAME_EXCLUDE_NOT_SUPPORTED,
20882                    "result column " + rc + " has a frame " + which
20883                            + " boundary with EXCLUDE clause "
20884                            + "(EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS); "
20885                            + "not supported yet", rc));
20886        }
20887
20888        // (b) Null-guard the boundary type (defensive).
20889        if (boundary.getBoundaryType() == null) {
20890            throw new SemanticIRBuildException(
20891                    Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_BOUNDARY_TYPE,
20892                    "result column " + rc + " has a frame " + which
20893                            + " boundary with null boundaryType "
20894                            + "(forward-compat / unexpected parser shape); not supported", rc));
20895        }
20896
20897        // (c) Map the kind via exhaustive switch.
20898        FrameBound.Kind kind = mapBoundaryKind(boundary.getBoundaryType());
20899
20900        // (d) Capture the optional offset literal. Reject non-constant
20901        // offsets (codex round-1 SHOULD 1 + slice-22 PG/ANSI probe — PG
20902        // accepts simple_object_name_t (column ROWS BETWEEN x PRECEDING ...),
20903        // ANSI accepts parenthesis_t ((x+1))).
20904        String offsetLiteral = null;
20905        TExpression offsetExpr = boundary.getBoundaryNumber();
20906        if (offsetExpr != null) {
20907            // Slice-22 codex impl-review SHOULD 1: when the kind forbids
20908            // an offset (UNBOUNDED_*/CURRENT_ROW), reject with
20909            // SemanticIRBuildException so the failure stays inside the
20910            // builder's error contract — without this guard, a parser
20911            // surfacing a stray boundary number on CURRENT_ROW would
20912            // escape as IllegalArgumentException from
20913            // FrameBound's ctor.
20914            boolean offsetAllowed = (kind == FrameBound.Kind.PRECEDING
20915                    || kind == FrameBound.Kind.FOLLOWING);
20916            if (!offsetAllowed) {
20917                throw new SemanticIRBuildException(
20918                        Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNEXPECTED_OFFSET,
20919                        "result column " + rc + " has a frame " + which
20920                                + " boundary of kind " + kind
20921                                + " carrying an unexpected offset '"
20922                                + offsetExpr + "' (forward-compat / "
20923                                + "unexpected parser shape); not supported", rc));
20924            }
20925            EExpressionType offsetType = offsetExpr.getExpressionType();
20926            if (offsetType != EExpressionType.simple_constant_t) {
20927                throw new SemanticIRBuildException(
20928                        Diagnostic.error(DiagnosticCode.WINDOW_FRAME_OFFSET_NON_CONSTANT,
20929                        "result column " + rc + " has a frame " + which
20930                                + " offset that is not a simple constant "
20931                                + "(got " + offsetType + " '" + offsetExpr + "'); "
20932                                + "not supported yet", rc));
20933            }
20934            offsetLiteral = offsetExpr.toString();
20935        }
20936        return new FrameBound(kind, offsetLiteral);
20937    }
20938
20939    /**
20940     * Slice 22: map the parser's {@link EBoundaryType} to the IR's
20941     * {@link FrameBound.Kind}. Exhaustive switch (slice-14 process
20942     * lesson #17).
20943     */
20944    private static FrameBound.Kind mapBoundaryKind(EBoundaryType type) {
20945        switch (type) {
20946            case ebtUnboundedPreceding:
20947                return FrameBound.Kind.UNBOUNDED_PRECEDING;
20948            case ebtUnboundedFollowing:
20949                return FrameBound.Kind.UNBOUNDED_FOLLOWING;
20950            case ebtCurrentRow:
20951                return FrameBound.Kind.CURRENT_ROW;
20952            case ebtPreceding:
20953                return FrameBound.Kind.PRECEDING;
20954            case ebtFollowing:
20955                return FrameBound.Kind.FOLLOWING;
20956            default:
20957                throw new SemanticIRBuildException(
20958                        Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNSUPPORTED_BOUNDARY_TYPE,
20959                        "unsupported frame boundary type: " + type, null));
20960        }
20961    }
20962
20963    /**
20964     * Slice 13: build the PARTITION BY ref list. Every item must be a
20965     * physical column reference ({@code simple_object_name_t} resolving
20966     * via the provider to {@code EXACT_MATCH}). Other shapes are
20967     * rejected with a tuned message — slice-9 / slice-13
20968     * rejection-over-silent-loss.
20969     */
20970    private static List<ColumnRef> buildWindowPartitionRefs(TPartitionClause pc,
20971                                                             TResultColumn rc,
20972                                                             TSelectSqlStatement enclosingSelect,
20973                                                             NameBindingProvider provider) {
20974        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
20975        TExpressionList list = pc.getExpressionList();
20976        for (int i = 0; i < list.size(); i++) {
20977            TExpression item = list.getExpression(i);
20978            EExpressionType t = item.getExpressionType();
20979            if (t == EExpressionType.simple_constant_t) {
20980                throw new SemanticIRBuildException(
20981                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_LITERAL,
20982                        "result column " + rc + " has PARTITION BY literal '"
20983                                + item + "'; not supported yet", rc));
20984            }
20985            if (t == EExpressionType.subquery_t || item.getSubQuery() != null) {
20986                throw new SemanticIRBuildException(
20987                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_SUBQUERY,
20988                        "result column " + rc + " has PARTITION BY containing "
20989                                + "a subquery; not supported yet", rc));
20990            }
20991            if (t == EExpressionType.function_t) {
20992                throw new SemanticIRBuildException(
20993                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_AGGREGATE,
20994                        "result column " + rc + " has PARTITION BY containing "
20995                                + "a function call '" + item + "'; not supported yet", rc));
20996            }
20997            if (t != EExpressionType.simple_object_name_t) {
20998                throw new SemanticIRBuildException(
20999                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_UNKNOWN_REFERENCE,
21000                        "result column " + rc + " has PARTITION BY using an "
21001                                + "unsupported expression shape (" + t + "): " + item, rc));
21002            }
21003            // Defensive: reject if the parser/resolver has retyped this
21004            // item as a projection alias. Current Oracle parsers leave
21005            // PARTITION BY <alias> as dbType=column even for projection
21006            // aliases; this guard fires for vendors that may behave
21007            // differently. The slice-19 discriminator below catches the
21008            // Oracle case where dbType stays "column" but the binding
21009            // came from the schema-less inferred-from-usage fallback.
21010            TObjectName on = item.getObjectOperand();
21011            if (on != null && on.getDbObjectType() == EDbObjectType.column_alias) {
21012                throw new SemanticIRBuildException(
21013                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_PROJECTION_ALIAS,
21014                        "result column " + rc + " has PARTITION BY referencing "
21015                                + "a projection alias '" + item + "'; not supported yet", rc));
21016            }
21017            // Slice 19: alias-bound discriminator. Reject when the
21018            // resolver's binding lacks definite FROM-scope evidence and
21019            // the name matches a calculated SELECT-list alias of the
21020            // enclosing SELECT. Without schema metadata the resolver
21021            // cannot tell alias from real column; rejection-over-silent-
21022            // guess matches the slice-9/-10/-13 invariant.
21023            if (on != null && provider.isCalculatedProjectionAliasFallback(on, enclosingSelect)) {
21024                throw new SemanticIRBuildException(
21025                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_CALCULATED_ALIAS,
21026                        "result column " + rc + " has PARTITION BY referencing a "
21027                                + "SELECT-list alias on a calculated expression ('" + item
21028                                + "'); not supported yet — requires schema metadata to "
21029                                + "discriminate alias from base column", rc));
21030            }
21031            // Resolve the column ref through the provider. EXACT_MATCH is
21032            // required (slice-1 fail-fast invariant); collectColumnRefs
21033            // does the heavy lifting and rejects anything else.
21034            List<ColumnRef> built = collectColumnRefs(item, provider);
21035            if (built.isEmpty()) {
21036                throw new SemanticIRBuildException(
21037                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_ITEM_UNUSABLE,
21038                        "result column " + rc + " has PARTITION BY item '"
21039                                + item + "' with no resolvable column refs", rc));
21040            }
21041            refs.addAll(built);
21042        }
21043        return new ArrayList<>(refs);
21044    }
21045
21046    /**
21047     * Slice 13: build the OVER ORDER BY ref list. Every sort key must
21048     * be a physical column reference (mirrors slice-9 outer ORDER BY
21049     * rejection set). Ordinals, projection aliases, expressions,
21050     * subqueries, window functions, and SIBLINGS / RESET WHEN are
21051     * rejected with tuned messages.
21052     */
21053    private static List<ColumnRef> buildWindowOrderRefs(TOrderBy ob,
21054                                                         TResultColumn rc,
21055                                                         TSelectSqlStatement enclosingSelect,
21056                                                         NameBindingProvider provider) {
21057        // Slice-13 codex impl-review MUST 2: defense in depth, mirror outer
21058        // ORDER BY's slice-9 SIBLINGS / RESET WHEN guards.
21059        if (ob.isSiblings()) {
21060            throw new SemanticIRBuildException(
21061                    Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_SIBLINGS_NOT_SUPPORTED,
21062                    "result column " + rc + " has OVER ORDER BY SIBLINGS; not supported yet "
21063                            + "(Oracle hierarchical-query syntax in window OVER clause)", rc));
21064        }
21065        if (ob.getResetWhenCondition() != null) {
21066            throw new SemanticIRBuildException(
21067                    Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_RESET_WHEN_NOT_SUPPORTED,
21068                    "result column " + rc + " has OVER ORDER BY ... RESET WHEN; not supported yet "
21069                            + "(Teradata window-style restart)", rc));
21070        }
21071        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
21072        TOrderByItemList items = ob.getItems();
21073        for (int i = 0; i < items.size(); i++) {
21074            TOrderByItem item = items.getOrderByItem(i);
21075            TExpression key = item.getSortKey();
21076            if (key == null) {
21077                throw new SemanticIRBuildException(
21078                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_NULL_SORT_KEY,
21079                        "result column " + rc + " has OVER ORDER BY item with "
21080                                + "null sort key", rc));
21081            }
21082            EExpressionType t = key.getExpressionType();
21083            if (t == EExpressionType.simple_constant_t) {
21084                // Catches both ordinal sort keys and string-literal sort keys.
21085                throw new SemanticIRBuildException(
21086                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_LITERAL,
21087                        "result column " + rc + " has OVER ORDER BY literal/ordinal '"
21088                                + key + "'; not supported yet", rc));
21089            }
21090            if (t == EExpressionType.subquery_t || key.getSubQuery() != null) {
21091                throw new SemanticIRBuildException(
21092                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_SUBQUERY,
21093                        "result column " + rc + " has OVER ORDER BY containing a "
21094                                + "subquery; not supported yet", rc));
21095            }
21096            if (t == EExpressionType.function_t) {
21097                TFunctionCall innerFn = key.getFunctionCall();
21098                if (innerFn != null && innerFn.getWindowDef() != null) {
21099                    throw new SemanticIRBuildException(
21100                            Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_WINDOW_FUNCTION,
21101                            "result column " + rc + " has OVER ORDER BY containing a "
21102                                    + "window function; not supported yet", rc));
21103                }
21104                throw new SemanticIRBuildException(
21105                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_AGGREGATE,
21106                        "result column " + rc + " has OVER ORDER BY containing a "
21107                                + "function call '" + key + "'; not supported yet", rc));
21108            }
21109            if (t != EExpressionType.simple_object_name_t) {
21110                throw new SemanticIRBuildException(
21111                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_UNKNOWN_REFERENCE,
21112                        "result column " + rc + " has OVER ORDER BY using an "
21113                                + "unsupported expression shape (" + t + "): " + key, rc));
21114            }
21115            // NOTE: Oracle's parser DOES retype OVER ORDER BY refs to
21116            // column_alias when they match a SELECT alias (mirrors
21117            // slice-9 outer ORDER BY behaviour). The defensive
21118            // column_alias guard from PARTITION BY is intentionally
21119            // omitted here — `collectColumnRefs` already skips
21120            // column_alias-typed nodes, and the empty-refs guard below
21121            // catches the resulting unresolvable item with a clear
21122            // message. Outer ORDER BY aliases use the same path.
21123            //
21124            // Slice 19: defensive symmetry with PARTITION BY. A future
21125            // vendor whose parser does NOT retype OVER ORDER BY refs to
21126            // column_alias would land here as `simple_object_name_t`
21127            // with an inferred-from-usage resolution; the discriminator
21128            // catches that case before collectColumnRefs descends. As of
21129            // slice 19, every supported vendor retypes (probe in
21130            // §14.21), so this branch is unreachable in current tests
21131            // — kept for forward-compat.
21132            TObjectName on = key.getObjectOperand();
21133            if (on != null && provider.isCalculatedProjectionAliasFallback(on, enclosingSelect)) {
21134                throw new SemanticIRBuildException(
21135                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_CALCULATED_ALIAS,
21136                        "result column " + rc + " has OVER ORDER BY referencing a "
21137                                + "SELECT-list alias on a calculated expression ('" + key
21138                                + "'); not supported yet — requires schema metadata to "
21139                                + "discriminate alias from base column", rc));
21140            }
21141            List<ColumnRef> built = collectColumnRefs(key, provider);
21142            if (built.isEmpty()) {
21143                throw new SemanticIRBuildException(
21144                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_ITEM_UNUSABLE,
21145                        "result column " + rc + " has OVER ORDER BY item '"
21146                                + key + "' with no resolvable column refs", rc));
21147            }
21148            refs.addAll(built);
21149        }
21150        return new ArrayList<>(refs);
21151    }
21152
21153    /**
21154     * Slice 13: reject any window function ({@code FUNC(...) OVER (...)})
21155     * appearing in a {@link TParseTreeNode} subtree. Used by the
21156     * WHERE / GROUP BY / JOIN ON guards before the visitor would
21157     * otherwise descend into the OVER clause and leak PARTITION BY /
21158     * OVER ORDER BY refs into the wrong column-ref bucket. Mirrors
21159     * {@link #rejectHavingWindowFunction} (slice 10) and
21160     * {@link #rejectOrderByWindowFunction} (slice 9).
21161     */
21162    /**
21163     * Slice 85 — admit RETURNING (PG / Oracle) and OUTPUT (SQL Server)
21164     * projections on INSERT / UPDATE / DELETE statements. Returns the
21165     * list of {@link OutputColumn}s for the {@code returningColumns}
21166     * slot on the DML's {@link StatementGraph}, and appends one
21167     * {@link LineageEdge} per source column ref to {@code lineage}:
21168     * <pre>
21169     *   from = LineageRef.statementOutput(dmlIdx, returningColumns[i].name)
21170     *   to   = LineageRef.tableColumn(targetQName, sourceColumnName)
21171     * </pre>
21172     * (consumer ← producer direction; mirrors slice-78 INSERT's
21173     * {@code target ← source} convention but with the DML's own output
21174     * as the consumer and the target table's column as the producer.)
21175     *
21176     * <p>At most one of {@code ret} and {@code out} is non-null. When
21177     * both are null (no RETURNING / OUTPUT clause), returns an empty
21178     * list and emits no edges.
21179     *
21180     * <p>Reject ordering (codex round-3 Q2 BLOCKING fix — two-pass):
21181     * <ol>
21182     *   <li>Pass 1, statement-level: empty projection list →
21183     *       {@link DiagnosticCode#RETURNING_EMPTY_PROJECTION}.</li>
21184     *   <li>Pass 1.5, OUTPUT-only DML-kind / pseudo-table mismatch scan:
21185     *       INSERT with any {@code DELETED.col} →
21186     *       {@link DiagnosticCode#OUTPUT_DELETED_ON_INSERT_NOT_SUPPORTED};
21187     *       DELETE with any {@code INSERTED.col} →
21188     *       {@link DiagnosticCode#OUTPUT_INSERTED_ON_DELETE_NOT_SUPPORTED}.
21189     *       Fires on the first matching column regardless of position.</li>
21190     *   <li>Pass 2, per-column (in SQL declaration order):
21191     *     <ul>
21192     *       <li>{@code *} → {@link DiagnosticCode#RETURNING_STAR_NOT_SUPPORTED}</li>
21193     *       <li>any subquery →
21194     *           {@link DiagnosticCode#RETURNING_HAS_SUBQUERY_NOT_SUPPORTED}</li>
21195     *       <li>any window function over a base ref → reuses
21196     *           {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} via
21197     *           {@link #rejectWindowFunctionInScope}</li>
21198     *       <li>any aggregate function over a base ref →
21199     *           {@link DiagnosticCode#RETURNING_HAS_AGGREGATE_NOT_SUPPORTED}
21200     *           (aggregates are not legal in DML RETURNING / OUTPUT per
21201     *           spec — fires defensively when parser admits them)</li>
21202     *     </ul>
21203     *   </li>
21204     * </ol>
21205     *
21206     * <p>OUTPUT_INTO_NOT_SUPPORTED is rejected at the caller (before
21207     * any FROM walk / SET / WHERE processing) so multi-violation shapes
21208     * route through the cheaper structural code first.
21209     *
21210     * @param ret RETURNING clause; null when this DML uses OUTPUT or
21211     *            no projection at all
21212     * @param out OUTPUT clause; null when this DML uses RETURNING or
21213     *            no projection at all
21214     * @param dmlKind "INSERT" / "UPDATE" / "DELETE" — only relevant for
21215     *                the pseudo-table mismatch scan (UPDATE admits both
21216     *                INSERTED and DELETED; INSERT admits only INSERTED;
21217     *                DELETE admits only DELETED)
21218     * @param targetQName the target table's qualified name; used as the
21219     *                    {@code to} endpoint of every emitted LineageEdge
21220     * @param fromSideAliasToStmtIndex slice-123 alias&rarr;statement-index map
21221     *                    keyed lowercase for SUBQUERY-kind FROM-side relations
21222     *                    that own a producing {@link StatementGraph}
21223     *                    (slice-84 USING-(SELECT) sub / slice-106 USING-CTE on
21224     *                    DELETE; slice-82/83/105 FROM-subquery / CTE-as-relation
21225     *                    on UPDATE). When a RETURNING/OUTPUT source ref resolves
21226     *                    to such a relation, the emitted edge's {@code to}
21227     *                    endpoint is the producer's
21228     *                    {@code STATEMENT_OUTPUT(idx, col)} rather than the
21229     *                    fictitious {@code TABLE_COLUMN(<alias>, col)}. Pass an
21230     *                    empty map to keep the slice-85 TABLE_COLUMN behaviour
21231     *                    (INSERT, MERGE)
21232     * @param provider name-binding provider; same instance used for
21233     *                 SET RHS / WHERE / JOIN ON ref collection so
21234     *                 FROM-side relation refs (slice-82 joined UPDATE,
21235     *                 slice-84 joined DELETE) resolve correctly
21236     * @param dmlIdx the DML statement's position in
21237     *               {@link SemanticProgram#getStatements()}; used as the
21238     *               {@code statementIndex} on the {@code from} endpoint
21239     * @param lineage in/out: collected edges are appended here
21240     * @param anchor parse-tree anchor for diagnostics
21241     */
21242    private static List<OutputColumn> buildReturningColumns(
21243            TReturningClause ret,
21244            TOutputClause out,
21245            String dmlKind,
21246            String targetQName,
21247            String targetAlias,
21248            TTable targetTable,
21249            List<RelationSource> fromSideRelations,
21250            Map<String, Integer> fromSideAliasToStmtIndex,
21251            NameBindingProvider provider,
21252            int dmlIdx,
21253            List<LineageEdge> lineage,
21254            TParseTreeNode anchor) {
21255        if (ret == null && out == null) {
21256            return Collections.emptyList();
21257        }
21258        // Oracle host-variable form: `RETURNING col INTO :v` — AST shape
21259        // is columnValueList + variableList populated, resultExprList null.
21260        // Slice 88 admits it: extract column exprs from columnValueList,
21261        // discard variableList (bind sinks have no semantic IR relevance).
21262        // The still-unsupported degenerate case (resultExprList=null AND
21263        // columnValueList=null) keeps RETURNING_INTO_NOT_SUPPORTED so the
21264        // code stays declared-not-unreachable per the slice-71/72/82 precedent.
21265        boolean isOracleInto = (ret != null && ret.getResultExprList() == null
21266                && ret.getColumnValueList() != null);
21267        if (ret != null && ret.getResultExprList() == null && !isOracleInto) {
21268            throw new SemanticIRBuildException(Diagnostic.error(
21269                    DiagnosticCode.RETURNING_INTO_NOT_SUPPORTED,
21270                    "Oracle `RETURNING col INTO :host_var` with no column list "
21271                            + "is not supported; admits the standard INTO form only",
21272                    anchor));
21273        }
21274        // Extract the source column list.
21275        TResultColumnList items = null;
21276        TExpressionList intoExprs = null;
21277        if (isOracleInto) {
21278            intoExprs = ret.getColumnValueList();
21279        } else if (ret != null) {
21280            items = ret.getResultExprList();
21281        } else {
21282            items = out.getSelectItemList();
21283        }
21284        int colCount = isOracleInto
21285                ? (intoExprs == null ? 0 : intoExprs.size())
21286                : (items == null ? 0 : items.size());
21287        // Pass 1: empty projection list (defensive — the parser usually
21288        // refuses to produce an empty list, but a malformed AST should
21289        // surface a clean diagnostic).
21290        if (colCount == 0) {
21291            throw new SemanticIRBuildException(Diagnostic.error(
21292                    DiagnosticCode.RETURNING_EMPTY_PROJECTION,
21293                    dmlKind + (ret != null ? " RETURNING" : " OUTPUT")
21294                            + " clause has no projection columns",
21295                    anchor));
21296        }
21297        // Pass 1.5: OUTPUT-only DML-kind / pseudo-table mismatch scan
21298        // (codex round-1 Q4 BLOCKING — deep-walk all TObjectName leaves
21299        // so compound exprs like `OUTPUT INSERTED.a + DELETED.b` also
21300        // reject deterministically). The parser sets pseudoTableType
21301        // on the fieldAttr for SIMPLE column references but leaves
21302        // it null on the leaf TObjectNames inside compound expressions;
21303        // we detect those by checking the objectToken spelling against
21304        // "INSERTED" / "DELETED".
21305        // The Oracle INTO path skips this scan (no INSERTED/DELETED pseudo-tables).
21306        if (out != null && !isOracleInto) {
21307            final String targetAliasFinal = targetAlias;
21308            final String targetQNameFinal = targetQName;
21309            final List<RelationSource> relsFinal = fromSideRelations;
21310            for (int i = 0; i < items.size(); i++) {
21311                TResultColumn rc = items.getResultColumn(i);
21312                final String dmlKindFinal = dmlKind;
21313                final TResultColumn rcFinal = rc;
21314                scanOutputPseudoTableLeaves(rc.getExpr(),
21315                        new TParseTreeVisitor() {
21316                            @Override
21317                            public void preVisit(TObjectName n) {
21318                                EPseudoTableType pt = detectPseudoTable(
21319                                        n, rcFinal, targetAliasFinal,
21320                                        targetQNameFinal, relsFinal);
21321                                if (pt == EPseudoTableType.deleted
21322                                        && "INSERT".equals(dmlKindFinal)) {
21323                                    throw new SemanticIRBuildException(Diagnostic.error(
21324                                            DiagnosticCode.OUTPUT_DELETED_ON_INSERT_NOT_SUPPORTED,
21325                                            "INSERT OUTPUT references DELETED."
21326                                                    + bareColumnNameOf(n)
21327                                                    + " but there is no deleted-row "
21328                                                    + "image on INSERT; use INSERTED.* instead",
21329                                            rcFinal));
21330                                }
21331                                if (pt == EPseudoTableType.inserted
21332                                        && "DELETE".equals(dmlKindFinal)) {
21333                                    throw new SemanticIRBuildException(Diagnostic.error(
21334                                            DiagnosticCode.OUTPUT_INSERTED_ON_DELETE_NOT_SUPPORTED,
21335                                            "DELETE OUTPUT references INSERTED."
21336                                                    + bareColumnNameOf(n)
21337                                                    + " but there is no inserted-row "
21338                                                    + "image on DELETE; use DELETED.* instead",
21339                                            rcFinal));
21340                                }
21341                            }
21342                        });
21343            }
21344        }
21345        // Pass 2: per-column. Build OutputColumns, emit edges.
21346        List<OutputColumn> outputs = new ArrayList<>(colCount);
21347        for (int i = 0; i < colCount; i++) {
21348            // For the Oracle INTO path rc is null — the INTO column list
21349            // carries bare expressions, not TResultColumn wrappers.
21350            TResultColumn rc = isOracleInto ? null
21351                    : items.getResultColumn(i);
21352            TExpression expr = isOracleInto
21353                    ? intoExprs.getExpression(i)
21354                    : (rc == null ? null : rc.getExpr());
21355            if (expr == null) {
21356                throw new SemanticIRBuildException(Diagnostic.error(
21357                        DiagnosticCode.RESULT_COLUMN_NULL_EXPRESSION,
21358                        dmlKind + (ret != null ? " RETURNING" : " OUTPUT")
21359                                + " column #" + (i + 1) + " has no expression",
21360                        rc != null ? rc : anchor));
21361            }
21362            // Slice 98 — MSSQL MERGE OUTPUT `$action` pseudo-column.
21363            // Returns the merge action string per output row ('INSERT' /
21364            // 'UPDATE' / 'DELETE') — it has no underlying base column.
21365            // Detected case-insensitively because parser tokens come out
21366            // as `$action` regardless of how the user wrote it; bracketed
21367            // `[$action]` is a delimited identifier and is NOT treated
21368            // as the pseudo-column (codex Q1 confirmed YES — slice-98
21369            // detection is literal text equality on the un-bracketed
21370            // spelling). The check is gated on dmlKind="MERGE" so
21371            // INSERT/UPDATE/DELETE OUTPUT (slice 85) are unaffected.
21372            if ("MERGE".equals(dmlKind)
21373                    && isMergeActionPseudoColumn(expr)) {
21374                String actionName = (rc != null && rc.getColumnAlias() != null
21375                        && !rc.getColumnAlias().toString().isEmpty())
21376                        ? rc.getColumnAlias().toString()
21377                        : expr.toString();
21378                outputs.add(new OutputColumn(actionName,
21379                        /*derived=*/ true,
21380                        /*aggregate=*/ false,
21381                        Collections.<ColumnRef>emptyList()));
21382                // No LineageEdge — $action has no producer column.
21383                continue;
21384            }
21385            // STAR check — bare `RETURNING *` parses as
21386            // simple_object_name_t with toString="*"; qualified star
21387            // forms like `RETURNING t.*` / `OUTPUT inserted.*` /
21388            // `OUTPUT deleted.*` parse as simple_object_name_t with
21389            // partToken (and getColumnNameOnly()) equal to "*"
21390            // (codex round-4 BLOCKING fix).
21391            // Slice 90: standard RETURNING star attempts catalog-backed expansion.
21392            // Slice 99: MSSQL MERGE OUTPUT INSERTED.* / DELETED.*
21393            //   attempts catalog-backed expansion against the target table.
21394            // Oracle INTO star and non-MERGE OUTPUT star (and bare /
21395            // target-alias / source-alias MERGE OUTPUT star) remain rejected.
21396            if (isStarReference(expr)) {
21397                if (isOracleInto) {
21398                    // Oracle INTO star: keep existing reject.
21399                    throw new SemanticIRBuildException(Diagnostic.error(
21400                            DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED,
21401                            dmlKind + " RETURNING INTO * star expansion "
21402                                    + "is not yet supported; use explicit column names",
21403                            rc != null ? rc : expr));
21404                }
21405                if (out != null) {
21406                    // Slice 99 / Slice 100 — MSSQL pseudo-table
21407                    // OUTPUT INSERTED.* / DELETED.* routes to catalog-
21408                    // backed expansion against the target table. The
21409                    // pseudo-table discriminator is the parser-set
21410                    // EPseudoTableType.inserted / .deleted flag on the
21411                    // star qualifier (slice-85 primary discriminator).
21412                    // Slice 99 lifted the reject for dmlKind="MERGE";
21413                    // slice 100 generalises to all DML kinds (INSERT /
21414                    // UPDATE / DELETE) — the parser sets pseudoTableType
21415                    // identically on non-MERGE OUTPUT stars, and Pass
21416                    // 1.5 has already rejected cross-direction
21417                    // mismatches (INSERT OUTPUT DELETED.* /
21418                    // DELETE OUTPUT INSERTED.*) before this branch.
21419                    // OUTPUT *, t.*, s.* (no pseudo-table marker) still
21420                    // reject — they're either ambiguous (bare *) or
21421                    // refer to non-pseudo relations.
21422                    EPseudoTableType pseudo = EPseudoTableType.none;
21423                    TObjectName starObj = expr.getObjectOperand();
21424                    if (starObj != null
21425                            && starObj.getPseudoTableType() != null) {
21426                        pseudo = starObj.getPseudoTableType();
21427                    }
21428                    if (pseudo == EPseudoTableType.inserted
21429                            || pseudo == EPseudoTableType.deleted) {
21430                        expandOutputPseudoTableStarColumns(
21431                                expr, rc, pseudo, dmlKind,
21432                                targetTable, targetQName,
21433                                provider, dmlIdx, lineage, anchor, outputs);
21434                        continue;
21435                    }
21436                    // Non-pseudo OUTPUT star (bare *, target-alias *,
21437                    // source-alias *): keep existing reject.
21438                    throw new SemanticIRBuildException(Diagnostic.error(
21439                            DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED,
21440                            dmlKind + " OUTPUT * star expansion is not "
21441                                    + "yet supported; use explicit column names",
21442                            rc != null ? rc : expr));
21443                }
21444                // Standard RETURNING star: attempt catalog-backed expansion.
21445                // On success, the helper adds to `outputs` and `lineage` in place
21446                // and we `continue` past the normal single-column build below.
21447                expandReturningStarColumns(
21448                        expr, rc, dmlKind, targetTable, targetAlias, targetQName,
21449                        fromSideRelations, provider, dmlIdx, lineage, anchor, outputs);
21450                continue;
21451            }
21452            // Subquery / aggregate / window — guarded by !isOracleInto
21453            // because Oracle's INTO column list forbids nested queries and
21454            // aggregates at the grammar level; skip the checks to avoid
21455            // false rejects on unusual AST shapes.
21456            if (!isOracleInto) {
21457                if (containsAnySubqueryExpression(expr)) {
21458                    throw new SemanticIRBuildException(Diagnostic.error(
21459                            DiagnosticCode.RETURNING_HAS_SUBQUERY_NOT_SUPPORTED,
21460                            dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT")
21461                                    + " column #" + (i + 1) + " contains a subquery; "
21462                                    + "slice 85 admits scalar expressions over base columns only",
21463                            rc));
21464                }
21465                rejectWindowFunctionInScope(expr,
21466                        dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT"));
21467                if (isAggregateFunction(expr)) {
21468                    throw new SemanticIRBuildException(Diagnostic.error(
21469                            DiagnosticCode.RETURNING_HAS_AGGREGATE_NOT_SUPPORTED,
21470                            dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT")
21471                                    + " column #" + (i + 1) + " contains an aggregate "
21472                                    + "function; aggregates are not legal in DML "
21473                                    + "RETURNING / OUTPUT projection per SQL spec",
21474                            rc));
21475                }
21476            }
21477            // Name extraction.
21478            // INTO path: no alias possible, use expr.toString() directly.
21479            // Normal path: use the projection-side helper which strips
21480            // INSERTED./DELETED. qualifiers on OUTPUT pseudo-table refs.
21481            String outName = isOracleInto
21482                    ? expr.toString()
21483                    : returningOutputName(rc, expr, dmlKind, ret != null);
21484            if (outName == null || outName.isEmpty()) {
21485                throw new SemanticIRBuildException(Diagnostic.error(
21486                        DiagnosticCode.RESULT_COLUMN_NO_NAME,
21487                        dmlKind + " RETURNING INTO column #" + (i + 1)
21488                                + " has no resolvable name",
21489                        anchor));
21490            }
21491            // Source collection via manual walker (slice-89 fix registers
21492            // RETURNING refs in Resolver2 allColumnReferences for DELETE/UPDATE;
21493            // INSERT RETURNING lacks an InsertScope so Resolver2 path is partial).
21494            // rc=null is safe for the INTO path: synthRefForReturningLeaf
21495            // only dereferences rc inside the `if (isOutput)` guard,
21496            // which is false for all RETURNING (non-OUTPUT) paths.
21497            List<ColumnRef> sources = collectReturningSourceRefs(
21498                    expr, rc, out != null && !isOracleInto,
21499                    targetAlias, targetQName, fromSideRelations);
21500            boolean derived = expr.getExpressionType()
21501                    != EExpressionType.simple_object_name_t;
21502            outputs.add(new OutputColumn(outName, derived,
21503                    /*aggregate=*/ false, sources));
21504            // Emit one LineageEdge per source column ref.
21505            // Edge direction (consumer ← producer; slice-85 convention
21506            // documented on getReturningColumns()):
21507            //   from = STATEMENT_OUTPUT(dmlIdx, returningName)
21508            //   to   = TABLE_COLUMN(<producer-qualified-name>, <colName>)
21509            // The producer qualified-name is:
21510            //   - target table qname when the source ref's relationAlias
21511            //     is INSERTED / DELETED (MSSQL OUTPUT pseudo-tables both
21512            //     ultimately reference the physical target row)
21513            //   - target table qname when the source ref's relationAlias
21514            //     matches the target alias
21515            //   - FROM-side relation's binding qualifiedName when the
21516            //     ref's relationAlias matches a FROM-side relation
21517            //   - the relationAlias verbatim otherwise (defensive)
21518            for (ColumnRef src : sources) {
21519                String srcCol = src.getColumnName();
21520                if (srcCol == null || srcCol.isEmpty()) continue;
21521                String alias = src.getRelationAlias();
21522                // Slice 123 — when the source ref binds to a SUBQUERY-kind
21523                // FROM-side relation that owns its own producing
21524                // StatementGraph (slice-84 USING-(SELECT) sub / slice-106
21525                // USING-CTE on DELETE; slice-82/83/105 FROM-subquery or
21526                // CTE-as-relation on UPDATE), emit the canonical cross-stmt
21527                // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge into the producer
21528                // rather than the fictitious TABLE_COLUMN(<alias>, col).
21529                // Base TABLE-kind FROM-side relations (slice-82/84 joined
21530                // RETURNING, pinned by Slice85Test §B/§C) and target /
21531                // INSERTED / DELETED refs fall through to the slice-85
21532                // TABLE_COLUMN path via resolveReturningEdgeTarget.
21533                Integer subIdx = returningSubquerySourceIndex(alias,
21534                        targetAlias, targetQName, fromSideRelations,
21535                        fromSideAliasToStmtIndex);
21536                if (subIdx != null) {
21537                    lineage.add(new LineageEdge(
21538                            LineageRef.statementOutput(dmlIdx, outName),
21539                            LineageRef.statementOutput(subIdx, srcCol)));
21540                } else {
21541                    String to = resolveReturningEdgeTarget(alias,
21542                            targetAlias, targetQName, fromSideRelations);
21543                    lineage.add(new LineageEdge(
21544                            LineageRef.statementOutput(dmlIdx, outName),
21545                            LineageRef.tableColumn(to, srcCol)));
21546                }
21547            }
21548        }
21549        return outputs;
21550    }
21551
21552    /**
21553     * Slice 98 helper — true when an expression is the MSSQL MERGE
21554     * OUTPUT {@code $action} pseudo-column.
21555     *
21556     * <p>Detection rule: the expression is a
21557     * {@code simple_object_name_t} and its {@code toString()} matches
21558     * {@code "$action"} case-insensitively. Bracketed delimited
21559     * identifiers like {@code [$action]} parse with the brackets in
21560     * the token string, so they are NOT matched here — a column
21561     * actually named {@code $action} (delimited) is treated as a
21562     * normal target column, not the pseudo-column (codex Q1 confirmed).
21563     *
21564     * <p>Caller gates the check on {@code dmlKind == "MERGE"} so the
21565     * slice-85 INSERT/UPDATE/DELETE OUTPUT path is unaffected.
21566     */
21567    private static boolean isMergeActionPseudoColumn(TExpression expr) {
21568        if (expr == null) return false;
21569        if (expr.getExpressionType() != EExpressionType.simple_object_name_t) {
21570            return false;
21571        }
21572        String text = expr.toString();
21573        return text != null && "$action".equalsIgnoreCase(text);
21574    }
21575
21576    /**
21577     * Slice 85 helper — true when an expression is a star reference
21578     * (bare {@code *} or qualified {@code t.*} / {@code INSERTED.*} /
21579     * {@code DELETED.*}). Covers both forms: bare star has
21580     * {@code expr.toString()=="*"}; qualified star is a
21581     * simple_object_name_t whose leaf TObjectName has
21582     * {@code partToken=="*"} (codex round-4 BLOCKING fix —
21583     * qualified stars were previously slipping past the bare-only
21584     * check and producing bogus ColumnRefs).
21585     */
21586    private static boolean isStarReference(TExpression expr) {
21587        if (expr == null) return false;
21588        if (expr.getExpressionType() != EExpressionType.simple_object_name_t) {
21589            return false;
21590        }
21591        if ("*".equals(expr.toString())) return true;
21592        TObjectName n = expr.getObjectOperand();
21593        if (n == null) return false;
21594        if (n.getPartToken() != null && "*".equals(n.getPartToken().toString())) {
21595            return true;
21596        }
21597        String colOnly = n.getColumnNameOnly();
21598        return colOnly != null && "*".equals(colOnly);
21599    }
21600
21601    /**
21602     * Slice 90 helper — expand a standard {@code RETURNING *} /
21603     * {@code RETURNING t.*} star into per-column
21604     * {@link OutputColumn} entries using catalog metadata from
21605     * {@link #lookupRelationColumnNames(TTable, NameBindingProvider)}.
21606     *
21607     * <p>Called only for standard PG/Oracle RETURNING (not MSSQL OUTPUT,
21608     * not Oracle INTO). Adds expanded columns to {@code outputs} and
21609     * matching {@link LineageEdge}s to {@code lineage} in place.
21610     *
21611     * <p>Qualifier matching mirrors Slice 59 SELECT star semantics:
21612     * alias-only — the qualifier must equal the target's effective alias,
21613     * not the schema-qualified name. For INSERT without alias the effective
21614     * alias is the bare table name, so {@code RETURNING employees.*} matches
21615     * {@code INSERT INTO schema.employees}.
21616     *
21617     * <p>Throws {@link SemanticIRBuildException} on any failure:
21618     * <ul>
21619     *   <li>{@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED} — no
21620     *       catalog metadata available for the target relation;</li>
21621     *   <li>{@link DiagnosticCode#RETURNING_STAR_NOT_SUPPORTED} — the
21622     *       qualifier matches a FROM-side relation alias but FROM-side
21623     *       star expansion is deferred to a future slice;</li>
21624     *   <li>{@link DiagnosticCode#RETURNING_STAR_QUALIFIER_UNKNOWN} — the
21625     *       qualifier does not match the target alias or any FROM-side
21626     *       relation alias.</li>
21627     * </ul>
21628     */
21629    private static void expandReturningStarColumns(
21630            TExpression expr,
21631            TResultColumn rc,
21632            String dmlKind,
21633            TTable targetTable,
21634            String targetAlias,
21635            String targetQName,
21636            List<RelationSource> fromSideRelations,
21637            NameBindingProvider provider,
21638            int dmlIdx,
21639            List<LineageEdge> lineage,
21640            TParseTreeNode anchor,
21641            List<OutputColumn> outputs) {
21642        // Extract qualifier: empty for bare `*`, table/alias name for `t.*`.
21643        String qualifier = "";
21644        TObjectName n = (expr != null) ? expr.getObjectOperand() : null;
21645        if (n != null) {
21646            String q = n.getTableString();
21647            if (q != null && !q.isEmpty()) qualifier = q;
21648        }
21649        // Rendered star form for use in diagnostic messages.
21650        String starForm = qualifier.isEmpty() ? "*" : qualifier + ".*";
21651        // Determine which relation to expand.
21652        // Rule (Slice 90, mirrors Slice 59 correlation-name semantics):
21653        //   effective alias only — bare name without alias counts as alias.
21654        boolean matchesTarget = qualifier.isEmpty()
21655                || qualifier.equalsIgnoreCase(targetAlias);
21656        if (matchesTarget) {
21657            // Attempt catalog-backed expansion of the target table.
21658            List<String> cols = lookupRelationColumnNames(targetTable, provider);
21659            if (cols == null || cols.isEmpty()) {
21660                throw new SemanticIRBuildException(Diagnostic.error(
21661                        DiagnosticCode.RETURNING_STAR_CATALOG_REQUIRED,
21662                        dmlKind + " RETURNING " + starForm
21663                                + " requires catalog metadata for target '"
21664                                + targetQName + "' to expand; supply a Catalog via "
21665                                + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)",
21666                        rc != null ? rc : anchor));
21667            }
21668            for (String colName : cols) {
21669                ColumnRef ref = new ColumnRef(targetAlias, colName);
21670                outputs.add(new OutputColumn(colName, /*derived=*/ false,
21671                        /*aggregate=*/ false, Collections.singletonList(ref)));
21672                lineage.add(new LineageEdge(
21673                        LineageRef.statementOutput(dmlIdx, colName),
21674                        LineageRef.tableColumn(targetQName, colName)));
21675            }
21676            return;
21677        }
21678        // Qualifier doesn't match target. Check FROM-side relations.
21679        for (RelationSource rs : fromSideRelations) {
21680            if (qualifier.equalsIgnoreCase(rs.getAlias())) {
21681                // Known FROM-side relation, but expansion is deferred.
21682                throw new SemanticIRBuildException(Diagnostic.error(
21683                        DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED,
21684                        dmlKind + " RETURNING " + starForm + " — "
21685                                + "star expansion for FROM-side/USING relations "
21686                                + "is deferred to a future slice; use explicit "
21687                                + "column names for FROM-side RETURNING refs",
21688                        rc != null ? rc : anchor));
21689            }
21690        }
21691        // Qualifier is truly unknown (doesn't match target or any FROM-side relation).
21692        throw new SemanticIRBuildException(Diagnostic.error(
21693                DiagnosticCode.RETURNING_STAR_QUALIFIER_UNKNOWN,
21694                dmlKind + " RETURNING " + starForm + " — qualifier '"
21695                        + qualifier + "' does not match the DML target alias '"
21696                        + targetAlias + "' or any FROM-side relation; "
21697                        + "use the target's effective alias for RETURNING star expansion",
21698                rc != null ? rc : anchor));
21699    }
21700
21701    /**
21702     * Slice 99 / Slice 100 helper — expand MSSQL pseudo-table
21703     * {@code OUTPUT INSERTED.*} / {@code OUTPUT DELETED.*} into
21704     * per-column {@link OutputColumn} entries using catalog metadata
21705     * from {@link #lookupRelationColumnNames(TTable, NameBindingProvider)}.
21706     *
21707     * <p>Slice 99 originally introduced this helper for MSSQL MERGE
21708     * OUTPUT. Slice 100 generalised it to all DML kinds (INSERT / UPDATE
21709     * / DELETE / MERGE): the parser sets {@code pseudoTableType=inserted/deleted}
21710     * on the star qualifier identically for non-MERGE DML, so the
21711     * expansion is mechanically the same — only the catalog-missing
21712     * message text varies by {@code dmlKind} per the slice-80
21713     * message-text-discrimination contract.
21714     *
21715     * <p>Mirrors the slice-90 standard-RETURNING star design with two
21716     * differences:
21717     * <ul>
21718     *   <li>The pseudo-table qualifier ({@code INSERTED} / {@code DELETED})
21719     *       is normalized to UPPERCASE on
21720     *       {@link ColumnRef#getRelationAlias()} regardless of the SQL
21721     *       case, matching slice-85
21722     *       {@code synthRefForReturningLeaf}'s
21723     *       {@code new ColumnRef("INSERTED", ...)} convention.</li>
21724     *   <li>The catalog lookup target is the DML target table (the
21725     *       pseudo-table rows physically reference target rows), not a
21726     *       FROM-side relation.</li>
21727     * </ul>
21728     *
21729     * <p>Catalog miss reuses {@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED}
21730     * (slice-90 code) with a discriminating message text formatted as
21731     * {@code "<dmlKind> OUTPUT <pseudoLabel>.* requires catalog metadata
21732     * for target '<qname>' ..."}.
21733     *
21734     * <p>Adds expanded columns to {@code outputs} and matching
21735     * {@link LineageEdge}s to {@code lineage} in place.
21736     *
21737     * @param dmlKind one of {@code "INSERT"}, {@code "UPDATE"},
21738     *                {@code "DELETE"}, {@code "MERGE"} — feeds the
21739     *                catalog-missing message text only; expansion is
21740     *                identical regardless of kind because the parser
21741     *                sets {@code pseudoTableType} on the star qualifier
21742     *                uniformly.
21743     */
21744    private static void expandOutputPseudoTableStarColumns(
21745            TExpression expr,
21746            TResultColumn rc,
21747            EPseudoTableType pseudoTable,
21748            String dmlKind,
21749            TTable targetTable,
21750            String targetQName,
21751            NameBindingProvider provider,
21752            int dmlIdx,
21753            List<LineageEdge> lineage,
21754            TParseTreeNode anchor,
21755            List<OutputColumn> outputs) {
21756        // Slice-85 convention: relationAlias is normalized to UPPERCASE.
21757        String pseudoLabel = (pseudoTable == EPseudoTableType.inserted)
21758                ? "INSERTED"
21759                : "DELETED";
21760        List<String> cols = lookupRelationColumnNames(targetTable, provider);
21761        if (cols == null || cols.isEmpty()) {
21762            throw new SemanticIRBuildException(Diagnostic.error(
21763                    DiagnosticCode.RETURNING_STAR_CATALOG_REQUIRED,
21764                    dmlKind + " OUTPUT " + pseudoLabel + ".* requires catalog "
21765                            + "metadata for target '" + targetQName
21766                            + "' to expand; supply a Catalog via "
21767                            + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)",
21768                    rc != null ? rc : anchor));
21769        }
21770        for (String colName : cols) {
21771            ColumnRef ref = new ColumnRef(pseudoLabel, colName);
21772            outputs.add(new OutputColumn(colName, /*derived=*/ false,
21773                    /*aggregate=*/ false, Collections.singletonList(ref)));
21774            // INSERTED / DELETED both physically reference the target
21775            // row image; lineage edge target is the target qname.
21776            lineage.add(new LineageEdge(
21777                    LineageRef.statementOutput(dmlIdx, colName),
21778                    LineageRef.tableColumn(targetQName, colName)));
21779        }
21780    }
21781
21782    /**
21783     * Slice 85 helper — pull the pseudo-table type for an OUTPUT
21784     * column (SQL Server). Returns {@link EPseudoTableType#none} for
21785     * RETURNING (PG / Oracle) columns and for OUTPUT columns whose
21786     * top-level expression doesn't carry an INSERTED / DELETED
21787     * qualifier. For compound expressions, the parser sets the
21788     * pseudo-table type on the fieldAttr only for SIMPLE column
21789     * references; compound shapes must be deep-walked separately
21790     * via {@link #scanOutputPseudoTableLeaves}.
21791     */
21792    private static EPseudoTableType pseudoTableOf(TResultColumn rc) {
21793        if (rc == null) return EPseudoTableType.none;
21794        TObjectName fa = rc.getFieldAttr();
21795        if (fa == null) return EPseudoTableType.none;
21796        EPseudoTableType pt = fa.getPseudoTableType();
21797        return (pt == null) ? EPseudoTableType.none : pt;
21798    }
21799
21800    /**
21801     * Slice 85 helper — walk an OUTPUT projection expression and
21802     * invoke {@code visitor} on every TObjectName leaf for the
21803     * pseudo-table mismatch scan. Skips function-name TObjectNames
21804     * via the dbObjectType filter so {@code OUTPUT FUNC(inserted.x)}
21805     * still surfaces the leaf inserted.x ref.
21806     */
21807    private static void scanOutputPseudoTableLeaves(
21808            TExpression expr, final TParseTreeVisitor visitor) {
21809        if (expr == null) return;
21810        // Collect function-name identities first (codex round-2 Q1
21811        // BLOCKING — dialect-portable structural filter).
21812        final java.util.Set<TObjectName> fnLeaves =
21813                collectFunctionNameLeaves(expr);
21814        // Fast path: leaf simple_object_name_t.
21815        if (expr.getExpressionType() == EExpressionType.simple_object_name_t) {
21816            TObjectName n = expr.getObjectOperand();
21817            if (n != null && !isFunctionNameObjectName(n, fnLeaves)) {
21818                visitor.preVisit(n);
21819            }
21820            return;
21821        }
21822        // Compound expression — walk for TObjectName leaves.
21823        expr.acceptChildren(new TParseTreeVisitor() {
21824            int nestedSelectDepth = 0;
21825            @Override
21826            public void preVisit(TSelectSqlStatement s) { nestedSelectDepth++; }
21827            @Override
21828            public void postVisit(TSelectSqlStatement s) { nestedSelectDepth--; }
21829            @Override
21830            public void preVisit(TObjectName node) {
21831                if (nestedSelectDepth > 0) return;
21832                if (isFunctionNameObjectName(node, fnLeaves)) return;
21833                visitor.preVisit(node);
21834            }
21835        });
21836    }
21837
21838    /**
21839     * Slice 85 helper — detect the pseudo-table type (INSERTED /
21840     * DELETED) for a TObjectName leaf in an OUTPUT projection,
21841     * honouring both the parser-set fieldAttr.pseudoTableType (for
21842     * simple leaf refs where the parser ran its qualifier swap) AND
21843     * the objectToken spelling (for compound expressions where the
21844     * parser left pseudoTableType=none on leaf TObjectNames).
21845     *
21846     * <p>Codex round-2 Q3 BLOCKING fix — MSSQL permits "INSERTED" /
21847     * "DELETED" as real identifiers via ColId, so the text-match
21848     * fallback fires ONLY when no FROM-side relation alias /
21849     * qualifiedName / bare-component matches the qualifier. With a
21850     * real table named "INSERTED" in scope, the text-match is
21851     * suppressed and the leaf surfaces as a normal column ref.
21852     */
21853    private static EPseudoTableType detectPseudoTable(TObjectName n,
21854                                                       TResultColumn rc,
21855                                                       String targetAlias,
21856                                                       String targetQName,
21857                                                       List<RelationSource> fromSideRelations) {
21858        if (n == null) return EPseudoTableType.none;
21859        // Direct: parser-set pseudoTableType.
21860        if (n.getPseudoTableType() != null
21861                && n.getPseudoTableType() != EPseudoTableType.none) {
21862            return n.getPseudoTableType();
21863        }
21864        // Indirect: if this leaf is the result column's fieldAttr,
21865        // read from the fieldAttr's pseudoTableType (slice-78
21866        // TOutputClause.doParse sets it there for simple refs).
21867        if (rc != null && rc.getFieldAttr() == n) {
21868            EPseudoTableType pt = pseudoTableOf(rc);
21869            if (pt != null && pt != EPseudoTableType.none) return pt;
21870        }
21871        // Compound expression leaf — the parser leaves
21872        // pseudoTableType=none on the leaf TObjectNames but the
21873        // objectToken spelling is preserved. Text-match
21874        // INSERTED / DELETED case-insensitively, but only when no
21875        // real FROM-side relation shadows the pseudo name (codex
21876        // round-2 Q3 BLOCKING).
21877        if (n.getObjectToken() != null && n.getPartToken() != null) {
21878            String obj = n.getObjectToken().toString();
21879            if (obj == null) return EPseudoTableType.none;
21880            boolean shadowedByRealRelation =
21881                    qualifierMatchesAnyRelation(obj, targetAlias,
21882                            targetQName, fromSideRelations);
21883            if (shadowedByRealRelation) return EPseudoTableType.none;
21884            if ("INSERTED".equalsIgnoreCase(obj)) return EPseudoTableType.inserted;
21885            if ("DELETED".equalsIgnoreCase(obj)) return EPseudoTableType.deleted;
21886        }
21887        return EPseudoTableType.none;
21888    }
21889
21890    /**
21891     * Slice 85 helper — true when {@code qualifier} matches some
21892     * real relation in scope (target alias / qualified name / bare
21893     * component, or any FROM-side relation alias / qualified name /
21894     * bare component). Used by {@link #detectPseudoTable} to
21895     * suppress the INSERTED / DELETED text-match when a real table
21896     * by that name is in scope.
21897     */
21898    private static boolean qualifierMatchesAnyRelation(String qualifier,
21899            String targetAlias, String targetQName,
21900            List<RelationSource> fromSideRelations) {
21901        if (qualifier == null || qualifier.isEmpty()) return false;
21902        if (targetAlias != null && targetAlias.equalsIgnoreCase(qualifier)) {
21903            return true;
21904        }
21905        if (targetQName != null
21906                && (targetQName.equalsIgnoreCase(qualifier)
21907                        || bareLastDotComponent(targetQName)
21908                                .equalsIgnoreCase(qualifier))) {
21909            return true;
21910        }
21911        if (fromSideRelations != null) {
21912            for (RelationSource rs : fromSideRelations) {
21913                String a = rs.getAlias();
21914                if (a != null && a.equalsIgnoreCase(qualifier)) return true;
21915                String qn = rs.getBinding() == null ? null
21916                        : rs.getBinding().getQualifiedName();
21917                if (qn != null
21918                        && (qn.equalsIgnoreCase(qualifier)
21919                                || bareLastDotComponent(qn)
21920                                        .equalsIgnoreCase(qualifier))) {
21921                    return true;
21922                }
21923            }
21924        }
21925        return false;
21926    }
21927
21928    /**
21929     * Slice 85 helper — collect identities of every TObjectName that
21930     * is a function-name in the given expression tree (codex round-2
21931     * Q1 BLOCKING — {@code EDbObjectType.function} is unreliable
21932     * across dialects: Oracle builtins surface as {@code constant},
21933     * MSSQL XML methods as {@code method}). The walker uses
21934     * {@link IdentityHashMap}-style reference equality so the
21935     * column-ref walker can structurally skip function-name leaves
21936     * regardless of dbType.
21937     */
21938    private static java.util.Set<TObjectName> collectFunctionNameLeaves(
21939            TExpression expr) {
21940        final java.util.Set<TObjectName> set = java.util.Collections.newSetFromMap(
21941                new IdentityHashMap<TObjectName, Boolean>());
21942        if (expr == null) return set;
21943        expr.acceptChildren(new TParseTreeVisitor() {
21944            @Override
21945            public void preVisit(TFunctionCall fn) {
21946                TObjectName name = fn.getFunctionName();
21947                if (name != null) set.add(name);
21948            }
21949        });
21950        return set;
21951    }
21952
21953    /**
21954     * Slice 85 helper — true when this TObjectName is in the
21955     * function-name set collected for the current expression
21956     * (codex round-2 Q1 BLOCKING fix — structural identity rather
21957     * than dbType). {@code functionNameLeaves} may be null (empty
21958     * set semantics) when the caller doesn't have the set in hand.
21959     */
21960    private static boolean isFunctionNameObjectName(TObjectName n,
21961            java.util.Set<TObjectName> functionNameLeaves) {
21962        if (n == null) return false;
21963        if (functionNameLeaves != null && functionNameLeaves.contains(n)) {
21964            return true;
21965        }
21966        // Best-effort fallback: dbType check catches the
21967        // single-leaf simple_object_name_t function case (rare —
21968        // those normally arrive as TFunctionCall). Kept defensively.
21969        EDbObjectType t = n.getDbObjectType();
21970        return t == EDbObjectType.function;
21971    }
21972
21973    /**
21974     * Slice 85 helper — best-effort spelling of the bare column name
21975     * for an OUTPUT pseudo-table ref like INSERTED.foo. Used only in
21976     * diagnostic message text.
21977     */
21978    private static String safePseudoColumn(TResultColumn rc) {
21979        if (rc == null) return "<unknown>";
21980        TObjectName fa = rc.getFieldAttr();
21981        if (fa == null) return "<unknown>";
21982        if (fa.getPartToken() != null) return fa.getPartToken().toString();
21983        if (fa.getPropertyToken() != null) return fa.getPropertyToken().toString();
21984        return rc.toString();
21985    }
21986
21987    /**
21988     * Slice 85 helper — derive the OutputColumn name for a RETURNING /
21989     * OUTPUT projection column. Uses the explicit alias when present,
21990     * else the bare column name (for OUTPUT INSERTED.col / DELETED.col
21991     * the bare partToken spelling, stripping the pseudo-table
21992     * qualifier). Falls back to {@code expr.toString()} for derived
21993     * expressions without alias (e.g. {@code RETURNING a + 1} → name
21994     * = "a + 1").
21995     */
21996    private static String returningOutputName(TResultColumn rc,
21997                                              TExpression expr,
21998                                              String dmlKind,
21999                                              boolean isReturning) {
22000        String alias = rc.getColumnAlias();
22001        if (alias != null && !alias.isEmpty()) {
22002            return alias;
22003        }
22004        // OUTPUT pseudo-table ref without alias — strip the qualifier
22005        // so the OutputColumn.name carries the bare column spelling.
22006        if (!isReturning) {
22007            EPseudoTableType pt = pseudoTableOf(rc);
22008            if (pt != EPseudoTableType.none) {
22009                TObjectName fa = rc.getFieldAttr();
22010                if (fa != null && fa.getPartToken() != null) {
22011                    return fa.getPartToken().toString();
22012                }
22013            }
22014        }
22015        // RETURNING bare column or derived expression — fall back to
22016        // the expression's toString(). For simple_object_name_t this is
22017        // the verbatim bare or qualified column spelling; for
22018        // arithmetic / function expressions it is the rendered text.
22019        String s = expr.toString();
22020        if (s == null || s.isEmpty()) {
22021            throw new SemanticIRBuildException(Diagnostic.error(
22022                    DiagnosticCode.RESULT_COLUMN_NO_NAME,
22023                    dmlKind + (isReturning ? " RETURNING" : " OUTPUT")
22024                            + " column has no resolvable name",
22025                    rc));
22026        }
22027        return s;
22028    }
22029
22030    /**
22031     * Slice 85 helper — collect ColumnRefs from a RETURNING / OUTPUT
22032     * projection expression. Slice 89 fixed TReturningClause.acceptChildren()
22033     * to descend into children so Resolver2 now registers RETURNING refs in
22034     * allColumnReferences for DELETE/UPDATE (INSERT RETURNING lacks InsertScope
22035     * so Resolver2 coverage there is partial). This walker remains the
22036     * authoritative source for Semantic IR because it maps qualifier tokens
22037     * directly onto the DML's known relation set:
22038     *
22039     * <ul>
22040     *   <li>OUTPUT pseudo-table ref ({@code INSERTED.col} /
22041     *       {@code DELETED.col}, detected via
22042     *       {@code fieldAttr.pseudoTableType}) → ColumnRef with
22043     *       uppercase {@code "INSERTED"} / {@code "DELETED"} as the
22044     *       relationAlias, preserving temporal phase (codex round-2
22045     *       Q2 BLOCKING).</li>
22046     *   <li>Qualified ref matching a FROM-side relation's alias
22047     *       (slice-82 joined UPDATE / slice-84 joined DELETE)
22048     *       → ColumnRef with that alias.</li>
22049     *   <li>Qualified ref matching the target table's effective alias
22050     *       or its qualified name → ColumnRef with the target alias.</li>
22051     *   <li>Unqualified ref → ColumnRef with the target alias
22052     *       (default scope).</li>
22053     *   <li>Qualified ref matching nothing known → ColumnRef with the
22054     *       parser's qualifier verbatim. Lineage consumers can spot
22055     *       the unresolved relation via the relationAlias they see.</li>
22056     * </ul>
22057     *
22058     * <p>Match policy: case-insensitive on alias / qualified name,
22059     * to match the slice-83 codex Q3 advisory and stay forgiving of
22060     * dialect-specific identifier casing.
22061     */
22062    private static List<ColumnRef> collectReturningSourceRefs(
22063            TExpression expr,
22064            TResultColumn rc,
22065            boolean isOutput,
22066            String targetAlias,
22067            String targetQName,
22068            List<RelationSource> fromSideRelations) {
22069        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
22070        // Collect function-name identities first (codex round-2 Q1
22071        // BLOCKING — dialect-portable structural filter; see
22072        // {@link #collectFunctionNameLeaves} javadoc for the
22073        // dbObjectType unreliability rationale).
22074        final java.util.Set<TObjectName> fnLeaves = collectFunctionNameLeaves(expr);
22075        // Fast path: simple_object_name_t leaf — handle directly so
22076        // OUTPUT INSERTED.col / DELETED.col resolves through fieldAttr.
22077        if (expr.getExpressionType() == EExpressionType.simple_object_name_t) {
22078            ColumnRef r = synthRefForReturningLeaf(
22079                    rc, expr.getObjectOperand(), isOutput,
22080                    targetAlias, targetQName, fromSideRelations, fnLeaves);
22081            if (r != null) refs.add(r);
22082            return new ArrayList<>(refs);
22083        }
22084        // Compound expression — walk for TObjectName leaves. We don't
22085        // descend into nested subqueries (already rejected upstream)
22086        // and don't try to resolve fieldAttr for compound expressions
22087        // (pseudo-table qualifier inside arithmetic / function args is
22088        // a rare shape; slice 85 surfaces the bare column ref against
22089        // the target alias as a best-effort).
22090        expr.acceptChildren(new TParseTreeVisitor() {
22091            int nestedSelectDepth = 0;
22092            @Override
22093            public void preVisit(TSelectSqlStatement nested) {
22094                nestedSelectDepth++;
22095            }
22096            @Override
22097            public void postVisit(TSelectSqlStatement nested) {
22098                nestedSelectDepth--;
22099            }
22100            @Override
22101            public void preVisit(TObjectName node) {
22102                if (nestedSelectDepth > 0) return;
22103                // Codex round-1 Q1 / round-2 Q1 BLOCKING — skip
22104                // function-name leaves via structural identity.
22105                if (isFunctionNameObjectName(node, fnLeaves)) return;
22106                ColumnRef r = synthRefForReturningLeaf(
22107                        rc, node, isOutput,
22108                        targetAlias, targetQName, fromSideRelations, fnLeaves);
22109                if (r != null) refs.add(r);
22110            }
22111        });
22112        return new ArrayList<>(refs);
22113    }
22114
22115    /**
22116     * Slice 85 helper — build one ColumnRef for a TObjectName leaf in
22117     * a RETURNING / OUTPUT projection. Returns null when the node is
22118     * not a column-name reference (e.g. a function name token).
22119     */
22120    private static ColumnRef synthRefForReturningLeaf(
22121            TResultColumn rc,
22122            TObjectName node,
22123            boolean isOutput,
22124            String targetAlias,
22125            String targetQName,
22126            List<RelationSource> fromSideRelations,
22127            java.util.Set<TObjectName> functionNameLeaves) {
22128        if (node == null) return null;
22129        // Codex round-1 Q1 / round-2 Q1 BLOCKING — skip function-name
22130        // TObjectNames via structural identity (the "UPPER" leaf in
22131        // `RETURNING UPPER(name)`).
22132        if (isFunctionNameObjectName(node, functionNameLeaves)) return null;
22133        String colName = bareColumnNameOf(node);
22134        if (colName == null || colName.isEmpty()) return null;
22135        // OUTPUT pseudo-table ref (INSERTED / DELETED). Detect via
22136        // fieldAttr on the result column (parser surfaces it there
22137        // for simple refs) OR objectToken spelling (compound exprs;
22138        // codex round-1 Q4 BLOCKING — same deep detection as
22139        // detectPseudoTable). Both INSERTED and DELETED ultimately
22140        // reference the target table's row; only the temporal phase
22141        // surfaces on ColumnRef.relationAlias.
22142        if (isOutput) {
22143            EPseudoTableType pt = detectPseudoTable(node, rc,
22144                    targetAlias, targetQName, fromSideRelations);
22145            if (pt == EPseudoTableType.inserted) {
22146                return new ColumnRef("INSERTED", partColumnNameOf(node, colName));
22147            }
22148            if (pt == EPseudoTableType.deleted) {
22149                return new ColumnRef("DELETED", partColumnNameOf(node, colName));
22150            }
22151        }
22152        // Qualifier resolution: bare or qualified.
22153        String qualifier = qualifierOf(node);
22154        if (qualifier == null || qualifier.isEmpty()) {
22155            return new ColumnRef(targetAlias, colName);
22156        }
22157        // Codex round-2 Q2 + round-3 BLOCKING fix — single-pass
22158        // count-all-candidates matcher. A relation "matches" the
22159        // qualifier if any of (alias, qualifiedName, bare-last-dot
22160        // component of qualifiedName) compares case-insensitive-
22161        // equal. Multiple matches (e.g. unaliased `FROM s1.t s2.t`
22162        // both have effectiveAlias "t" via TTable.getName() fallback,
22163        // both have bareComponent "t") are ambiguous and fall through
22164        // to the verbatim qualifier path so consumers see the
22165        // ambiguity rather than a silent order-dependent pick.
22166        int totalMatches = 0;
22167        // Single-match accumulators (one each — only valid when
22168        // totalMatches == 1):
22169        RelationSource matchedRelation = null;
22170        boolean matchedIsTarget = false;
22171        if (fromSideRelations != null) {
22172            for (RelationSource rs : fromSideRelations) {
22173                if (relationCandidateMatch(rs, qualifier)) {
22174                    totalMatches++;
22175                    matchedRelation = rs;
22176                }
22177            }
22178        }
22179        if (targetCandidateMatch(targetAlias, targetQName, qualifier)) {
22180            totalMatches++;
22181            matchedIsTarget = true;
22182        }
22183        if (totalMatches == 1) {
22184            if (matchedIsTarget) {
22185                return new ColumnRef(
22186                        targetAlias != null ? targetAlias : targetQName, colName);
22187            }
22188            String a = matchedRelation.getAlias();
22189            String qn = matchedRelation.getBinding() == null ? null
22190                    : matchedRelation.getBinding().getQualifiedName();
22191            return new ColumnRef((a != null && !a.isEmpty()) ? a : qn, colName);
22192        }
22193        // Zero matches or ambiguous — pass through verbatim. Lineage
22194        // consumers can spot the unresolved / ambiguous relation.
22195        return new ColumnRef(qualifier, colName);
22196    }
22197
22198    /**
22199     * Slice 85 helper — true when a FROM-side relation is a match
22200     * candidate for the qualifier under any of: alias, full
22201     * qualifiedName, or bare last-dot component of qualifiedName
22202     * (case-insensitive). Caller uses {@link #synthRefForReturningLeaf}'s
22203     * single-pass count-then-pick policy to disambiguate.
22204     */
22205    private static boolean relationCandidateMatch(RelationSource rs,
22206                                                   String qualifier) {
22207        if (rs == null || qualifier == null || qualifier.isEmpty()) return false;
22208        String a = rs.getAlias();
22209        if (a != null && a.equalsIgnoreCase(qualifier)) return true;
22210        String qn = rs.getBinding() == null ? null
22211                : rs.getBinding().getQualifiedName();
22212        if (qn == null) return false;
22213        return qn.equalsIgnoreCase(qualifier)
22214                || bareLastDotComponent(qn).equalsIgnoreCase(qualifier);
22215    }
22216
22217    /**
22218     * Slice 85 helper — true when the target table is a candidate
22219     * match for the qualifier (alias / qualifiedName / bare
22220     * component, case-insensitive).
22221     */
22222    private static boolean targetCandidateMatch(String targetAlias,
22223                                                 String targetQName,
22224                                                 String qualifier) {
22225        if (qualifier == null || qualifier.isEmpty()) return false;
22226        if (targetAlias != null && targetAlias.equalsIgnoreCase(qualifier)) {
22227            return true;
22228        }
22229        if (targetQName != null
22230                && (targetQName.equalsIgnoreCase(qualifier)
22231                        || bareLastDotComponent(targetQName)
22232                                .equalsIgnoreCase(qualifier))) {
22233            return true;
22234        }
22235        return false;
22236    }
22237
22238    /**
22239     * Slice 85 helper — strip everything up to and including the
22240     * last dot in a qualified name. Returns the input unchanged when
22241     * no dot is present.
22242     */
22243    private static String bareLastDotComponent(String qname) {
22244        if (qname == null) return "";
22245        int dot = qname.lastIndexOf('.');
22246        return (dot < 0) ? qname : qname.substring(dot + 1);
22247    }
22248
22249    /**
22250     * Slice 85 helper — map a ColumnRef.relationAlias to the qualified
22251     * table name used on the LineageEdge {@code to} endpoint. INSERTED
22252     * / DELETED both collapse to the target's qualified name; FROM-side
22253     * aliases route to their bound qualifiedName; target alias / qname
22254     * stays on the target; unknown aliases pass through verbatim.
22255     */
22256    private static String resolveReturningEdgeTarget(
22257            String alias, String targetAlias, String targetQName,
22258            List<RelationSource> fromSideRelations) {
22259        if ("INSERTED".equals(alias) || "DELETED".equals(alias)) {
22260            return targetQName;
22261        }
22262        if (targetAlias != null && targetAlias.equalsIgnoreCase(alias)) {
22263            return targetQName;
22264        }
22265        if (targetQName != null && targetQName.equalsIgnoreCase(alias)) {
22266            return targetQName;
22267        }
22268        if (fromSideRelations != null) {
22269            for (RelationSource rs : fromSideRelations) {
22270                if (rs.getAlias() != null
22271                        && rs.getAlias().equalsIgnoreCase(alias)) {
22272                    String qn = rs.getBinding() == null ? null
22273                            : rs.getBinding().getQualifiedName();
22274                    return (qn != null && !qn.isEmpty()) ? qn : alias;
22275                }
22276            }
22277        }
22278        return alias != null ? alias : targetQName;
22279    }
22280
22281    /**
22282     * Slice 123 — resolve a RETURNING/OUTPUT source ref's relation alias to
22283     * the statement index of a SUBQUERY-kind FROM-side relation that owns
22284     * its own producing {@link StatementGraph}, so the slice-85 walker can
22285     * emit a cross-stmt {@code STATEMENT_OUTPUT(dmlIdx, ret) →
22286     * STATEMENT_OUTPUT(subOrCteIdx, col)} edge instead of the fictitious
22287     * {@code TABLE_COLUMN(<alias>, col)} edge.
22288     *
22289     * <p>Returns the producing statement index iff ALL of:
22290     * <ul>
22291     *   <li>{@code fromSideAliasToStmtIndex} is non-empty and {@code alias}
22292     *       is non-empty;</li>
22293     *   <li>{@code alias} is NOT an OUTPUT pseudo-table ({@code INSERTED} /
22294     *       {@code DELETED}) and does NOT match the target alias or qname —
22295     *       those stay on the slice-85 {@code TABLE_COLUMN(targetQName)}
22296     *       path (mirrors {@link #resolveReturningEdgeTarget}'s priority);</li>
22297     *   <li>a {@link RelationSource} in {@code fromSideRelations} matches the
22298     *       alias (case-insensitive) AND is {@link RelationKind#SUBQUERY}-kind
22299     *       (mirrors the slice-83 {@link #emitUpdateSubquerySourceEdges}
22300     *       filter — base TABLE-kind FROM relations are never promoted);</li>
22301     *   <li>the alias has an entry in {@code fromSideAliasToStmtIndex}
22302     *       (keyed lowercase).</li>
22303     * </ul>
22304     * Otherwise returns {@code null} and the caller keeps the TABLE_COLUMN
22305     * edge. No producer-column publication check (consistent with
22306     * {@link #emitUpdateSubquerySourceEdges}).
22307     *
22308     * <p>Invariant — the {@link RelationKind#SUBQUERY} filter and the map are
22309     * consistent by construction, so the filter never spuriously suppresses a
22310     * real producer. Every alias in {@code fromSideAliasToStmtIndex} comes from
22311     * one of three sources, all of which add only SUBQUERY-kind relations for
22312     * the matching alias:
22313     * <ul>
22314     *   <li>{@link #buildDeleteCombinedAliasToSubIdx} /
22315     *       {@link #buildUpdateCombinedAliasToSubIdx} (DELETE / UPDATE), whose
22316     *       two entry sources are (a) slice-84/83 FROM-subquery aliases —
22317     *       always SUBQUERY-kind relations — and (b) slice-106/105
22318     *       CTE-as-FROM-relation aliases, which {@code buildDeleteRelation} /
22319     *       {@code buildUpdateRelation} record as SUBQUERY-kind (NOT
22320     *       {@link RelationKind#CTE});</li>
22321     *   <li>slice-124 MERGE {@code aliasToSubIdx}, whose entries are the
22322     *       USING alias for the USING-(SELECT) subquery branch and for the
22323     *       USING-as-CTE branch — both add a SUBQUERY-kind relation. The
22324     *       USING-as-CTE branch also registers an inert bare-CTE-name key with
22325     *       no matching relation in {@code fromSideRelations}, so it is never
22326     *       reached by the per-alias relation match below.</li>
22327     * </ul>
22328     * The filter is kept as defense-in-depth (a stale TABLE-kind entry would
22329     * never be promoted) and for parity with the slice-83
22330     * {@code emitUpdateSubquerySourceEdges}, which carries the identical
22331     * SUBQUERY-kind guard.
22332     */
22333    private static Integer returningSubquerySourceIndex(
22334            String alias, String targetAlias, String targetQName,
22335            List<RelationSource> fromSideRelations,
22336            Map<String, Integer> fromSideAliasToStmtIndex) {
22337        if (alias == null || alias.isEmpty()) return null;
22338        if (fromSideAliasToStmtIndex == null
22339                || fromSideAliasToStmtIndex.isEmpty()) {
22340            return null;
22341        }
22342        // Pseudo-tables and target self-refs are never cross-stmt sources.
22343        if ("INSERTED".equals(alias) || "DELETED".equals(alias)) return null;
22344        if (targetAlias != null && targetAlias.equalsIgnoreCase(alias)) {
22345            return null;
22346        }
22347        if (targetQName != null && targetQName.equalsIgnoreCase(alias)) {
22348            return null;
22349        }
22350        if (fromSideRelations == null) return null;
22351        for (RelationSource rs : fromSideRelations) {
22352            if (rs.getAlias() == null
22353                    || !rs.getAlias().equalsIgnoreCase(alias)) {
22354                continue;
22355            }
22356            if (rs.getBinding() == null
22357                    || rs.getBinding().getKind() != RelationKind.SUBQUERY) {
22358                return null;
22359            }
22360            return fromSideAliasToStmtIndex.get(
22361                    alias.toLowerCase(Locale.ROOT));
22362        }
22363        return null;
22364    }
22365
22366    /**
22367     * Slice 85 helper — extract the bare column name from a TObjectName
22368     * leaf, honouring partToken / propertyToken / objectToken in the
22369     * order set by the parser. Returns null when no column-name token
22370     * is present.
22371     */
22372    private static String bareColumnNameOf(TObjectName node) {
22373        // partToken is the column name in `qualifier.col` form;
22374        // for bare `col`, the parser may put it on objectToken.
22375        if (node.getPartToken() != null) {
22376            return node.getPartToken().toString();
22377        }
22378        if (node.getColumnNameOnly() != null
22379                && !node.getColumnNameOnly().isEmpty()) {
22380            return node.getColumnNameOnly();
22381        }
22382        if (node.getObjectToken() != null) {
22383            return node.getObjectToken().toString();
22384        }
22385        return null;
22386    }
22387
22388    /**
22389     * Slice 85 helper — qualifier (table alias or schema-table) of a
22390     * column-name reference. Returns null for bare references.
22391     */
22392    private static String qualifierOf(TObjectName node) {
22393        // For `t.col`, parser populates objectToken=t, partToken=col.
22394        if (node.getPartToken() != null && node.getObjectToken() != null) {
22395            return node.getObjectToken().toString();
22396        }
22397        return null;
22398    }
22399
22400    /**
22401     * Slice 85 helper — pseudo-table partToken column name for OUTPUT
22402     * INSERTED.col / DELETED.col. Falls back to the bare column name
22403     * when partToken is null (e.g. raw bare reference).
22404     */
22405    private static String partColumnNameOf(TObjectName node, String fallbackColName) {
22406        if (node.getPartToken() != null) {
22407            return node.getPartToken().toString();
22408        }
22409        return fallbackColName;
22410    }
22411
22412    private static void rejectWindowFunctionInScope(gudusoft.gsqlparser.nodes.TParseTreeNode root,
22413                                                    String clauseLabel) {
22414        if (root == null) return;
22415        final boolean[] found = {false};
22416        root.acceptChildren(new TParseTreeVisitor() {
22417            @Override
22418            public void preVisit(TFunctionCall fn) {
22419                if (found[0]) return;
22420                if (fn.getWindowDef() != null) found[0] = true;
22421            }
22422        });
22423        if (found[0]) {
22424            throw new SemanticIRBuildException(
22425                    Diagnostic.error(DiagnosticCode.CLAUSE_WINDOW_FUNCTION_LEAK,
22426                    clauseLabel + " contains a window function (OVER (...)); "
22427                            + "window functions are not allowed in " + clauseLabel
22428                            + " per standard SQL", root));
22429        }
22430    }
22431
22432    private static String effectiveOutputName(TResultColumn rc) {
22433        String alias = rc.getColumnAlias();
22434        if (alias != null && !alias.isEmpty()) {
22435            return alias;
22436        }
22437        String colName = rc.getColumnNameOnly();
22438        if (colName != null && !colName.isEmpty()) {
22439            return colName;
22440        }
22441        throw new SemanticIRBuildException(
22442                Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NO_NAME,
22443                "result column " + rc + " has neither alias nor column name", rc));
22444    }
22445
22446    private static List<ColumnRef> buildFilterColumnRefs(TSelectSqlStatement select,
22447                                                         NameBindingProvider provider,
22448                                                         boolean allowPredicateSubqueries,
22449                                                         List<StatementGraph> stmtsForExtraction,
22450                                                         List<LineageEdge> lineageForExtraction,
22451                                                         Map<String, Integer> cteMapForExtraction,
22452                                                         PredicateClauseContext whereClauseContext,
22453                                                         List<SemiJoinFact> semiFactsOut,
22454                                                         Set<String> outerRelationAliases) {
22455        TWhereClause where = select.getWhereClause();
22456        if (where == null || where.getCondition() == null) {
22457            return new ArrayList<>();
22458        }
22459        Set<TExpression> extractedWhereRoots =
22460                Collections.<TExpression>emptySet();
22461        if (containsAnySubquery(where)) {
22462            if (!allowPredicateSubqueries) {
22463                // Slice 112 — non-outer SELECTs (FROM-subquery, scalar
22464                // projection subquery body, predicate body) keep the
22465                // slice-80 blanket reject. The outermost SELECT path
22466                // (slice 112) and set-op branch path (slice 113) thread
22467                // {@code allowPredicateSubqueries=true} plus the live
22468                // extraction context so the slice-23+ walker can lift
22469                // uncorrelated predicate-subquery wrappers. Inner
22470                // contexts also have earlier preflight rejecters
22471                // ({@code rejectSubqueriesInFromSubqueryBodyClauses} for
22472                // FROM-subquery bodies, {@code rejectSubqueriesInPredicateBodyClauses}
22473                // for slice-23 predicate bodies); this remains the
22474                // fallback path for any unanticipated nested SELECTs
22475                // that bypass those preflights.
22476                throw new SemanticIRBuildException(
22477                        Diagnostic.error(DiagnosticCode.WHERE_HAS_SUBQUERY_NOT_SUPPORTED,
22478                        "WHERE clause contains a subquery; subqueries in WHERE "
22479                                + "are not supported yet in nested SELECTs",
22480                        select));
22481            }
22482            // Slice 112 / 113 — outer SELECT WHERE and set-op branch
22483            // WHERE lift the slice-80 blanket subquery reject by
22484            // routing uncorrelated predicate-subquery wrappers
22485            // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
22486            // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction
22487            // pipeline refactored by slice 110 to take a
22488            // PredicateClauseContext. Slice 112 added the SELECT_WHERE
22489            // constant for outer SELECT WHERE; slice 113 adds the
22490            // SET_OP_BRANCH_WHERE constant for nested set-op branch
22491            // WHERE — both reuse the same SELECT_WHERE_* DiagnosticCode
22492            // family (a branch IS a SELECT, only nested) and differ
22493            // only in the {@code clauseLabel} for diagnostic messages.
22494            // Each extracted wrapper lands as its own
22495            // <predicate_subquery_<i>> StatementGraph BEFORE the host
22496            // outer SELECT or set-op branch in {@code stmts} (selectIdx
22497            // = stmts.size() naturally accounts for them — slice-83
22498            // dynamic-index pattern, slice 110/111 precedent).
22499            //
22500            // Remaining non-subquery refs flow into filterColumnRefs
22501            // via collectColumnRefsSkipping. Window functions in
22502            // non-subquery subtrees still reject via
22503            // rejectWindowFunctionInScopeSkipping. The {@code provider}
22504            // already carries withCteContext / withInScopeRelationColumns
22505            // from the outer build chain (slice-65 withUsingScope
22506            // preserves both facets), so the predicate body's inner
22507            // FROM cte routes through RelationKind.CTE and the body's
22508            // own lineage edge becomes STATEMENT_OUTPUT(predicateIdx,
22509            // col) -> STATEMENT_OUTPUT(cteIdx, col) instead of
22510            // TABLE_COLUMN (slice 110/111 precedent).
22511            extractedWhereRoots =
22512                    extractUncorrelatedPredicateSubqueriesFromClause(
22513                            where.getCondition(), provider,
22514                            stmtsForExtraction, lineageForExtraction,
22515                            cteMapForExtraction,
22516                            whereClauseContext,
22517                            /*correlationScope=*/ null,
22518                            semiFactsOut,
22519                            outerRelationAliases);
22520            rejectAnyRemainingSubqueriesFromClause(
22521                    where.getCondition(), extractedWhereRoots,
22522                    whereClauseContext);
22523        }
22524        // Slice 13: reject window functions in WHERE before
22525        // collectColumnRefs descends into OVER (...) and leaks
22526        // PARTITION BY / OVER ORDER BY refs into filterColumnRefs.
22527        // Slice 112 — skip extracted predicate-subquery subtrees so
22528        // inner window functions do not leak into the outer reject
22529        // (mirrors the slice-110/111 UPDATE/DELETE WHERE behaviour).
22530        rejectWindowFunctionInScopeSkipping(where, "WHERE clause",
22531                extractedWhereRoots);
22532        return collectColumnRefsSkipping(where, provider, extractedWhereRoots);
22533    }
22534
22535    /**
22536     * Slice 65 — shared visitor body that emits either the merged-key
22537     * source list (when {@code node} is an unqualified reference to a
22538     * USING merged key in the current SELECT's
22539     * {@link UsingScope}) or the resolver2-bound {@link ColumnRef}.
22540     *
22541     * <p>Used by every visitor that walks expression subtrees and
22542     * collects column refs ({@link #collectColumnRefs},
22543     * {@link #collectColumnRefsSkippingExtended}, the derived FILTER /
22544     * WITHIN-GROUP-excluding variants). Each visitor remains
22545     * responsible for its own skip-depth and nested-SELECT-depth
22546     * tracking; only the column-emit body is shared.
22547     *
22548     * <p>Behavior:
22549     * <ul>
22550     *   <li>If the node is not a column, name is null/empty/star → no-op.</li>
22551     *   <li>If the node is unqualified AND its name matches a USING
22552     *       key in {@code provider.getUsingScope()} AND that scope
22553     *       reports the reference as ambiguous (two disconnected
22554     *       classes, or a catalog-proven out-of-class same-named
22555     *       relation) → throw {@link SemanticIRBuildException}.</li>
22556     *   <li>Otherwise if the unqualified name matches a USING key
22557     *       unambiguously → emit each {@link ColumnRef} from the
22558     *       merged source list (FROM-ordered, deduped per relation).</li>
22559     *   <li>Otherwise → delegate to
22560     *       {@link NameBindingProvider#bindColumn} and emit the bound
22561     *       {@link ColumnRef}; any non-EXACT_MATCH binding records a
22562     *       reject (caller throws after collecting all rejects).</li>
22563     * </ul>
22564     *
22565     * <p>The qualifier check is the SQL-written prefix
22566     * ({@link TObjectName#getTableString()}); when present the
22567     * merged-key path is skipped so {@code a.k} continues to resolve
22568     * to {@code (a, k)} regardless of {@code k}'s USING-key status.
22569     */
22570    private static void appendMergedOrBoundColumnRef(
22571            TObjectName node,
22572            NameBindingProvider provider,
22573            LinkedHashSet<ColumnRef> refsOut,
22574            List<String> rejectsOut,
22575            boolean[] sawQualifiedRejectOut) {
22576        if (node.getDbObjectType() != EDbObjectType.column) return;
22577        String name = node.getColumnNameOnly();
22578        if (name == null || "*".equals(name)) return;
22579        UsingScope scope = provider.getUsingScope();
22580        String qualifier = node.getTableString();
22581        if ((qualifier == null || qualifier.isEmpty()) && scope.has(name)) {
22582            if (scope.isAmbiguous(name)) {
22583                throw new SemanticIRBuildException(
22584                        Diagnostic.error(DiagnosticCode.UNQUALIFIED_COLUMN_AMBIGUOUS,
22585                        "unqualified reference to '" + name + "' is ambiguous: "
22586                                + scope.ambiguityReason(name)
22587                                + "; qualify with a table alias", null));
22588            }
22589            for (ColumnRef ref : scope.mergedSourcesFor(name)) {
22590                refsOut.add(ref);
22591            }
22592            return;
22593        }
22594        ColumnBinding binding = provider.bindColumn(node);
22595        boolean unqualified = (qualifier == null || qualifier.isEmpty());
22596        // Catalog-less NATURAL JOIN degrade (GSP R6): an unqualified
22597        // reference that fails to bind exactly, when the FROM clause carries
22598        // a catalog-less NATURAL join, is a reference to the merged NATURAL
22599        // key — unambiguous under NATURAL semantics. Resolve it to the
22600        // deterministic left-side relation instead of failing the whole
22601        // analysis. Only fires for NATURAL FROM clauses; plain-join
22602        // unqualified ambiguity still rejects below.
22603        if (unqualified
22604                && (binding == null || binding.getStatus() != ResolutionStatus.EXACT_MATCH)
22605                && scope.hasNaturalDegrade()) {
22606            refsOut.add(new ColumnRef(scope.naturalDegradeFallbackAlias(), name));
22607            return;
22608        }
22609        if (binding == null) {
22610            rejectsOut.add(node + "[no binding]");
22611            if (!unqualified && sawQualifiedRejectOut != null) {
22612                sawQualifiedRejectOut[0] = true;
22613            }
22614            return;
22615        }
22616        if (binding.getStatus() != ResolutionStatus.EXACT_MATCH) {
22617            rejectsOut.add(node + "[" + binding.getStatus() + "]");
22618            if (!unqualified && sawQualifiedRejectOut != null) {
22619                sawQualifiedRejectOut[0] = true;
22620            }
22621            return;
22622        }
22623        // Slice 164 (S3): attach the source-text span of the originating
22624        // TObjectName. Slice 165 (S4): attach the GAP-5 resolution from the
22625        // binding's final table. Both are excluded from ColumnRef identity,
22626        // so the LinkedHashSet dedupe and emission order are unaffected.
22627        refsOut.add(new ColumnRef(binding.getRelationAlias(), binding.getColumnName(),
22628                null, SourceSpan.of(node), resolutionOf(binding)));
22629    }
22630
22631    /**
22632     * Slice 165 (S4) — map a {@link ColumnBinding}'s final table to an
22633     * explicit {@link ColumnResolution} (GAP 5). A non-null final-table
22634     * qualified name yields RESOLVED; a null one yields UNRESOLVED (the
22635     * resolver did not produce a base table — never fabricated). This path
22636     * only sees EXACT_MATCH bindings; non-exact / null bindings are
22637     * rejected upstream, so strict diagnostics are not loosened.
22638     */
22639    static ColumnResolution resolutionOf(ColumnBinding binding) {
22640        String finalTableQn = binding.getFinalTableQualifiedName();
22641        return (finalTableQn != null && !finalTableQn.isEmpty())
22642                ? ColumnResolution.resolved(finalTableQn)
22643                : ColumnResolution.UNRESOLVED;
22644    }
22645
22646    /**
22647     * Visit every column-typed {@link TObjectName} reachable from the given
22648     * subtree, ask the provider to bind it, and return de-duplicated
22649     * {@link ColumnRef}s. Any non-EXACT_MATCH binding aborts the build.
22650     *
22651     * <p>Slice 65: when the provider carries a non-empty
22652     * {@link UsingScope}, unqualified references that match a USING
22653     * merged key are expanded to the merged source list (one ref per
22654     * relation in the equivalence class) before delegating to
22655     * {@link NameBindingProvider#bindColumn}. See
22656     * {@link #appendMergedOrBoundColumnRef}.
22657     */
22658    private static List<ColumnRef> collectColumnRefs(gudusoft.gsqlparser.nodes.TParseTreeNode root,
22659                                                     final NameBindingProvider provider) {
22660        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
22661        final List<String> rejects = new ArrayList<>();
22662        // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at
22663        // the wrong side). Such a miss is a genuine error and must stay fatal;
22664        // only all-unqualified rejects are eligible for the join-graph degrade.
22665        final boolean[] sawQualifiedReject = {false};
22666        root.acceptChildren(new TParseTreeVisitor() {
22667            int nestedSelectDepth = 0;
22668
22669            @Override
22670            public void preVisit(TSelectSqlStatement nested) {
22671                nestedSelectDepth++;
22672            }
22673
22674            @Override
22675            public void postVisit(TSelectSqlStatement nested) {
22676                nestedSelectDepth--;
22677            }
22678
22679            @Override
22680            public void preVisit(TObjectName node) {
22681                if (nestedSelectDepth > 0) return;
22682                appendMergedOrBoundColumnRef(node, provider, refs, rejects,
22683                        sawQualifiedReject);
22684            }
22685        });
22686        if (!rejects.isEmpty()) {
22687            rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]);
22688        }
22689        return new ArrayList<>(refs);
22690    }
22691
22692    /**
22693     * Tolerant variant of {@link #collectColumnRefs} for the MySQL
22694     * self-reference DELETE path (slice 92 Codex P1 fix).
22695     *
22696     * <p>The MySQL parser populates {@code stmt.tables} with 3 entries for
22697     * {@code DELETE T1 FROM T1 WHERE id = 1} (target + {@code joins[0]} +
22698     * {@code referenceJoins[0]}). Resolver2's {@code inferredCandidates}
22699     * then sees 3 candidates for any unqualified column ref and marks the
22700     * binding as NOT_FOUND, which {@link #collectColumnRefs} rejects as
22701     * {@code COLUMN_BINDING_NON_EXACT}.
22702     *
22703     * <p>This variant emits EXACT_MATCH bindings verbatim and falls back
22704     * to the SQL-written qualifier (or {@code null} for unqualified refs)
22705     * for non-exact bindings instead of throwing. Subquery children are
22706     * not descended into (matches the strict collector's behaviour).
22707     */
22708    /**
22709     * @param fallbackRelationAlias used when the binding is non-exact and the
22710     *        SQL-written qualifier is absent; for the MySQL self-reference path
22711     *        this is {@code targetQName} so unqualified refs like
22712     *        {@code WHERE id = 1} emit {@code ColumnRef(targetName, "id")}
22713     *        instead of crashing on the non-null constraint on
22714     *        {@link ColumnRef#ColumnRef(String, String)}.
22715     */
22716    private static List<ColumnRef> collectColumnRefsTolerant(
22717            gudusoft.gsqlparser.nodes.TParseTreeNode root,
22718            final NameBindingProvider provider,
22719            final String fallbackRelationAlias) {
22720        return collectColumnRefsTolerant(root, provider, fallbackRelationAlias,
22721                Collections.<TExpression>emptySet());
22722    }
22723
22724    /**
22725     * Slice 111 — variant of the slice-92 tolerant collector that also
22726     * skips any descendants of {@code skipRoots} (extracted predicate
22727     * subquery wrappers). Mirrors the
22728     * {@link #collectColumnRefsSkipping} skipping behavior so DELETE
22729     * WHERE-side IN-SELECT / EXISTS / scalar-comparison wrappers
22730     * extracted by
22731     * {@link #extractUncorrelatedPredicateSubqueriesFromClause} are
22732     * not double-collected as outer filter refs on the MySQL self-ref
22733     * DELETE path. For the non-self-ref DELETE path the
22734     * {@link #collectColumnRefsSkipping} helper handles the same job;
22735     * this helper exists only for the slice-92 path which needs the
22736     * tolerant binding behavior to survive Resolver2's
22737     * NOT_FOUND / NON_EXACT bindings on unqualified self-ref refs.
22738     */
22739    private static List<ColumnRef> collectColumnRefsTolerant(
22740            gudusoft.gsqlparser.nodes.TParseTreeNode root,
22741            final NameBindingProvider provider,
22742            final String fallbackRelationAlias,
22743            final Set<TExpression> skipRoots) {
22744        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
22745        // Root fast path: if root IS a skipped TExpression subtree, return empty.
22746        if (root instanceof TExpression && skipRoots.contains(root)) {
22747            return new ArrayList<>(refs);
22748        }
22749        root.acceptChildren(new TParseTreeVisitor() {
22750            int nestedSelectDepth = 0;
22751            int skipDepth = 0;
22752
22753            @Override
22754            public void preVisit(TExpression e) {
22755                if (skipRoots.contains(e)) skipDepth++;
22756            }
22757
22758            @Override
22759            public void postVisit(TExpression e) {
22760                if (skipRoots.contains(e) && skipDepth > 0) skipDepth--;
22761            }
22762
22763            @Override
22764            public void preVisit(TSelectSqlStatement nested) {
22765                nestedSelectDepth++;
22766            }
22767
22768            @Override
22769            public void postVisit(TSelectSqlStatement nested) {
22770                nestedSelectDepth--;
22771            }
22772
22773            @Override
22774            public void preVisit(TObjectName node) {
22775                if (skipDepth > 0) return;
22776                if (nestedSelectDepth > 0) return;
22777                if (node.getDbObjectType() != EDbObjectType.column) return;
22778                String name = node.getColumnNameOnly();
22779                if (name == null || "*".equals(name)) return;
22780                ColumnBinding binding = provider.bindColumn(node);
22781                if (binding != null
22782                        && binding.getStatus() == ResolutionStatus.EXACT_MATCH) {
22783                    refs.add(new ColumnRef(
22784                            binding.getRelationAlias(), binding.getColumnName()));
22785                } else {
22786                    // Non-exact or null binding: prefer SQL-written qualifier;
22787                    // fall back to the single delete-target name so the
22788                    // ColumnRef non-null constraint is satisfied.
22789                    String qualifier = node.getTableString();
22790                    String alias = (qualifier != null && !qualifier.isEmpty())
22791                            ? qualifier : fallbackRelationAlias;
22792                    refs.add(new ColumnRef(alias, name));
22793                }
22794            }
22795        });
22796        return new ArrayList<>(refs);
22797    }
22798
22799    /**
22800     * Thrown when the input falls outside current builder scope or a
22801     * binding fails. Slice 67 attached a {@link Diagnostic} to every
22802     * throw site so external callers can pattern-match on
22803     * {@link DiagnosticCode} rather than parsing message text. The
22804     * legacy {@code (String)} constructor was removed in slice 67;
22805     * use one of the {@link Diagnostic#error} factories and the
22806     * {@link #SemanticIRBuildException(Diagnostic)} constructor.
22807     */
22808    public static final class SemanticIRBuildException extends RuntimeException {
22809        private final Diagnostic diagnostic;
22810
22811        public SemanticIRBuildException(Diagnostic diagnostic) {
22812            super(java.util.Objects.requireNonNull(diagnostic, "diagnostic").getMessage());
22813            this.diagnostic = diagnostic;
22814        }
22815
22816        /**
22817         * @return the structured diagnostic for this rejection. Always
22818         *         non-null after slice 67.
22819         */
22820        public Diagnostic getDiagnostic() {
22821            return diagnostic;
22822        }
22823    }
22824}