001package gudusoft.gsqlparser.ir.semantic.builder;
002
003import gudusoft.gsqlparser.EBoundaryType;
004import gudusoft.gsqlparser.EDbObjectType;
005import gudusoft.gsqlparser.EDbVendor;
006import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
007import gudusoft.gsqlparser.util.SQLUtil;
008import gudusoft.gsqlparser.EExpressionType;
009import gudusoft.gsqlparser.EGroupingSetType;
010import gudusoft.gsqlparser.EJoinType;
011import gudusoft.gsqlparser.ELimitRowType;
012import gudusoft.gsqlparser.EPseudoTableType;
013import gudusoft.gsqlparser.EQuantifierType;
014import gudusoft.gsqlparser.ESetOperatorType;
015import gudusoft.gsqlparser.ETableSource;
016import gudusoft.gsqlparser.EUniqueRowFilterType;
017import gudusoft.gsqlparser.TSourceToken;
018import gudusoft.gsqlparser.ir.semantic.ColumnRef;
019import gudusoft.gsqlparser.ir.semantic.Diagnostic;
020import gudusoft.gsqlparser.ir.semantic.DiagnosticCode;
021import gudusoft.gsqlparser.ir.semantic.DiagnosticDescriptorCatalog;
022import gudusoft.gsqlparser.ir.semantic.Severity;
023import gudusoft.gsqlparser.ir.semantic.SourceSpan;
024import gudusoft.gsqlparser.ir.semantic.FrameBound;
025import gudusoft.gsqlparser.ir.semantic.GroupingElement;
026import gudusoft.gsqlparser.nodes.TParseTreeNode;
027import gudusoft.gsqlparser.ir.semantic.LineageEdge;
028import gudusoft.gsqlparser.ir.semantic.LineageRef;
029import gudusoft.gsqlparser.ir.semantic.OutputColumn;
030import gudusoft.gsqlparser.ir.semantic.RelationKind;
031import gudusoft.gsqlparser.ir.semantic.RelationSource;
032import gudusoft.gsqlparser.ir.semantic.RecoveryMetadata;
033import gudusoft.gsqlparser.ir.semantic.RelatedLocation;
034import gudusoft.gsqlparser.ir.semantic.RowLimit;
035import gudusoft.gsqlparser.ir.semantic.RowLimitKind;
036import gudusoft.gsqlparser.ir.semantic.SemanticBuildResult;
037import gudusoft.gsqlparser.ir.semantic.SemanticProgram;
038import gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer;
039import gudusoft.gsqlparser.ir.semantic.SetOperator;
040import gudusoft.gsqlparser.ir.semantic.StatementGraph;
041import gudusoft.gsqlparser.ir.semantic.TargetRelation;
042import gudusoft.gsqlparser.ir.semantic.WindowFrame;
043import gudusoft.gsqlparser.ir.semantic.WindowSpec;
044import gudusoft.gsqlparser.ir.semantic.binding.ColumnBinding;
045import gudusoft.gsqlparser.ir.semantic.joinanalysis.ColumnResolution;
046import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinAnalysisFacts;
047import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpoint;
048import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpointKind;
049import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEntity;
050import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinGraph;
051import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinSourceSyntax;
052import gudusoft.gsqlparser.ir.semantic.joinanalysis.Predicate;
053import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateKind;
054import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateOperand;
055import gudusoft.gsqlparser.ir.semantic.joinanalysis.QueryBlockScope;
056import gudusoft.gsqlparser.ir.semantic.joinanalysis.ScopeKind;
057import gudusoft.gsqlparser.ir.semantic.joinanalysis.SemanticJoinType;
058import gudusoft.gsqlparser.ir.semantic.validation.oracle.OracleLegacyOuterJoinValidationResult;
059import gudusoft.gsqlparser.ir.semantic.validation.oracle.OracleLegacyOuterJoinValidator;
060import gudusoft.gsqlparser.ir.semantic.validation.oracle.PredicateJoinIntent;
061import gudusoft.gsqlparser.ir.semantic.binding.FromSubqueryNaming;
062import gudusoft.gsqlparser.ir.semantic.binding.NameBindingProvider;
063import gudusoft.gsqlparser.ir.semantic.binding.RelationBinding;
064import gudusoft.gsqlparser.ir.semantic.binding.UsingScope;
065import gudusoft.gsqlparser.nodes.TCTE;
066import gudusoft.gsqlparser.nodes.TCTEList;
067import gudusoft.gsqlparser.nodes.TExpression;
068import gudusoft.gsqlparser.nodes.TExpressionList;
069import gudusoft.gsqlparser.nodes.TFetchFirstClause;
070import gudusoft.gsqlparser.nodes.TFunctionCall;
071import gudusoft.gsqlparser.nodes.TLimitClause;
072import gudusoft.gsqlparser.nodes.TOffsetClause;
073import gudusoft.gsqlparser.nodes.TTopClause;
074import gudusoft.gsqlparser.nodes.TGroupBy;
075import gudusoft.gsqlparser.nodes.TGroupByItem;
076import gudusoft.gsqlparser.nodes.TGroupByItemList;
077import gudusoft.gsqlparser.nodes.TGroupingExpressionItem;
078import gudusoft.gsqlparser.nodes.TGroupingSet;
079import gudusoft.gsqlparser.nodes.TGroupingSetItem;
080import gudusoft.gsqlparser.nodes.TGroupingSetItemList;
081import gudusoft.gsqlparser.nodes.TRollupCube;
082import gudusoft.gsqlparser.nodes.TJoin;
083import gudusoft.gsqlparser.nodes.TJoinItem;
084import gudusoft.gsqlparser.nodes.TJoinItemList;
085import gudusoft.gsqlparser.nodes.TJoinList;
086import gudusoft.gsqlparser.nodes.TObjectName;
087import gudusoft.gsqlparser.nodes.TObjectNameList;
088import gudusoft.gsqlparser.nodes.TOrderBy;
089import gudusoft.gsqlparser.nodes.TOutputClause;
090import gudusoft.gsqlparser.nodes.TReturningClause;
091import gudusoft.gsqlparser.nodes.TOrderByItem;
092import gudusoft.gsqlparser.nodes.TOrderByItemList;
093import gudusoft.gsqlparser.nodes.TParseTreeVisitor;
094import gudusoft.gsqlparser.nodes.TPartitionClause;
095import gudusoft.gsqlparser.nodes.TPivotClause;
096import gudusoft.gsqlparser.nodes.TPivotInClause;
097import gudusoft.gsqlparser.nodes.TPivotedTable;
098import gudusoft.gsqlparser.nodes.TUnpivotInClause;
099import gudusoft.gsqlparser.nodes.TUnpivotInClauseItem;
100import gudusoft.gsqlparser.nodes.TResultColumn;
101import gudusoft.gsqlparser.nodes.TResultColumnList;
102import gudusoft.gsqlparser.nodes.TSelectDistinct;
103import gudusoft.gsqlparser.nodes.TTable;
104import gudusoft.gsqlparser.nodes.TWhereClause;
105import gudusoft.gsqlparser.nodes.TWindowDef;
106import gudusoft.gsqlparser.nodes.TWithinGroup;
107import gudusoft.gsqlparser.nodes.TWindowFrame;
108import gudusoft.gsqlparser.nodes.TWindowFrameBoundary;
109import gudusoft.gsqlparser.resolver2.ResolutionStatus;
110import gudusoft.gsqlparser.EInsertSource;
111import gudusoft.gsqlparser.nodes.TColumnDefinition;
112import gudusoft.gsqlparser.nodes.TColumnDefinitionList;
113import gudusoft.gsqlparser.nodes.TViewAliasClause;
114import gudusoft.gsqlparser.nodes.TViewAliasItem;
115import gudusoft.gsqlparser.nodes.TViewAliasItemList;
116import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement;
117import gudusoft.gsqlparser.stmt.TCreateViewSqlStatement;
118import gudusoft.gsqlparser.stmt.TDeleteSqlStatement;
119import gudusoft.gsqlparser.stmt.TInsertSqlStatement;
120import gudusoft.gsqlparser.stmt.TMergeSqlStatement;
121import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
122import gudusoft.gsqlparser.stmt.TUpdateSqlStatement;
123import gudusoft.gsqlparser.nodes.TMergeWhenClause;
124import gudusoft.gsqlparser.nodes.TMergeUpdateClause;
125import gudusoft.gsqlparser.nodes.TMergeInsertClause;
126
127import java.util.ArrayDeque;
128import java.util.ArrayList;
129import java.util.Collections;
130import java.util.Deque;
131import java.util.EnumSet;
132import java.util.HashMap;
133import java.util.HashSet;
134import java.util.IdentityHashMap;
135import java.util.LinkedHashMap;
136import java.util.LinkedHashSet;
137import java.util.List;
138import java.util.Locale;
139import java.util.Map;
140import java.util.Set;
141
142/**
143 * Builds a {@link SemanticProgram} from a parsed and resolved
144 * {@link TSelectSqlStatement}.
145 *
146 * <p>Current scope (after slice 9): SELECT with one or more base-table or
147 * CTE sources, optional WHERE, optional JOIN of base tables with ON
148 * conditions, optional GROUP BY (slice 6), optional WITH clause including
149 * chained CTEs (each CTE sees the ones declared strictly before it),
150 * optional FROM-clause subquery (slice 5), optional row-deduplication via
151 * {@code SELECT DISTINCT} or Oracle's {@code SELECT UNIQUE} synonym
152 * (slice 8 — see {@link StatementGraph#isDistinct()}), optional ORDER BY
153 * over physical column references or column-bearing expressions
154 * (slice 9 — see {@link StatementGraph#getOrderByColumnRefs()}).
155 * Expression projections like {@code salary * 2 AS doubled} or
156 * {@code a.x + a.y} are accepted and marked
157 * {@link OutputColumn#isDerived()}; aggregate function calls (slice 6)
158 * are flagged via {@link OutputColumn#isAggregate()}.
159 *
160 * <p>Slice 9 lifts {@code ORDER BY} for sort keys that are physical
161 * column references or expressions over them. The collected references
162 * surface as {@link StatementGraph#getOrderByColumnRefs()}. Sort
163 * direction ({@code ASC}/{@code DESC}) and null placement
164 * ({@code NULLS FIRST}/{@code NULLS LAST}) are presentation metadata
165 * and are not modelled. Ordinal forms ({@code ORDER BY 1}) and
166 * projection-alias forms ({@code SELECT id AS x ... ORDER BY x}) are
167 * rejected so the dependency information is never silently lost; a
168 * later slice can model output-position references explicitly. The
169 * canonical lineage model (slice 7) deliberately ignores ORDER BY —
170 * sort order changes presentation, not column dependency or row-set
171 * membership.
172 *
173 * <p>Row-limit clauses ({@code LIMIT}, {@code TOP}, {@code OFFSET},
174 * {@code FETCH FIRST}) are rejected statement-wide, including the
175 * SQL Server-style {@code ORDER BY ... OFFSET ... FETCH NEXT}. With
176 * a row-limit present, {@code ORDER BY} ceases to be presentation-only
177 * and starts deciding which rows survive — the canonical-model
178 * exclusion would no longer be sound, so the entire statement is out
179 * of scope until a future slice models row-limit semantics.
180 *
181 * <p>Slice 10 lifts {@code HAVING}: the predicate's column references
182 * are collected into {@link StatementGraph#getHavingColumnRefs()} via
183 * {@link #buildHavingColumnRefs}. The same visitor pattern as projection
184 * and ORDER BY rejects subqueries (scalar, EXISTS, IN-SELECT, ANY/ALL/
185 * SOME) and window functions before {@link #collectColumnRefs} runs, so
186 * inner-scope refs never leak. HAVING without GROUP BY is supported (the
187 * parser still attaches a {@link TGroupBy} node with empty items).
188 * HAVING is row-influence semantically but does not contribute to the
189 * canonical lineage model — see
190 * {@link StatementGraph#getHavingColumnRefs()} for why.
191 *
192 * <p>Slice 11 lifted uncorrelated scalar subqueries in projection;
193 * scalar bodies are extracted as their own statements via
194 * {@link #extractScalarSubqueriesAsStatements} with the synthetic-name
195 * convention {@code <scalar_subquery_<index>>}.
196 *
197 * <p>Slice 12 lifts set operations (UNION / UNION ALL / INTERSECT /
198 * INTERSECT ALL / MINUS / MINUS ALL / EXCEPT / EXCEPT ALL) at the top
199 * level and as CTE bodies. Each branch becomes its own
200 * {@link StatementGraph} with synthetic name
201 * {@code <set_op_branch_<index>>}; the outer set-op statement carries
202 * empty {@code relations} and lineage edges fan out per-position to
203 * each branch. The flatten descends the left-leaning AST iteratively
204 * (per CLAUDE.md — no recursion on {@code leftStmt}/{@code rightStmt}).
205 * See {@link #buildSetOpProgram}.
206 *
207 * <p>Slice 22 lifts window-function frame clauses
208 * ({@code ROWS}/{@code RANGE}/{@code GROUPS BETWEEN ...}); the frame
209 * unit, start bound, and optional end bound are captured in
210 * {@link WindowFrame} hung off
211 * {@link WindowSpec#getFrame()}. Frame info is presentation-only
212 * (dlineage XML harvests no frame information) and does NOT contribute
213 * to the canonical lineage model — same status as slice-13's
214 * PARTITION BY / OVER ORDER BY refs. Per-bound EXCLUDE clauses
215 * (Netezza-reachable) and non-constant offsets (PG
216 * {@code simple_object_name_t}, ANSI {@code parenthesis_t}) are still
217 * rejected.
218 *
219 * <p>Still rejected: {@code WITH RECURSIVE}, {@code DISTINCT ON (...)}
220 * and other non-{@code DISTINCT}/{@code UNIQUE} row-filters,
221 * scalar-body constant-only projections (zero column refs),
222 * correlated scalar subqueries, scalar bodies with
223 * subqueries in WHERE/JOIN ON/GROUP BY, multi-column scalar inner,
224 * scalar subqueries embedded in larger projection expressions including
225 * EXISTS-in-projection, embedded window functions in larger projection
226 * expressions, window functions in scalar-subquery bodies, window
227 * functions in WHERE/JOIN ON/GROUP BY/HAVING/ORDER BY, empty
228 * {@code OVER ()}, frame clauses with non-constant offsets (PG
229 * {@code simple_object_name_t}, ANSI {@code parenthesis_t}), frame
230 * {@code EXCLUDE} clauses (Netezza-reachable), named windows,
231 * vendor-specific window extensions ({@code FILTER (WHERE ...)},
232 * {@code WITHIN GROUP},
233 * {@code KEEP DENSE_RANK}, Hive {@code DISTRIBUTE BY}/{@code CLUSTER BY}/
234 * {@code SORT BY}/{@code PARTITION BY ... SORT (...)}), non-physical
235 * {@code PARTITION BY} / OVER {@code ORDER BY} refs (literals,
236 * subqueries, function calls, expressions, expression-alias references),
237 * window function names outside the slice-13 allowlist,
238 * (slice 63 lifts explicit {@code CROSS JOIN}, slice 64 lifts
239 * {@code JOIN ... USING (...)}, and slice 66 lifts {@code NATURAL JOIN}
240 * at outer / CTE-body / FROM-subquery-body call sites; all three stay
241 * rejected inside scalar / set-op-branch / set-op-CTE / predicate bodies;
242 * NATURAL additionally requires resolvable catalog metadata on both
243 * sides, with a side-specific reject otherwise), duplicate aliases,
244 * Oracle
245 * {@code ORDER SIBLINGS BY}, Teradata {@code ORDER BY ... RESET WHEN},
246 * row-limit clauses, ORDER BY ordinals/aliases, Teradata {@code QUALIFY}
247 * clause, set operations nested in FROM-subquery / scalar bodies,
248 * mixed-operator and mixed-{@code _ALL} set-op chains, set-op outer
249 * ORDER BY / row-limit clauses, set-op internal-node modifiers, branch
250 * column-count mismatch, set-op branches with FROM-subquery / scalar
251 * projection / their own CTE list, nested WITH on set-op CTE body. The
252 * builder fails fast outside this scope so callers see the unsupported
253 * case immediately rather than receiving a half-built IR.
254 *
255 * <p><b>API status: advanced/preview.</b> This low-level builder requires the
256 * caller to own parsing, Resolver2 configuration, statement dispatch,
257 * catalog/provider consistency, diagnostics, and recovery. New production
258 * integrations should use
259 * {@link gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer}; advanced
260 * integrations should use the atomic {@code buildResult} methods rather than
261 * deprecated {@code build} compatibility adapters.
262 */
263public final class SemanticIRBuilder {
264
265    /**
266     * Reserved name prefix for synthetic scalar-subquery body
267     * statements (slice 11). Names take the form
268     * {@code "<scalar_subquery_<index>>"}; the angle brackets ensure
269     * no collision with real CTE names or FROM-clause aliases.
270     * {@link #isScalarSyntheticName(String)} is the only authorised
271     * detector — both this builder and
272     * {@code SemanticIRProjector.BodyIndexes} use it so the convention
273     * lives in one place.
274     */
275    public static final String SCALAR_BODY_PREFIX = "<scalar_subquery_";
276
277    /**
278     * Strict regex for synthetic scalar-subquery-body names. Format is
279     * exactly {@code <scalar_subquery_<digits>>} — pinning the digits
280     * suffix and the closing angle bracket prevents a real (quoted)
281     * CTE alias that happens to begin with the prefix from being
282     * misclassified as a synthetic name and silently skipped by
283     * {@code BodyIndexes}.
284     */
285    private static final java.util.regex.Pattern SCALAR_NAME_PATTERN =
286            java.util.regex.Pattern.compile("<scalar_subquery_\\d+>");
287
288    /**
289     * True iff {@code name} is a synthetic scalar-subquery-body name
290     * created by this builder (slice 11). Used by
291     * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when
292     * building the CTE/FROM-subquery name lookup tables — scalar
293     * bodies are reached only via lineage edges, never via relations.
294     *
295     * <p>The match is strict: the name must be the full reserved
296     * pattern {@code <scalar_subquery_<digits>>}. A real CTE alias
297     * that happens to start with {@code <scalar_subquery_} but
298     * doesn't match the digits-and-closing-bracket suffix is NOT
299     * skipped (codex impl-review round-1 SHOULD 2).
300     */
301    public static boolean isScalarSyntheticName(String name) {
302        return name != null && SCALAR_NAME_PATTERN.matcher(name).matches();
303    }
304
305    /**
306     * Reserved name prefix for synthetic set-op-branch body statements
307     * (slice 12). Names take the form {@code "<set_op_branch_<index>>"};
308     * the angle brackets ensure no collision with real CTE names or
309     * FROM-clause aliases. {@link #isSetOpBranchSyntheticName(String)} is
310     * the only authorised detector — both this builder and
311     * {@code SemanticIRProjector.BodyIndexes} use it so the convention
312     * lives in one place (slice-11 process lesson #10 generalised).
313     */
314    public static final String SET_OP_BRANCH_PREFIX = "<set_op_branch_";
315
316    /**
317     * Strict regex for synthetic set-op-branch-body names. Format is
318     * exactly {@code <set_op_branch_<digits>>} — pinning the digits
319     * suffix and the closing angle bracket prevents a real (quoted) CTE
320     * alias that happens to begin with the prefix from being
321     * misclassified as a synthetic name and silently skipped by
322     * {@code BodyIndexes}.
323     */
324    private static final java.util.regex.Pattern SET_OP_BRANCH_NAME_PATTERN =
325            java.util.regex.Pattern.compile("^<set_op_branch_\\d+>$");
326
327    /**
328     * True iff {@code name} is a synthetic set-op-branch-body name
329     * created by this builder (slice 12). Used by
330     * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when
331     * building the CTE/FROM-subquery name lookup tables — set-op
332     * branches are reached only via lineage edges, never via relations.
333     *
334     * <p>The match is strict: the name must be the full reserved
335     * pattern {@code <set_op_branch_<digits>>}.
336     */
337    public static boolean isSetOpBranchSyntheticName(String name) {
338        return name != null && SET_OP_BRANCH_NAME_PATTERN.matcher(name).matches();
339    }
340
341    /**
342     * Reserved name prefix for synthetic predicate-subquery body statements
343     * (slice 23 — uncorrelated EXISTS extracted from outer-SELECT JOIN ON).
344     * Names take the form {@code "<predicate_subquery_<index>>"}; the angle
345     * brackets ensure no collision with real CTE names or FROM-clause aliases.
346     * {@link #isPredicateSubquerySyntheticName(String)} is the only authorised
347     * detector — both this builder and {@code SemanticIRProjector.BodyIndexes}
348     * use it so the convention lives in one place.
349     */
350    public static final String PREDICATE_BODY_PREFIX = "<predicate_subquery_";
351
352    /**
353     * Strict regex for synthetic predicate-subquery-body names. Format is
354     * exactly {@code <predicate_subquery_<digits>>}; pinning the digit suffix
355     * and the closing angle bracket prevents a real (quoted) CTE alias that
356     * happens to begin with the prefix from being misclassified as a synthetic
357     * name and silently skipped by {@code BodyIndexes}.
358     */
359    private static final java.util.regex.Pattern PREDICATE_BODY_NAME_PATTERN =
360            java.util.regex.Pattern.compile("<predicate_subquery_\\d+>");
361
362    /**
363     * True iff {@code name} is a synthetic predicate-subquery-body name
364     * created by this builder (slice 23). Used by
365     * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when
366     * building the CTE/FROM-subquery name lookup tables — predicate-subquery
367     * bodies are unreachable from outer (no relation edge, no lineage edge).
368     */
369    public static boolean isPredicateSubquerySyntheticName(String name) {
370        return name != null && PREDICATE_BODY_NAME_PATTERN.matcher(name).matches();
371    }
372
373    /**
374     * Aggregate function names recognized by the builder's per-output
375     * aggregate flag detection (slice 6 originated; slice 29 / slice 30
376     * extended). Treated as case-insensitive. Callers should go through
377     * {@link #isAggregateFunction(TExpression)} rather than reading this
378     * set directly.
379     *
380     * <p>Slice-29 extensions: dialect aggregates {@code listagg},
381     * {@code string_agg}, {@code group_concat}, {@code array_agg}.
382     * Slice-30 extension: {@code mode} (PostgreSQL ordered-set aggregate;
383     * admitted via the slice-29 WITHIN GROUP path under
384     * {@code findUnsupportedWithinGroupFunctionName}). Slice 30 also
385     * removes {@code mode} from {@link #WINDOW_FUNCTION_NAMES} via an
386     * explicit {@code s.remove("mode")} so the slice-13 window allowlist
387     * isn't widened — see {@link #WINDOW_FUNCTION_NAMES} JavaDoc and
388     * {@code DlineageXmlProjector.ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} for
389     * the matching window-vs-aggregate discriminator override.
390     */
391    private static final Set<String> AGGREGATE_FUNCTION_NAMES;
392    static {
393        Set<String> s = new HashSet<>();
394        s.add("count");
395        s.add("sum");
396        s.add("avg");
397        s.add("min");
398        s.add("max");
399        s.add("stddev");
400        s.add("variance");
401        s.add("var_samp");
402        s.add("var_pop");
403        s.add("stddev_samp");
404        s.add("stddev_pop");
405        // Common dialect-specific aggregates so the flag has fewer false negatives.
406        s.add("listagg");      // Oracle, PostgreSQL 16+
407        s.add("string_agg");   // PostgreSQL, SQL Server
408        s.add("group_concat"); // MySQL
409        s.add("array_agg");    // PostgreSQL, Snowflake, BigQuery
410        // Slice 30: PostgreSQL ordered-set aggregate. Unlike percentile_cont /
411        // percentile_disc / rank-family, mode() has no documented window form
412        // in any GSP-supported vendor; admitting it lets the WITHIN GROUP path
413        // accept it in JOIN ON predicate subqueries (slice 29 lift extension)
414        // AND lets DlineageXmlProjector mark its output aggregate=true.
415        // Defensive: WINDOW_FUNCTION_NAMES below subtracts mode after
416        // s.addAll(AGGREGATE_FUNCTION_NAMES) so mode() OVER (...) stays
417        // rejected by the slice-13 window allowlist.
418        s.add("mode");         // PostgreSQL ordered-set aggregate (slice 30)
419        AGGREGATE_FUNCTION_NAMES = Collections.unmodifiableSet(s);
420    }
421
422    /**
423     * Slice 42: hypothetical-set ordered-set aggregate function names that
424     * are ALSO valid window functions. Unlike {@link #AGGREGATE_FUNCTION_NAMES}
425     * these names are admitted as aggregates ONLY when the call carries a
426     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}
427     * {@link TWindowDef} attachment (Oracle / SQL Server parser style —
428     * {@code RANK(100) WITHIN GROUP (ORDER BY x.id)} produces
429     * {@code fn.getWindowDef()!=null}, {@code wd.isIncludingOverClause()==
430     * false}, {@code wd.getWithinGroup()!=null}). Any other shape — direct
431     * {@code fn.getWithinGroup()} (PG / Snowflake style),
432     * {@code fn.getWindowDef()} with {@code OVER (...)}, or no attachment
433     * at all — keeps the existing window-function classification.
434     *
435     * <p>The set is intentionally NOT merged into
436     * {@link #AGGREGATE_FUNCTION_NAMES} because that would also lift the PG
437     * direct-attachment hypothetical-set form ({@code rank(0.5) WITHIN GROUP
438     * (ORDER BY x.salary)}). Pre-plan probe ({@code /tmp/probe42/Probe42.java})
439     * confirmed PG dlineage XML for hypothetical-set is structurally
440     * indistinguishable from {@code rank() OVER (ORDER BY x)} (both emit
441     * {@code clauseType="orderby"} fdr) — admitting PG hypothetical-set
442     * would manufacture an {@code AGGREGATION_MISMATCH} divergence on the
443     * windowed form because the projector's
444     * {@code DlineageXmlProjector.isWindowFunctionResultset} cannot tell
445     * the two forms apart on PG.
446     *
447     * <p>The Oracle / MSSQL hypothetical-set form, by contrast, emits
448     * neither a {@code clauseType="orderby"} fdr nor a
449     * {@code clauseType="selectList"} fdr (probe-confirmed) — so the
450     * projector's slice-13 windowed-vs-aggregate discriminator returns
451     * {@code false} and the matching projector-side
452     * {@code AGGREGATE_FUNCTION_NAMES} entry marks the output aggregate.
453     * Their OVER form ({@code RANK() OVER (ORDER BY x.id)}) emits
454     * {@code clauseType="orderby"} as expected and stays correctly
455     * classified as windowed.
456     *
457     * <p>Vendor-gated to Oracle / MSSQL inside
458     * {@link #isAdmittedTopLevelWithinGroupAggregate} and
459     * {@link #findUnsupportedWithinGroupFunctionName}; the PG
460     * direct-attachment shape never satisfies the
461     * {@link #isWithinGroupOnlyWindowDef} predicate (because PG sets
462     * {@code fn.getWindowDef()==null}) so the carve-out cannot accidentally
463     * fire on PG.
464     */
465    private static final Set<String> HYPOTHETICAL_SET_AGGREGATE_NAMES;
466    static {
467        Set<String> s = new HashSet<>();
468        s.add("rank");
469        s.add("dense_rank");
470        s.add("percent_rank");
471        s.add("cume_dist");
472        HYPOTHETICAL_SET_AGGREGATE_NAMES = Collections.unmodifiableSet(s);
473    }
474
475    /**
476     * Slice 42: true iff {@code fn} is an Oracle / MSSQL hypothetical-set
477     * ordered-set aggregate call shape — {@code RANK} / {@code DENSE_RANK} /
478     * {@code PERCENT_RANK} / {@code CUME_DIST} with
479     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}
480     * {@link TWindowDef} attachment. Used both as a name-whitelist
481     * discriminator (so PG direct {@code fn.getWithinGroup()} cannot
482     * accidentally pass through, since PG sets {@code fn.getWindowDef()==
483     * null}) and as the {@link #isAggregateFunction} carve-out trigger.
484     */
485    private static boolean isHypotheticalSetWithinGroupCall(TFunctionCall fn) {
486        if (fn == null) return false;
487        if (!isWithinGroupOnlyWindowDef(fn.getWindowDef())) return false;
488        if (fn.getFunctionName() == null) return false;
489        String name = fn.getFunctionName().toString();
490        if (name == null || name.isEmpty()) return false;
491        return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains(
492                name.toLowerCase(Locale.ROOT));
493    }
494
495    /**
496     * Predicate-bearing join types accepted by the current builder.
497     * Slice 64: each must carry either an ON condition or a USING
498     * clause; the per-key {@code joinColumnRefs} emission happens in
499     * {@link #buildRelations} for USING and via
500     * {@link #collectColumnRefs} for ON. NATURAL, semi/anti,
501     * vendor-specific joins, and nested-join sources stay rejected so
502     * the IR cannot quietly drop a row-set predicate. The unqualified
503     * output-naming case for USING merged keys is deferred to S65.
504     */
505    private static final EnumSet<EJoinType> ALLOWED_PREDICATE_JOIN_TYPES = EnumSet.of(
506            EJoinType.inner,
507            EJoinType.left,
508            EJoinType.right,
509            EJoinType.full,
510            EJoinType.fullouter,
511            EJoinType.leftouter,
512            EJoinType.rightouter,
513            EJoinType.join,
514            // MySQL STRAIGHT_JOIN is an optimizer join-order hint with no
515            // effect on result semantics; it carries a normal ON/USING
516            // clause and mapSemanticJoinType() degrades it to INNER. Admit
517            // it on the predicate path so it emits a standard INNER join
518            // entity instead of aborting analysis with UNSUPPORTED_JOIN_TYPE.
519            EJoinType.straight
520    );
521
522    /**
523     * Slice 63 — join types admitted by the builder but that must NOT
524     * carry an ON or USING clause. Currently just {@code CROSS}; the
525     * tier exists so that future ON-less shapes can join the same path
526     * with the same shape contract. Slice 66 added a separate
527     * {@link #NATURAL_JOIN_TYPES} tier because NATURAL has its own
528     * catalog-required reject path that CROSS does not.
529     */
530    private static final EnumSet<EJoinType> ALLOWED_ON_LESS_JOIN_TYPES = EnumSet.of(
531            EJoinType.cross
532    );
533
534    /**
535     * Slice 66 — NATURAL join types. Each MUST NOT carry an ON or USING
536     * clause. Each MUST have resolvable catalog metadata on BOTH sides;
537     * a missing-catalog reject fires inside {@link #buildRelations}
538     * with a side-specific diagnostic. The shared-column list is
539     * inferred from the running {@link LeftOutputState} ∩ right's
540     * catalog and feeds into {@link #emitMergedJoinRefs} the same way
541     * a syntactically-declared USING list does.
542     */
543    private static final EnumSet<EJoinType> NATURAL_JOIN_TYPES = EnumSet.of(
544            EJoinType.natural,
545            EJoinType.natural_inner,
546            EJoinType.natural_left,
547            EJoinType.natural_right,
548            EJoinType.natural_leftouter,
549            EJoinType.natural_rightouter,
550            EJoinType.natural_full,
551            EJoinType.natural_fullouter
552    );
553
554    private static boolean isNaturalJoinType(EJoinType jt) {
555        return jt != null && NATURAL_JOIN_TYPES.contains(jt);
556    }
557
558    /**
559     * SQL Server lateral joins: {@code CROSS APPLY} / {@code OUTER APPLY}.
560     * The right operand is a correlated derived table (subquery or
561     * table-valued function) whose correlation lives <em>inside</em> the
562     * right operand (its WHERE clause for the subquery form, its function
563     * arguments for the TVF form) rather than on an ON/USING clause. So
564     * APPLY is admitted on the ON-less tier (like {@code CROSS}); it MUST
565     * NOT carry ON/USING. {@code mapSemanticJoinType} degrades
566     * {@code crossapply -> INNER} and {@code outerapply -> LEFT}; the
567     * lateral nature is carried by {@link JoinEntity#isLateral()} (mirrors
568     * how NATURAL is carried by {@code isNatural()} rather than its own
569     * join type). The correlation is preserved by the normal child
570     * StatementGraph the right derived table already produces, so it is
571     * NOT lifted onto the join edge (the predicate references the inner
572     * relation and the outer scope, not the right endpoint).
573     */
574    private static final EnumSet<EJoinType> LATERAL_JOIN_TYPES = EnumSet.of(
575            EJoinType.crossapply,
576            EJoinType.outerapply
577    );
578
579    private static boolean isLateralJoinType(EJoinType jt) {
580        return jt != null && LATERAL_JOIN_TYPES.contains(jt);
581    }
582
583    /**
584     * True when a join operand is an ANSI / PostgreSQL {@code LATERAL} derived
585     * table or table-valued function. Unlike the SQL Server APPLY forms (which
586     * are encoded in the join <em>type</em> and matched by
587     * {@link #isLateralJoinType}), the {@code LATERAL} keyword sits on the right
588     * operand itself: the parser captures it as that {@link TTable}'s start
589     * token (covers {@code , LATERAL (...)}, {@code CROSS JOIN LATERAL (...)},
590     * {@code JOIN LATERAL (...) ON ...}, and {@code LATERAL func(...)}). A plain
591     * derived table starts with {@code (} and a base relation with its name, so
592     * this never false-matches a non-lateral operand.
593     */
594    private static boolean isLateralTable(TTable t) {
595        if (t == null) {
596            return false;
597        }
598        // LATERAL always precedes a derived table / table-valued function /
599        // xmltable, never a base relation. So a plain object-name table that
600        // happens to be named `lateral` (legal where LATERAL is unreserved, or
601        // when quoted) is NOT a lateral join even though its start token reads
602        // `lateral` — exclude the base-relation shape before the token check.
603        if (t.getTableType() == ETableSource.objectname) {
604            return false;
605        }
606        TSourceToken st = t.getStartToken();
607        return st != null && "LATERAL".equalsIgnoreCase(st.toString());
608    }
609
610    private SemanticIRBuilder() {}
611
612    /**
613     * Session owned by one atomic {@code build*Result} invocation. Deep static
614     * builder helpers route diagnostics to the active session, but the facts
615     * leave the builder only through {@link SemanticBuildResult}; consumers do
616     * not coordinate a second drain operation.
617     */
618    private static final ThreadLocal<BuildSession> ACTIVE_BUILD_SESSION =
619            new ThreadLocal<>();
620
621    /**
622     * Compatibility snapshot for legacy {@code build* + drain} callers. It is
623     * populated only after an atomic result is complete and is never consumed
624     * by {@link SqlSemanticAnalyzer} or another core pipeline stage.
625     */
626    private static final ThreadLocal<List<Diagnostic>> LEGACY_BUILD_DIAGNOSTICS =
627            new ThreadLocal<>();
628
629    private static void recordBuildDiagnostic(Diagnostic diagnostic) {
630        BuildSession session = ACTIVE_BUILD_SESSION.get();
631        if (session == null) {
632            throw new IllegalStateException(
633                    "build diagnostic emitted outside an active build session");
634        }
635        session.report(diagnostic);
636    }
637
638    private static void recordBuildWarning(Diagnostic warning) {
639        if (warning.getSeverity() != Severity.WARN) {
640            throw new IllegalArgumentException(
641                    "recordBuildWarning requires WARN severity");
642        }
643        recordBuildDiagnostic(warning);
644    }
645
646    /** Record a recovered subject after its root diagnostic was reported. */
647    private static void recordBuildRecovery(RecoveryMetadata.Entry entry) {
648        BuildSession session = ACTIVE_BUILD_SESSION.get();
649        if (session == null) {
650            throw new IllegalStateException(
651                    "build recovery emitted outside an active build session");
652        }
653        session.recover(entry);
654    }
655
656    /**
657     * Clear the current thread's legacy compatibility snapshot. New callers
658     * should use a {@code build*Result} method and need no clear/drain calls.
659     */
660    public static void clearBuildDiagnostics() {
661        LEGACY_BUILD_DIAGNOSTICS.remove();
662    }
663
664    /**
665     * Return the current thread's legacy compatibility snapshot without
666     * clearing it. Each legacy top-level build replaces the snapshot, so an
667     * undrained prior invocation cannot contaminate the next one.
668     */
669    public static List<Diagnostic> pendingBuildDiagnostics() {
670        List<Diagnostic> snapshot = LEGACY_BUILD_DIAGNOSTICS.get();
671        if (snapshot == null || snapshot.isEmpty()) {
672            return Collections.emptyList();
673        }
674        return new ArrayList<>(snapshot);
675    }
676
677    /**
678     * Read and clear the current thread's legacy compatibility snapshot.
679     * Production analysis consumes {@link SemanticBuildResult} directly.
680     */
681    public static List<Diagnostic> drainBuildDiagnostics() {
682        List<Diagnostic> pending = pendingBuildDiagnostics();
683        LEGACY_BUILD_DIAGNOSTICS.remove();
684        return pending;
685    }
686
687    /**
688     * Catalog-less {@code NATURAL JOIN} degrade — record the
689     * {@link DiagnosticCode#NATURAL_CATALOG_REQUIRED} reject as a non-fatal
690     * {@link Severity#WARN} instead of throwing. The join still appears in
691     * the join graph as a bare NATURAL entity (empty conditions / USING
692     * columns, both endpoints populated); only the shared-column predicates
693     * are absent. Shared between the SELECT-side {@link #buildRelations} and
694     * the joined-UPDATE FROM path so the wording stays identical to the
695     * former fatal diagnostic.
696     */
697    private static void recordNaturalDegradeWarning(NaturalKeyResult r, TJoinItem item) {
698        recordBuildWarning(Diagnostic.warn(
699                DiagnosticCode.NATURAL_CATALOG_REQUIRED,
700                formatNaturalCatalogReject(r), item));
701    }
702
703    /**
704     * Terminate a clause column-collection pass that accumulated non-exact
705     * (unresolvable) column bindings. Normally fatal
706     * ({@link DiagnosticCode#COLUMN_BINDING_NON_EXACT}); but two structural
707     * anchors degrade it to a non-fatal warning (GSP R6) — record the warning
708     * and let the build continue (the unresolved refs are simply omitted,
709     * never fabricated) instead of discarding the whole statement graph:
710     *
711     * <ol>
712     *   <li><b>USING join anchor</b> — the FROM clause carries a
713     *       {@code JOIN ... USING} (the scope has merged keys). The join
714     *       structure is fully known and only the unqualified non-key SELECT/
715     *       WHERE columns are unplaceable without a catalog.</li>
716     *   <li><b>Join-graph anchor</b> — the FROM clause resolved a fully-built
717     *       join graph (two or more endpoints from explicit ON / CROSS / comma
718     *       predicates; {@link NameBindingProvider#hasJoinStructureAnchor()}).
719     *       An explicit ON / CROSS join graph is just as fully determined as a
720     *       USING join — the only thing missing without a catalog is
721     *       <em>which side</em> an unqualified column belongs to, which is
722     *       exactly the non-fatal case the USING branch already tolerates.</li>
723     * </ol>
724     *
725     * <p>BOTH anchors fire ONLY when every reject is an <em>unqualified</em>
726     * column miss ({@code allRejectsUnqualified}). A qualified reference that
727     * fails to bind (e.g. {@code b.id} pointing at the wrong side, or an
728     * unregistered catalog column under {@code JOIN ... USING}) is a genuine
729     * error, not a which-side ambiguity, and stays fatal under either anchor.
730     *
731     * <p>Catalog-less NATURAL joins are handled earlier by the
732     * {@code hasNaturalDegrade()} fallback. The fatal path remains for the
733     * genuinely structureless case (no resolvable join graph) and for any
734     * qualified-reference miss.
735     */
736    private static void rejectNonExactBindings(List<String> rejects,
737                                               NameBindingProvider provider,
738                                               boolean allRejectsUnqualified) {
739        if (provider != null && allRejectsUnqualified
740                && provider.getUsingScope().hasMergedKeys()) {
741            recordBuildWarning(Diagnostic.warn(DiagnosticCode.COLUMN_BINDING_NON_EXACT,
742                    "non-exact column bindings (USING join, no aliases): " + rejects, null));
743            return;
744        }
745        if (provider != null && provider.hasJoinStructureAnchor()
746                && allRejectsUnqualified) {
747            recordBuildWarning(Diagnostic.warn(DiagnosticCode.COLUMN_BINDING_NON_EXACT,
748                    "non-exact column bindings (join graph resolved, no aliases/catalog): "
749                            + rejects, null));
750            return;
751        }
752        throw new SemanticIRBuildException(Diagnostic.error(
753                DiagnosticCode.COLUMN_BINDING_NON_EXACT,
754                "non-exact column bindings: " + rejects, null));
755    }
756
757    /**
758     * Options in force for the {@code build*} call currently running on this
759     * thread. Set by the three-argument {@code build*} overloads for exactly
760     * the duration of their delegate call and restored (not merely cleared)
761     * afterwards, so a nested build cannot leak its options to the caller and
762     * a two-argument call always sees {@link SemanticIRBuildOptions#defaults()}.
763     *
764     * <p>Thread-local rather than a threaded parameter because the deep
765     * recursion between the {@code build*} entry points and the nested-body
766     * extractors is many frames wide. It is restored at the same atomic build
767     * boundary as {@link #ACTIVE_BUILD_SESSION}.
768     */
769    private static final ThreadLocal<SemanticIRBuildOptions> ACTIVE_OPTIONS =
770            new ThreadLocal<>();
771
772    /** @return the options for the in-flight build; never null. */
773    private static SemanticIRBuildOptions activeOptions() {
774        SemanticIRBuildOptions o = ACTIVE_OPTIONS.get();
775        return o == null ? SemanticIRBuildOptions.defaults() : o;
776    }
777
778    /**
779     * Run {@code body} with {@code options} in force, restoring whatever was
780     * previously in force on this thread (including "nothing") on the way out,
781     * on both the normal and the exceptional path.
782     */
783    private static SemanticProgram withOptions(
784            SemanticIRBuildOptions options,
785            java.util.function.Supplier<SemanticProgram> body) {
786        if (options == null) {
787            throw new IllegalArgumentException("options must not be null");
788        }
789        SemanticIRBuildOptions previous = ACTIVE_OPTIONS.get();
790        ACTIVE_OPTIONS.set(options);
791        try {
792            return body.get();
793        } finally {
794            if (previous == null) {
795                ACTIVE_OPTIONS.remove();
796            } else {
797                ACTIVE_OPTIONS.set(previous);
798            }
799        }
800    }
801
802    /** Execute one build and publish its program and diagnostics atomically. */
803    private static SemanticBuildResult executeBuild(
804            SemanticIRBuildOptions options,
805            java.util.function.Supplier<SemanticProgram> body) {
806        if (options == null) {
807            throw new IllegalArgumentException("options must not be null");
808        }
809        BuildSession previous = ACTIVE_BUILD_SESSION.get();
810        BuildSession session = new BuildSession();
811        ACTIVE_BUILD_SESSION.set(session);
812        try {
813            SemanticProgram program = withOptions(options, body);
814            return session.completed(program);
815        } catch (SemanticIRBuildException ex) {
816            session.reportMissing(ex.getDiagnostics());
817            return session.rejected();
818        } finally {
819            if (previous == null) {
820                ACTIVE_BUILD_SESSION.remove();
821            } else {
822                ACTIVE_BUILD_SESSION.set(previous);
823            }
824        }
825    }
826
827    /** Adapt an atomic result to the legacy build-and-drain protocol. */
828    private static SemanticProgram publishLegacyResult(SemanticBuildResult result) {
829        List<Diagnostic> diagnostics = result.getDiagnostics();
830        // A legacy build can be invoked re-entrantly from a provider callback.
831        // Its caller receives only a SemanticProgram, so merge the completed
832        // atomic facts into the restored parent session; otherwise the inner
833        // diagnostics would survive only in the legacy snapshot that the
834        // outer atomic/analyzer pipeline deliberately never drains.
835        BuildSession parent = ACTIVE_BUILD_SESSION.get();
836        // A rejected nested invocation has no semantic payload belonging to
837        // the parent. If its exception escapes, executeBuild records it on the
838        // normal exceptional path; if the callback handles it, it must remain
839        // isolated from the otherwise-successful parent result.
840        if (parent != null && result.getRecoveredProgram() != null) {
841            parent.absorb(result);
842        }
843        if (diagnostics.isEmpty()) {
844            LEGACY_BUILD_DIAGNOSTICS.remove();
845        } else {
846            LEGACY_BUILD_DIAGNOSTICS.set(Collections.unmodifiableList(
847                    new ArrayList<>(diagnostics)));
848        }
849
850        SemanticProgram program = result.getRecoveredProgram();
851        if (program != null) {
852            return program;
853        }
854        throw SemanticIRBuildException.withDiagnostics(diagnostics);
855    }
856
857    private static final class BuildSession {
858        private final List<Diagnostic> diagnostics = new ArrayList<>();
859        private final List<RecoveryMetadata.Entry> recoveryEntries =
860                new ArrayList<>();
861
862        private void report(Diagnostic diagnostic) {
863            diagnostics.add(java.util.Objects.requireNonNull(
864                    diagnostic, "diagnostic"));
865        }
866
867        private void reportMissing(List<Diagnostic> additional) {
868            for (Diagnostic diagnostic : additional) {
869                if (!containsIdentity(diagnostics, diagnostic)) {
870                    report(diagnostic);
871                }
872            }
873        }
874
875        private void absorb(SemanticBuildResult nested) {
876            reportMissing(nested.getDiagnostics());
877            for (RecoveryMetadata.Entry entry
878                    : nested.getRecoveryMetadata().getEntries()) {
879                if (!recoveryEntries.contains(entry)) {
880                    recover(entry);
881                }
882            }
883        }
884
885        private static boolean containsIdentity(List<Diagnostic> values,
886                                                Diagnostic candidate) {
887            for (Diagnostic value : values) {
888                if (value == candidate) {
889                    return true;
890                }
891            }
892            return false;
893        }
894
895        private void recover(RecoveryMetadata.Entry entry) {
896            RecoveryMetadata.Entry nonNull = java.util.Objects.requireNonNull(
897                    entry, "entry");
898            if (!diagnostics.contains(nonNull.getRootDiagnostic())) {
899                throw new IllegalArgumentException(
900                        "recovery root diagnostic must be reported first");
901            }
902            recoveryEntries.add(nonNull);
903        }
904
905        private SemanticBuildResult completed(SemanticProgram program) {
906            if (recoveryEntries.isEmpty()) {
907                return SemanticBuildResult.completed(program, diagnostics);
908            }
909            return SemanticBuildResult.recovered(program, diagnostics,
910                    RecoveryMetadata.of(recoveryEntries));
911        }
912
913        private SemanticBuildResult rejected() {
914            return SemanticBuildResult.rejected(diagnostics);
915        }
916    }
917
918    /**
919     * Build a SELECT under caller-supplied {@code options}.
920     *
921     * <p>Identical to {@link #build(TSelectSqlStatement, NameBindingProvider)}
922     * except for the knobs in {@code options}; pass
923     * {@link SemanticIRBuildOptions#defaults()} for the historical behaviour.
924     * With
925     * {@link SemanticIRBuildOptions#withDegradeUnsupportedNestedBlocks(boolean)
926     * degrade mode} on, an unsupported predicate-subquery body no longer
927     * aborts the call: it becomes an
928     * {@link StatementGraph#KIND_UNANALYZED} placeholder plus a
929     * {@link DiagnosticCode#NESTED_BLOCK_UNANALYZED} warning, and the host
930     * block's join graph is returned intact.
931     */
932    public static SemanticBuildResult buildResult(
933            TSelectSqlStatement select, NameBindingProvider provider,
934            SemanticIRBuildOptions options) {
935        return executeBuild(options, () -> buildImpl(select, provider));
936    }
937
938    /** Build a SELECT and atomically return its program and diagnostics. */
939    public static SemanticBuildResult buildResult(
940            TSelectSqlStatement select, NameBindingProvider provider) {
941        return buildResult(select, provider, SemanticIRBuildOptions.defaults());
942    }
943
944    /**
945     * Legacy compatibility API. Prefer {@link #buildResult}; this method
946     * publishes the same diagnostics through {@link #drainBuildDiagnostics()}.
947     */
948    @Deprecated
949    public static SemanticProgram build(TSelectSqlStatement select,
950                                        NameBindingProvider provider,
951                                        SemanticIRBuildOptions options) {
952        clearBuildDiagnostics();
953        return publishLegacyResult(buildResult(select, provider, options));
954    }
955
956    @Deprecated
957    public static SemanticProgram build(TSelectSqlStatement select,
958                                        NameBindingProvider provider) {
959        clearBuildDiagnostics();
960        return publishLegacyResult(buildResult(select, provider));
961    }
962
963    private static SemanticProgram buildImpl(TSelectSqlStatement select,
964                                             NameBindingProvider provider) {
965        if (select == null) {
966            throw new IllegalArgumentException("select must not be null");
967        }
968        if (provider == null) {
969            throw new IllegalArgumentException("provider must not be null");
970        }
971        List<StatementGraph> stmts = new ArrayList<>();
972        List<LineageEdge> lineage = new ArrayList<>();
973        Map<String, Integer> cteNameToStatementIndex = new HashMap<>();
974        Map<String, List<String>> ctePublishedColumns = new HashMap<>();
975        int outerIndex = buildSelectProgramInto(select, provider, stmts, lineage,
976                cteNameToStatementIndex, ctePublishedColumns);
977        // Slice 170 (S9): attach query-block scope metadata (GAP 4).
978        attachQueryBlockScopes(stmts, outerIndex, cteNameToStatementIndex);
979        return new SemanticProgram(stmts, lineage);
980    }
981
982    /**
983     * Slice 170 (S9) — populate {@link QueryBlockScope} on each emitted
984     * SELECT-program block (GAP 4): a stable {@code statementIndex}, the
985     * {@link ScopeKind}, the CTE/derived name when present, and a
986     * {@code parentStatementIndex} pointing at the <em>lexical owner</em>.
987     *
988     * <p>The flat list order is preserved (mutates entries in place via the
989     * immutable {@code withJoinAnalysisFacts} copier). The outer statement
990     * ({@code outerIndex}) is the {@link ScopeKind#MAIN} block with a null
991     * parent. CTE bodies (known via {@code cteNameToStatementIndex}) and
992     * other bodies emitted before the outer take the outer as their
993     * top-level lexical owner.
994     *
995     * <p><strong>V1 scope (plan Q-CTE):</strong> {@code parentStatementIndex}
996     * is the top-level owning statement. Precise multi-level nested-body
997     * parent edges (e.g. a subquery inside a CTE body) are a documented
998     * follow-up; for the common single-level case (CTEs / FROM-subqueries
999     * directly under one SELECT) this is exact.
1000     */
1001    private static void attachQueryBlockScopes(List<StatementGraph> stmts,
1002                                               int outerIndex,
1003                                               Map<String, Integer> cteNameToStatementIndex) {
1004        if (stmts.isEmpty()) return;
1005        java.util.Set<Integer> cteIndices = new java.util.HashSet<Integer>(
1006                cteNameToStatementIndex.values());
1007        for (int i = 0; i < stmts.size(); i++) {
1008            StatementGraph sg = stmts.get(i);
1009            ScopeKind kind;
1010            Integer parent;
1011            if (i == outerIndex) {
1012                kind = ScopeKind.MAIN;
1013                parent = null;
1014            } else if (cteIndices.contains(Integer.valueOf(i))) {
1015                kind = ScopeKind.CTE;
1016                parent = Integer.valueOf(outerIndex);
1017            } else {
1018                // A FROM/scalar/predicate body emitted before the outer.
1019                kind = ScopeKind.DERIVED_TABLE;
1020                parent = Integer.valueOf(outerIndex);
1021            }
1022            // Slice 179 (R5): the block's own source span, set at the SELECT
1023            // construction site, follows the existing 1-based/end-exclusive
1024            // SourceSpan convention. Null when not threaded (e.g. DML).
1025            QueryBlockScope scope = new QueryBlockScope(
1026                    i, parent, kind, sg.getName(), sg.getSourceSpan());
1027            stmts.set(i, sg.withJoinAnalysisFacts(
1028                    sg.getJoinAnalysisFacts().withQueryBlockScope(scope)));
1029        }
1030    }
1031
1032    /**
1033     * Slice 122 — build a SELECT program into a CALLER-OWNED
1034     * {@code stmts}/{@code lineage} pair, seeded with a caller-supplied
1035     * outer CTE context. {@link #build} delegates here with fresh empty
1036     * maps (behaviour-preserving extraction of its former inline body).
1037     *
1038     * <p>The MERGE USING-subquery branch in {@link #buildMerge} delegates
1039     * here with a COPY of the outer MERGE's {@code cteNameToStatementIndex}
1040     * (and {@code ctePublishedColumns}) so a USING body that references an
1041     * outer MERGE CTE
1042     * ({@code WITH cte AS (...) MERGE INTO t USING (SELECT ... FROM cte) s})
1043     * resolves the reference as CTE-kind and emits the cross-statement
1044     * {@code STATEMENT_OUTPUT(usingIdx, col) -> STATEMENT_OUTPUT(cteIdx, col)}
1045     * edge into the already-built CTE body. Building directly into the
1046     * shared lists means the inner statements land at their final absolute
1047     * indices with NO rebase — so edges to the pre-seeded outer CTE
1048     * reference the correct index (the pre-slice-122
1049     * {@code build()+rebaseLineageEdge} path could not, because the rebase
1050     * offset would wrongly shift an outer-CTE index too). This closes the
1051     * slice-110 documented parity gap.
1052     *
1053     * <p>{@code hasOuterCteListAlreadyProcessed} is derived from THIS
1054     * select's own CTE list only — the seeded outer CTEs are not declared
1055     * by {@code select.getCteList()}, so they must not trigger the
1056     * nested-WITH reject in {@code buildSelectStatementImpl}.
1057     *
1058     * <p>Slice 108 Phase 0 — the two helper calls (CTE walker +
1059     * outer-SELECT body) were extracted from {@link #build} into
1060     * {@link #buildSelectCteList} / {@link #buildSelectBodyAfterCteWalk};
1061     * calling them with {@code allowShadowOverride=false} /
1062     * {@code additionalAllCteNames=null} reproduces the prior inline walker.
1063     *
1064     * @return the index, into the supplied {@code stmts} list, of the
1065     *         program's final (outer / merged-set-op) statement — i.e. the
1066     *         statement a consumer should treat as this SELECT's result.
1067     */
1068    private static int buildSelectProgramInto(
1069            TSelectSqlStatement select,
1070            NameBindingProvider provider,
1071            List<StatementGraph> stmts,
1072            List<LineageEdge> lineage,
1073            Map<String, Integer> cteNameToStatementIndex,
1074            Map<String, List<String>> ctePublishedColumns) {
1075        TCTEList cteList = select.getCteList();
1076        boolean hasOuterCteList = cteList != null && cteList.size() > 0;
1077        buildSelectCteList(cteList, provider, stmts, lineage,
1078                cteNameToStatementIndex, ctePublishedColumns,
1079                /*allowShadowOverride=*/ false,
1080                /*additionalAllCteNames=*/ null);
1081        buildSelectBodyAfterCteWalk(select, provider, stmts, lineage,
1082                cteNameToStatementIndex, ctePublishedColumns,
1083                /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteList);
1084        return stmts.size() - 1;
1085    }
1086
1087    /**
1088     * Slice 108 — walk a SELECT-side WITH clause and append each CTE body
1089     * to {@code stmts} as a preceding statement. Extracted from the inline
1090     * walker that previously lived in {@link #build} (lines ~516–663 pre-
1091     * slice-108). Mirrors the slice-101 {@link #buildMergeCteList},
1092     * slice-105 {@link #buildUpdateCteList}, and slice-106
1093     * {@link #buildDeleteCteList} helpers.
1094     *
1095     * <p>Phase 0 (behaviour-preserving refactor): {@code allowShadowOverride
1096     * = false} and {@code additionalAllCteNames = null} reproduce the
1097     * pre-slice-108 inline walker byte-for-byte.
1098     *
1099     * <p>Phase 1 (shadow admit): {@code allowShadowOverride = true} enables
1100     * the mixed outer+inner WITH on INSERT shadow case (slice 108). When
1101     * called from {@link #buildInsert}, the OUTER pass runs with
1102     * {@code allowShadowOverride=false}, populating
1103     * {@code cteNameToStatementIndex} and {@code ctePublishedColumns} with
1104     * outer CTE bindings. The INNER pass then runs with
1105     * {@code allowShadowOverride=true} and
1106     * {@code additionalAllCteNames=outerAllNames}. The inner pass:
1107     * <ul>
1108     *   <li>uses a fresh local {@code localVisibleSoFar} for intra-list
1109     *       duplicate detection (so {@code DUPLICATE_CTE_NAME} still
1110     *       fires for inner {@code x, x} even when outer also declares
1111     *       {@code x});</li>
1112     *   <li>snapshots {@code cteNameToStatementIndex.keySet()} at entry
1113     *       into {@code outerKeysSnapshot}; the union
1114     *       {@code outerKeysSnapshot ∪ localVisibleSoFar} drives BOTH
1115     *       {@link #rejectForwardCteReferences} AND
1116     *       {@link NameBindingProvider#withCteContext} (round-2 codex
1117     *       BLOCKER 3 fix — keeps inner-y references to outer-x from
1118     *       being falsely flagged as forward references);</li>
1119     *   <li>on collision with an outer entry (after a successful body
1120     *       build), {@link Map#put} overrides the
1121     *       {@code cteNameToStatementIndex} and {@code ctePublishedColumns}
1122     *       entries so the source SELECT sees the INNER body. The OUTER
1123     *       body stays in {@code stmts[]} at its earlier position; its
1124     *       cteMap entry is just no longer referenced by name (PG nested-
1125     *       WITH inner-shadows-outer semantics).</li>
1126     * </ul>
1127     *
1128     * <p>{@code additionalAllCteNames} is unioned into the per-call
1129     * {@code allCteNames} that {@link #rejectForwardCteReferences} consults
1130     * (round-1 codex BLOCKER 2 fix — keeps each scope's forward-ref check
1131     * narrow so an outer CTE body referencing a base-table whose name
1132     * happens to coincide with an inner CTE name does NOT falsely flag).
1133     */
1134    private static void buildSelectCteList(
1135            TCTEList cteList,
1136            NameBindingProvider provider,
1137            List<StatementGraph> stmts,
1138            List<LineageEdge> lineage,
1139            Map<String, Integer> cteNameToStatementIndex,
1140            Map<String, List<String>> ctePublishedColumns,
1141            boolean allowShadowOverride,
1142            Set<String> additionalAllCteNames) {
1143        if (cteList == null || cteList.size() == 0) {
1144            return;
1145        }
1146        rejectRecursiveCtes(cteList);
1147
1148        // Per-call allCteNames for rejectForwardCteReferences. The optional
1149        // additionalAllCteNames extends this scope (Phase 1: outer names
1150        // visible to inner CTE body forward-ref checks). Phase 0 path passes
1151        // null, so this is just collectCteNames(cteList).
1152        Set<String> allCteNames;
1153        if (additionalAllCteNames != null && !additionalAllCteNames.isEmpty()) {
1154            allCteNames = new HashSet<>(collectCteNames(cteList));
1155            allCteNames.addAll(additionalAllCteNames);
1156        } else {
1157            allCteNames = collectCteNames(cteList);
1158        }
1159
1160        // Phase 1: snapshot outer-scope CTE names at entry so subsequent
1161        // iterations of this list always see the FULL outer scope for
1162        // forward-ref classification and withCteContext, even if a shadow
1163        // override later overwrites a name's cteMap entry.
1164        Set<String> outerKeysSnapshot = allowShadowOverride
1165                ? new HashSet<>(cteNameToStatementIndex.keySet())
1166                : null;
1167
1168        // Build each CTE body left-to-right. Each CTE sees CTEs declared
1169        // strictly before it (standard SQL chain semantics, slice 4).
1170        // Slice 18: CTE bodies accept FROM-subqueries (mirroring the
1171        // outer-SELECT extraction path) AND scalar-subquery projections
1172        // (slice 11): for each CTE body, FROM-subqueries are extracted
1173        // first, then scalar bodies, then the CTE body is built/appended.
1174        // The per-CTE-body subqueryAliasToIndex is local to the iteration
1175        // so different CTE bodies cannot collide on FROM-subquery aliases.
1176        // Slice 60: running map of "CTE name → published column names"
1177        // for star expansion. Each CTE's published columns are added
1178        // AFTER its body is built so a CTE cannot self-reference and
1179        // forward references (rejected earlier) cannot leak through.
1180        // Set-op CTE bodies use the merged StatementGraph.outputColumns.
1181        // For non-set-op CTE bodies the column names also come from
1182        // StatementGraph.outputColumns. Explicit CTE column lists are
1183        // rejected at the star expander, not at populate time.
1184        Set<String> localVisibleSoFar = new HashSet<>();
1185        for (int i = 0; i < cteList.size(); i++) {
1186            TCTE cte = cteList.getCTE(i);
1187            String cteName = cte.getTableName().toString();
1188            String cteNameLower = cteName.toLowerCase(Locale.ROOT);
1189            // Slice 15 MUST 9 / round-4 MUST 1: reject duplicate CTE
1190            // names BEFORE rejectForwardCteReferences so duplicate-name
1191            // diagnostics are not preempted by forward-reference
1192            // diagnostics. cteNameToStatementIndex is keyed lower-case;
1193            // a duplicate entry would silently overwrite the earlier
1194            // body and leave OUTER_REFERENCE-of-CTE pointing at the
1195            // wrong statement.
1196            //
1197            // Slice 108: intra-list duplicate check uses localVisibleSoFar
1198            // (NOT outerKeysSnapshot) so an inner CTE shadowing an outer
1199            // CTE is admitted while inner-x, inner-x stays rejected (round-1
1200            // codex BLOCKER 1 fix).
1201            if (localVisibleSoFar.contains(cteNameLower)) {
1202                throw new SemanticIRBuildException(
1203                        Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME,
1204                        "duplicate CTE name '" + cteName
1205                                + "' in WITH clause; CTE names must be unique", cte));
1206            }
1207            // Slice 108: effectiveVisible = outerKeysSnapshot ∪ localVisibleSoFar.
1208            // Drives BOTH rejectForwardCteReferences AND
1209            // bodyProvider.withCteContext so inner-y body referencing outer-x
1210            // is admitted (round-2 codex BLOCKER 3 fix).
1211            Set<String> effectiveVisible;
1212            if (outerKeysSnapshot != null) {
1213                if (outerKeysSnapshot.isEmpty()) {
1214                    effectiveVisible = localVisibleSoFar;
1215                } else if (localVisibleSoFar.isEmpty()) {
1216                    effectiveVisible = outerKeysSnapshot;
1217                } else {
1218                    effectiveVisible = new HashSet<>(outerKeysSnapshot);
1219                    effectiveVisible.addAll(localVisibleSoFar);
1220                }
1221            } else {
1222                effectiveVisible = localVisibleSoFar;
1223            }
1224            rejectForwardCteReferences(cte, allCteNames, effectiveVisible);
1225            // Slice 60: bodyProvider gets the CTE-context narrowing
1226            // first; the effective-alias-keyed in-scope map is
1227            // applied LATER, after the body's own FROM-subqueries
1228            // are extracted (so we can walk the body's FROM clause
1229            // and resolve each relation to its effective alias).
1230            // This deferred narrowing replaces the slice-60 v1 path
1231            // that put the running ctePublishedColumns map (CTE-name
1232            // keyed) directly on the provider — that keying class
1233            // could collide when a subquery alias matched a CTE
1234            // name (codex diff-review).
1235            NameBindingProvider bodyProvider = provider.withCteContext(effectiveVisible);
1236            TSelectSqlStatement cteBody = cte.getSubquery();
1237            // Slice 103 — snapshot lineage size BEFORE the body branch so
1238            // the slice-102 rename helper can rewrite outgoing
1239            // STATEMENT_OUTPUT refs in [lineageSize0, lineage.size())
1240            // without touching prior CTE bodies' edges. Covers BOTH the
1241            // set-op and non-set-op branches (mirrors slice-102
1242            // buildMergeCteList at line ~5820).
1243            int lineageSize0 = lineage.size();
1244            int bodyIdx;
1245            if (cteBody != null
1246                    && cteBody.getSetOperatorType() != null
1247                    && cteBody.getSetOperatorType() != ESetOperatorType.none) {
1248                // Slice 12: set-op CTE body. The outer set-op statement
1249                // carries the CTE name so BodyIndexes.cteByConsumerAndName
1250                // resolves it (slice-18 consumer-keyed projector lookup).
1251                // The CTE body's CTE list (if any) is rejected as a
1252                // nested-WITH inside buildSetOpProgram.
1253                bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, lineage,
1254                        cteNameToStatementIndex, cteName,
1255                        /*hasOuterCteListAlreadyProcessed=*/ false);
1256                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
1257            } else {
1258                // Slice 18: snapshot/rollback around recursive
1259                // FROM-subquery extraction inside this CTE body.
1260                // Mirrors the outer-SELECT wrapper below and the
1261                // slice-16 set-op wrapper. Currently defensive: a
1262                // thrown exception in a deeper level would otherwise
1263                // leak siblings/ancestors at this CTE's level into
1264                // stmts/lineage. The wrapper truncates back to the
1265                // pre-extraction boundary and rethrows. Per-CTE
1266                // granularity: earlier CTE bodies in the same WITH
1267                // list are NOT rolled back (they're already complete).
1268                int cteStmtsSize0 = stmts.size();
1269                int cteLineageSize0 = lineage.size();
1270                Map<String, Integer> cteSubqueryAliasToIndex;
1271                try {
1272                    // Slice 60: pass the running ctePublishedColumns
1273                    // so the body's own FROM-subqueries see earlier
1274                    // CTEs at every recursion level.
1275                    cteSubqueryAliasToIndex =
1276                            extractFromSubqueriesAsStatements(cteBody, bodyProvider,
1277                                    stmts, lineage, cteNameToStatementIndex,
1278                                    ctePublishedColumns);
1279                } catch (RuntimeException ex) {
1280                    while (stmts.size() > cteStmtsSize0) stmts.remove(stmts.size() - 1);
1281                    while (lineage.size() > cteLineageSize0) lineage.remove(lineage.size() - 1);
1282                    throw ex;
1283                }
1284                EnclosingScope cteEnclosing = buildEnclosingScope(cteBody,
1285                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
1286                        /*parent=*/ null);
1287                Map<Integer, ScalarInfo> cteScalarMap =
1288                        extractScalarSubqueriesAsStatements(cteBody,
1289                                bodyProvider, stmts, lineage,
1290                                cteNameToStatementIndex, cteEnclosing,
1291                                /*allowRecursiveScalarSubqueryExtraction=*/ true);
1292                // Slice 60 (codex diff-review): build the per-CTE
1293                // effective-alias-keyed in-scope map by walking the
1294                // CTE body's FROM list. CTE references and
1295                // FROM-subquery aliases live in the same FROM
1296                // namespace (preflight rejects duplicates), so
1297                // effective-alias keying makes a name collision
1298                // physically impossible.
1299                Map<String, List<String>> cteBodyInScope =
1300                        buildEffectiveAliasInScopeMap(cteBody, bodyProvider,
1301                                ctePublishedColumns, cteSubqueryAliasToIndex,
1302                                stmts);
1303                NameBindingProvider cteBodyProviderWithStar = bodyProvider
1304                        .withInScopeRelationColumns(cteBodyInScope);
1305                // Slice 114 — switch from the 7-arg buildSelectStatement
1306                // to the 14-arg buildSelectStatementImpl so the CTE
1307                // body's WHERE clause can extract uncorrelated predicate
1308                // subqueries (IN-SELECT / EXISTS / NOT EXISTS / scalar
1309                // comparison / ANY-ALL-SOME) as their own statements.
1310                // The wrapper mirrors the outer-SELECT entry pattern in
1311                // {@link #build}: if the build appends predicate bodies
1312                // and then a later post-extraction reject fires, the
1313                // try/catch truncates stmts/lineage back to the
1314                // pre-call boundary so a partial extraction doesn't
1315                // leak into the program. The slice-113 set-op branch
1316                // call site is itself enclosed by the slice-16
1317                // SET-OP-WIDE rollback at {@link #buildSetOpProgram};
1318                // the CTE-body call sites do NOT inherit a similar
1319                // enclosing wrapper, which is why slice 114 adds one
1320                // here. The from-subquery / scalar-subquery
1321                // extractions above this point have their own
1322                // slice-17/18 wrappers, so the pre-CALL snapshot
1323                // bounds the truncate exactly to whatever
1324                // buildSelectStatementImpl appended.
1325                int cteBodyStmtsSnapshot = stmts.size();
1326                int cteBodyLineageSnapshot = lineage.size();
1327                StatementGraph body;
1328                try {
1329                    if (isPivotSelect(cteBody)) {
1330                        // Slice 139 — a nested PIVOT/UNPIVOT as a CTE body.
1331                        // The slice-129 pivot router in
1332                        // buildSelectStatementImpl is gated to the OUTER
1333                        // SELECT (name == null && !isPredicateBody), so a
1334                        // pivot built here (name == cteName) would otherwise
1335                        // fall to the normal path and reject with the
1336                        // misleading TABLE_BINDING_UNRESOLVED
1337                        // ("null(piviot_table)"). Route it to buildPivotSelect
1338                        // with the CTE name as the statement name — mirroring
1339                        // the slice-138 processDirectSubqueryTable branch for a
1340                        // FROM-subquery body. The CTE becomes a proper pivot
1341                        // StatementGraph; the outer query references the CTE
1342                        // name (CTE-kind) so the standard cross-stmt CTE path
1343                        // emits STATEMENT_OUTPUT(outer) → STATEMENT_OUTPUT(cte),
1344                        // and the emitLineageForStatement(body, ...) call below
1345                        // wires the pivot's own output → source edges
1346                        // (base-table TABLE_COLUMN, or — when the pivot's source
1347                        // is itself a subquery, slice 136 — STATEMENT_OUTPUT via
1348                        // cteSubqueryAliasToIndex). All pivot deferrals are
1349                        // preserved by buildPivotSelect's own guards
1350                        // (PIVOT_WITH_QUERY_CLAUSE / MULTIPLE_CLAUSES /
1351                        // UNPIVOT / STAR_CATALOG_REQUIRED).
1352                        body = buildPivotSelect(cteBody,
1353                                cteBodyProviderWithStar, cteName);
1354                    } else {
1355                        body = buildSelectStatementImpl(cteBody,
1356                            cteBodyProviderWithStar, cteName,
1357                            /*hasOuterCteListAlreadyProcessed=*/ false,
1358                            /*allowFromSubqueries=*/ true,
1359                            /*allowScalarProjectionSubqueries=*/ true,
1360                            /*allowWindowProjection=*/ true,
1361                            // Slice 114 — keep JOIN-ON predicate
1362                            // subqueries rejected inside CTE bodies
1363                            // (preserve slice 23/26 contract; the lift
1364                            // is WHERE-only; the two flags are
1365                            // independent per slice 113 split).
1366                            /*allowJoinOnPredicateSubqueries=*/ false,
1367                            /*stmtsForExtraction=*/ stmts,
1368                            /*lineageForExtraction=*/ lineage,
1369                            /*cteMapForExtraction=*/ cteNameToStatementIndex,
1370                            /*isPredicateBody=*/ false,
1371                            /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE,
1372                            /*allowWherePredicateSubqueries=*/ true);
1373                    }
1374                } catch (RuntimeException ex) {
1375                    while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
1376                    while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
1377                    throw ex;
1378                }
1379                bodyIdx = stmts.size();
1380                stmts.add(body);
1381                // Slice 108 — emit lineage BEFORE the cteMap.put so that
1382                // in the shadow case (allowShadowOverride=true with
1383                // cteNameLower already in cteMap from outer pass), the
1384                // body's column refs to <cteNameLower> still resolve to
1385                // the OUTER body (PG inner-x body sees outer-x via the
1386                // closer-enclosing-not-yet-shadowed fallback). Non-shadow
1387                // cases are unaffected because cteMap does not yet contain
1388                // cteNameLower at this point and the body cannot reference
1389                // its own name without going through the recursive-CTE
1390                // path (already rejected upstream).
1391                emitLineageForStatement(body, bodyIdx, lineage,
1392                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
1393                        cteScalarMap);
1394                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
1395            }
1396            // Slice 103 — apply the slice-102 rename helper if the CTE
1397            // declares an explicit column list (no-op otherwise). The
1398            // helper returns the published column list (renamed if
1399            // explicit, else body's inner names). Slice-60's
1400            // `ctePublishedColumns.put` is collapsed into this single
1401            // call site (covers both branches above).
1402            List<String> publishedCols = applyExplicitCteColumnListRename(
1403                    cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT");
1404            ctePublishedColumns.put(cteNameLower, publishedCols);
1405            localVisibleSoFar.add(cteNameLower);
1406        }
1407    }
1408
1409    /**
1410     * Slice 108 — outer-SELECT processing extracted from the previous inline
1411     * body of {@link #build} (lines ~665–763 pre-slice-108).
1412     *
1413     * <p>{@code hasOuterCteListAlreadyProcessed} is an EXPLICIT boolean
1414     * parameter (round-2 codex BLOCKER 4 fix). Previously this was inferred
1415     * from {@code select.getCteList() != null && size > 0}; after the
1416     * slice-108 buildInsert shadow path nulls {@code source.getCteList()}
1417     * before calling, that inference would be wrong. The caller passes the
1418     * truth.
1419     *
1420     * <p>The post-walk {@code cteNameToStatementIndex.keySet()} replaces the
1421     * pre-walk {@code allCteNames} because the walker has populated every
1422     * declared CTE name by lowercase key — they are equal sets.
1423     */
1424    private static void buildSelectBodyAfterCteWalk(
1425            TSelectSqlStatement select,
1426            NameBindingProvider provider,
1427            List<StatementGraph> stmts,
1428            List<LineageEdge> lineage,
1429            Map<String, Integer> cteNameToStatementIndex,
1430            Map<String, List<String>> ctePublishedColumns,
1431            boolean hasOuterCteListAlreadyProcessed) {
1432        Set<String> allCteNames = cteNameToStatementIndex.isEmpty()
1433                ? Collections.<String>emptySet()
1434                : new HashSet<>(cteNameToStatementIndex.keySet());
1435
1436        // Slice 12: top-level set-op dispatch. CTE list (if any) was
1437        // already processed above; pass hasOuterCteListAlreadyProcessed=true
1438        // so buildSetOpProgram doesn't re-flag it as a nested WITH.
1439        if (select.getSetOperatorType() != null
1440                && select.getSetOperatorType() != ESetOperatorType.none) {
1441            NameBindingProvider outerProvider = provider.withCteContext(allCteNames);
1442            buildSetOpProgram(select, outerProvider, stmts, lineage,
1443                    cteNameToStatementIndex, /*setOpName=*/ null,
1444                    /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteListAlreadyProcessed);
1445            return;
1446        }
1447
1448        // Outer statement: pre-extract any FROM-clause subqueries as their
1449        // own statements, then any scalar-subquery projections, then build
1450        // the outer body, then emit lineage with the global CTE map, the
1451        // outer-local subquery alias map, AND the scalar-projection map.
1452        // Slice 60: outerProvider gets the CTE-context narrowing here;
1453        // the effective-alias-keyed in-scope map is applied LATER, after
1454        // outer FROM-subqueries are extracted. The same deferred
1455        // narrowing pattern as the CTE-body branch — see the codex
1456        // diff-review note on alias/CTE-name collision.
1457        NameBindingProvider outerProvider = provider.withCteContext(allCteNames);
1458        // Slice 17: snapshot/rollback around recursive FROM-subquery
1459        // extraction. The recursive extractor mutates stmts/lineage as
1460        // each level's bodies land; if a deeper-level rejection fires
1461        // after sibling/ancestor mutations, this wrapper truncates the
1462        // lists back to the pre-call boundary and rethrows. Mirrors the
1463        // slice-16 buildSetOpProgram wrapper (§14.18 process lesson #21:
1464        // when a class of mutation-free checks can fire after partial
1465        // mutation, close it transactionally instead of point-fixing).
1466        //
1467        // The rollback is currently defensive: build() allocates fresh
1468        // stmts/lineage per invocation, so a thrown exception's caller
1469        // cannot directly observe leaked state. The wrapper is kept
1470        // because (a) the slice-17 preflight closes the most direct
1471        // partial-mutation classes BEFORE the recursive extraction
1472        // runs, but recursive levels can still fail at deeper rejection
1473        // points (e.g. a nested set-op-in-FROM-subquery body inside a
1474        // sibling that succeeds at the preflight); (b) consistency with
1475        // slice 16's pattern means a future refactor that lifts the
1476        // build() per-call list allocation does not silently re-open
1477        // the partial-mutation class.
1478        int stmtsSize0 = stmts.size();
1479        int lineageSize0 = lineage.size();
1480        Map<String, Integer> outerSubqueryAliasToIndex;
1481        try {
1482            outerSubqueryAliasToIndex =
1483                    extractFromSubqueriesAsStatements(select, outerProvider,
1484                            stmts, lineage, cteNameToStatementIndex,
1485                            ctePublishedColumns);
1486        } catch (RuntimeException ex) {
1487            while (stmts.size() > stmtsSize0) stmts.remove(stmts.size() - 1);
1488            while (lineage.size() > lineageSize0) lineage.remove(lineage.size() - 1);
1489            throw ex;
1490        }
1491        EnclosingScope outerEnclosing = buildEnclosingScope(select,
1492                cteNameToStatementIndex, outerSubqueryAliasToIndex,
1493                /*parent=*/ null);
1494        Map<Integer, ScalarInfo> outerScalarMap =
1495                extractScalarSubqueriesAsStatements(select, outerProvider,
1496                        stmts, lineage, cteNameToStatementIndex, outerEnclosing,
1497                        /*allowRecursiveScalarSubqueryExtraction=*/ true);
1498        // Slice 60 (codex diff-review): build the outer's
1499        // effective-alias-keyed in-scope map by walking the outer
1500        // SELECT's FROM list. Effective-alias keying eliminates the
1501        // CTE-name vs subquery-alias collision class.
1502        Map<String, List<String>> outerInScope = buildEffectiveAliasInScopeMap(
1503                select, outerProvider, ctePublishedColumns,
1504                outerSubqueryAliasToIndex, stmts);
1505        NameBindingProvider outerProviderWithStar = outerProvider
1506                .withInScopeRelationColumns(outerInScope);
1507        // Slice 23: outer-SELECT path uses buildSelectStatementImpl directly so
1508        // the slice-23 EXISTS-extraction can append predicate-body statements
1509        // to `stmts`/`lineage`. Snapshot/rollback wrapper around the call
1510        // matches the slice-16/17/20 pattern: a partial extraction (e.g. third
1511        // EXISTS rejected after first two extracted) truncates the lists.
1512        int outerStmtsSnapshot = stmts.size();
1513        int outerLineageSnapshot = lineage.size();
1514        StatementGraph outer;
1515        try {
1516            outer = buildSelectStatementImpl(select, outerProviderWithStar, null,
1517                    /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteListAlreadyProcessed,
1518                    /*allowFromSubqueries=*/ true,
1519                    /*allowScalarProjectionSubqueries=*/ true,
1520                    /*allowWindowProjection=*/ true,
1521                    /*allowJoinOnPredicateSubqueries=*/ true,
1522                    stmts, lineage,
1523                    /*cteMapForExtraction=*/ cteNameToStatementIndex,
1524                    /*isPredicateBody=*/ false,
1525                    /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE,
1526                    /*allowWherePredicateSubqueries=*/ true);
1527        } catch (RuntimeException e) {
1528            while (stmts.size() > outerStmtsSnapshot) stmts.remove(stmts.size() - 1);
1529            while (lineage.size() > outerLineageSnapshot) lineage.remove(lineage.size() - 1);
1530            throw e;
1531        }
1532        int outerIndex = stmts.size();
1533        stmts.add(outer);
1534        emitLineageForStatement(outer, outerIndex, lineage,
1535                cteNameToStatementIndex, outerSubqueryAliasToIndex, outerScalarMap);
1536    }
1537
1538    /**
1539     * Slice 78 — admit a single {@code INSERT INTO target SELECT ...}
1540     * statement. Builds the source SELECT via {@link #build} (reusing
1541     * the existing pipeline unchanged), then appends an {@code "INSERT"}-
1542     * kind {@link StatementGraph} carrying the target relation and
1543     * cross-statement lineage edges.
1544     *
1545     * <p>Admitted shape: {@code INSERT INTO <target> [(c1, c2, ...)]
1546     * <subquery-SELECT>}. Rejections:
1547     * <ul>
1548     *   <li>{@link EInsertSource#values}, {@code values_empty},
1549     *       {@code default_values}, {@code execute},
1550     *       {@code values_function}, {@code values_multi_table},
1551     *       {@code hive_query}, {@code values_oracle_record},
1552     *       {@code set_column_value}, {@code value_table} →
1553     *       {@link DiagnosticCode#INSERT_SOURCE_NOT_SUPPORTED}.</li>
1554     *   <li>Oracle {@code INSERT ALL} / {@code INSERT FIRST} →
1555     *       {@link DiagnosticCode#INSERT_MULTI_TABLE_NOT_SUPPORTED}.
1556     *       Hive multi-insert ({@code multiInsertStatements} non-empty) is
1557     *       routed to {@link #buildHiveMultiInsert} instead of rejected.</li>
1558     *   <li>Missing target table (defensive — the parser usually rejects
1559     *       first) → {@link DiagnosticCode#INSERT_TARGET_MISSING}.</li>
1560     *   <li>Explicit column list arity ≠ source SELECT output count →
1561     *       {@link DiagnosticCode#INSERT_COLUMN_COUNT_MISMATCH}.</li>
1562     * </ul>
1563     *
1564     * <p>The source SELECT is built first via {@code build()} and its
1565     * full {@link SemanticProgram} (CTE bodies + scalar bodies +
1566     * FROM-subquery bodies + outer SELECT + cross-stmt lineage) is
1567     * appended verbatim to the returned program. The INSERT
1568     * {@link StatementGraph} is appended LAST; its
1569     * {@link StatementGraph#getRelations() relations} lists the source
1570     * SELECT as a single {@link RelationKind#SUBQUERY} entry whose
1571     * {@code qualifiedName} is the source SELECT's outer-statement name
1572     * (synthesised when needed). All other column-ref lists stay empty
1573     * on the INSERT — an INSERT has no projection of its own.
1574     *
1575     * <p>Cross-statement {@link LineageEdge}s for the INSERT are
1576     * {@code from = TABLE_COLUMN(target_qname, target_col_i_name)}
1577     * and {@code to = STATEMENT_OUTPUT(selectIdx, source_output_i_name)}.
1578     * Target column names are the explicit INSERT column-list spellings
1579     * when supplied, else the source SELECT's positional output names.
1580     */
1581    /**
1582     * Build under caller-supplied {@code options}; see
1583     * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}.
1584     */
1585    public static SemanticBuildResult buildInsertResult(
1586            TInsertSqlStatement insert, NameBindingProvider provider,
1587            SemanticIRBuildOptions options) {
1588        return executeBuild(options, () -> buildInsertImpl(insert, provider));
1589    }
1590
1591    /** Build an INSERT and atomically return its program and diagnostics. */
1592    public static SemanticBuildResult buildInsertResult(
1593            TInsertSqlStatement insert, NameBindingProvider provider) {
1594        return buildInsertResult(insert, provider,
1595                SemanticIRBuildOptions.defaults());
1596    }
1597
1598    @Deprecated
1599    public static SemanticProgram buildInsert(TInsertSqlStatement insert,
1600                                     NameBindingProvider provider,
1601                                     SemanticIRBuildOptions options) {
1602        clearBuildDiagnostics();
1603        return publishLegacyResult(buildInsertResult(insert, provider, options));
1604    }
1605
1606    @Deprecated
1607    public static SemanticProgram buildInsert(TInsertSqlStatement insert,
1608                                        NameBindingProvider provider) {
1609        clearBuildDiagnostics();
1610        return publishLegacyResult(buildInsertResult(insert, provider));
1611    }
1612
1613    private static SemanticProgram buildInsertImpl(TInsertSqlStatement insert,
1614                                                   NameBindingProvider provider) {
1615        if (insert == null) {
1616            throw new IllegalArgumentException("insert must not be null");
1617        }
1618        if (provider == null) {
1619            throw new IllegalArgumentException("provider must not be null");
1620        }
1621
1622        // Oracle INSERT ALL / FIRST rejects: their multi-value AST shape
1623        // is fundamentally different from the Hive multi-insert path.
1624        // Slice 78 scopes single-target INSERT SELECT; slice 93 lifts
1625        // the Hive multi-insert case via buildHiveMultiInsert.
1626        if (insert.isInsertAll() || insert.isInsertFirst()) {
1627            throw new SemanticIRBuildException(Diagnostic.error(
1628                    DiagnosticCode.INSERT_MULTI_TABLE_NOT_SUPPORTED,
1629                    "multi-table INSERT (INSERT ALL / INSERT FIRST) is not "
1630                            + "supported by SemanticIRBuilder.buildInsert; "
1631                            + "slice 78 admits single-target INSERT INTO <target> SELECT ...",
1632                    insert));
1633        }
1634        // Slice 109 — outer-WITH on Hive multi-insert
1635        // (`WITH x AS (...) FROM x INSERT INTO t1 SELECT ... INSERT INTO t2
1636        // SELECT ...`) is now admitted via buildHiveMultiInsert's CTE-aware
1637        // path. The slice-104 early reject for this shape is removed; the
1638        // helper builds the outer CTE bodies ONCE upfront and reuses the
1639        // shared cteMap/publishedMap across every sub-SELECT.
1640        // INSERT_OUTER_WITH_ON_HIVE_MULTI_INSERT_NOT_SUPPORTED stays declared-
1641        // but-unreached for API stability (slice 71/72/82/86/95/96/97/98/108
1642        // retain-for-documentation precedent).
1643        //
1644        // Hive multi-insert: FROM src INSERT INTO t1 SELECT ... INSERT INTO t2 SELECT ...
1645        // Each sub-SELECT already carries the shared FROM source in its fromClause.
1646        if (!insert.getMultiInsertStatements().isEmpty()) {
1647            return buildHiveMultiInsert(insert, provider);
1648        }
1649
1650        EInsertSource src = insert.getInsertSource();
1651        if (src != EInsertSource.subquery) {
1652            throw new SemanticIRBuildException(Diagnostic.error(
1653                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
1654                    "INSERT source '" + src + "' is not supported by "
1655                            + "SemanticIRBuilder.buildInsert; slice 78 admits "
1656                            + "subquery-source INSERT only (INSERT INTO <target> SELECT ...)",
1657                    insert));
1658        }
1659
1660        // Slice 85 — cheap statement-level OUTPUT_INTO reject runs
1661        // BEFORE the source SELECT is built so a multi-violation
1662        // shape (e.g. `INSERT INTO t OUTPUT INSERTED.x INTO #log
1663        // SELECT ... FROM bad_join`) routes to the cheaper structural
1664        // code first.
1665        if (insert.getOutputClause() != null
1666                && insert.getOutputClause().getIntoTable() != null) {
1667            throw new SemanticIRBuildException(Diagnostic.error(
1668                    DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED,
1669                    "INSERT OUTPUT ... INTO <target> writes a second target; "
1670                            + "slice 85 admits projection-only OUTPUT",
1671                    insert));
1672        }
1673
1674        TTable targetTable = insert.getTargetTable();
1675        if (targetTable == null || targetTable.getTableName() == null) {
1676            throw new SemanticIRBuildException(Diagnostic.error(
1677                    DiagnosticCode.INSERT_TARGET_MISSING,
1678                    "INSERT statement has no resolvable target table",
1679                    insert));
1680        }
1681        String targetQName = targetTable.getTableName().toString();
1682        if (targetQName.isEmpty()) {
1683            throw new SemanticIRBuildException(Diagnostic.error(
1684                    DiagnosticCode.INSERT_TARGET_MISSING,
1685                    "INSERT target table name is empty",
1686                    insert));
1687        }
1688
1689        TSelectSqlStatement source = insert.getSubQuery();
1690        if (source == null) {
1691            // Defensive: getInsertSource() == subquery but subQuery is
1692            // null. Surface as INSERT_TARGET_MISSING's source half.
1693            throw new SemanticIRBuildException(Diagnostic.error(
1694                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
1695                    "INSERT source is declared as subquery but no SELECT "
1696                            + "statement was attached",
1697                    insert));
1698        }
1699
1700        // Slice 104 — outer-WITH on INSERT. The parser attaches the outer
1701        // WITH clause to insert.getCteList(), NOT to source.getCteList().
1702        // Before slice 104 buildInsert ignored insert.getCteList(), which
1703        // silently mis-bound CTE references in the source SELECT as
1704        // TABLE-kind relations with phantom columns. The slice-104 fix is
1705        // an AST handoff: move insert.getCteList() onto source.getCteList()
1706        // for the duration of the inner build(source) call so the
1707        // slice-103 SELECT-side CTE walker handles construction, rename,
1708        // and rejects (recursive / duplicate / forward-reference / arity
1709        // mismatch). Restore in finally so the AST is observably
1710        // unchanged to the caller (Java field references — token-chain
1711        // state is perturbed by setCteList(null)'s removeTokens() but
1712        // observably benign for downstream Semantic IR).
1713        //
1714        // Slice 107 / 108 — mixed outer-WITH + inner-WITH on INSERT. PG /
1715        // Oracle / Snowflake admit at parse. Three sub-cases:
1716        //   (a) Only outer WITH populated. AST handoff (slice 104): move
1717        //       insert.cteList onto source.cteList and call build(source).
1718        //   (b) Only inner WITH populated. Pass through unchanged (the
1719        //       walker handles it on its own).
1720        //   (c) Both outer and inner WITH populated. Slice 107 admitted
1721        //       this for disjoint names via a flat-merge; slice 108 admits
1722        //       it for the SHADOWING case too (`WITH x ... INSERT ... WITH
1723        //       x ... SELECT ... FROM x` — inner shadows outer per
1724        //       PG/Oracle/Snowflake nested-WITH semantics). The slice-108
1725        //       implementation uses a TWO-PASS walker invocation in this
1726        //       method: outer pass first (allowShadowOverride=false), then
1727        //       inner pass (allowShadowOverride=true,
1728        //       additionalAllCteNames=outer-names). The walker's two-set
1729        //       visibility model (outerKeysSnapshot ∪ localVisibleSoFar)
1730        //       keeps PG semantics correct: inner-x's body sees outer-x via
1731        //       the cteMap (override is post-build), and inner CTEs declared
1732        //       after inner-x see inner-x. The OUTER body stays in stmts[]
1733        //       at its position; its cteMap entry is just no longer
1734        //       referenced by name (shadowed). Source SELECT's `FROM x`
1735        //       resolves to inner-x.
1736        //
1737        // INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED stays declared but
1738        // is no longer reached by slice 108. Slice107Test §F/§Q (cross-
1739        // boundary duplicate rejects) are deleted; positive coverage moves
1740        // to Slice108Test.
1741        TCTEList outerCtes = insert.getCteList();
1742        TCTEList savedSourceCtes = source.getCteList();
1743        boolean handoffApplied = false;
1744        SemanticProgram inner;
1745        boolean haveOuterCtes = outerCtes != null && outerCtes.size() > 0;
1746        boolean haveInnerCtes = savedSourceCtes != null && savedSourceCtes.size() > 0;
1747        if (haveOuterCtes && haveInnerCtes) {
1748            // Slice 108 — two-pass walker. Null both AST CTE lists before
1749            // calling buildSelectBodyAfterCteWalk so the helper does not
1750            // re-process source.getCteList(). hasOuterCteListAlreadyProcessed
1751            // is passed true (round-2 codex BLOCKER 4 fix).
1752            source.setCteList(null);
1753            insert.setCteList(null);
1754            handoffApplied = true;
1755            try {
1756                List<StatementGraph> innerStmts = new ArrayList<>();
1757                List<LineageEdge> innerLineage = new ArrayList<>();
1758                Map<String, Integer> cteMap = new HashMap<>();
1759                Map<String, List<String>> publishedMap = new HashMap<>();
1760                // Outer pass: outerAllNames as its own scope.
1761                buildSelectCteList(outerCtes, provider, innerStmts, innerLineage,
1762                        cteMap, publishedMap,
1763                        /*allowShadowOverride=*/ false,
1764                        /*additionalAllCteNames=*/ null);
1765                // Inner pass: outerAllNames also visible for forward-ref
1766                // classification (round-1 codex BLOCKER 2 fix); shadow
1767                // override admits cross-boundary duplicate names.
1768                Set<String> outerAllNames = collectCteNames(outerCtes);
1769                buildSelectCteList(savedSourceCtes, provider, innerStmts, innerLineage,
1770                        cteMap, publishedMap,
1771                        /*allowShadowOverride=*/ true,
1772                        /*additionalAllCteNames=*/ outerAllNames);
1773                // Source SELECT body sees the post-pass cteMap (inner wins
1774                // for shadowed names).
1775                buildSelectBodyAfterCteWalk(source, provider, innerStmts, innerLineage,
1776                        cteMap, publishedMap,
1777                        /*hasOuterCteListAlreadyProcessed=*/ true);
1778                inner = new SemanticProgram(innerStmts, innerLineage);
1779            } finally {
1780                source.setCteList(savedSourceCtes);
1781                insert.setCteList(outerCtes);
1782            }
1783        } else {
1784            // Single-sided cases. Slice 104 AST handoff for outer-only;
1785            // pass-through for inner-only or no CTEs.
1786            if (haveOuterCtes) {
1787                source.setCteList(outerCtes);
1788                insert.setCteList(null);
1789                handoffApplied = true;
1790            }
1791            try {
1792                // buildImpl, NOT the public two-argument build: this is an
1793                // INTERNAL recursion into the DML's own source SELECT and must
1794                // inherit the ambient options, or the three-argument
1795                // buildInsert/buildCreateTable/buildCreateView degrade
1796                // overloads would silently still throw. The public two-argument
1797                // overload deliberately pins defaults and is for EXTERNAL
1798                // callers only.
1799                inner = buildImpl(source, provider);
1800            } finally {
1801                if (handoffApplied) {
1802                    source.setCteList(savedSourceCtes);
1803                    insert.setCteList(outerCtes);
1804                }
1805            }
1806        }
1807
1808        // Slice 93 — delegate INSERT-graph assembly to the shared helper
1809        // used by both single-target (slice 78) and Hive multi-insert
1810        // (slice 93). out is freshly empty so the helper's rebase offset
1811        // is 0 (no-op for inner lineage). RETURNING/OUTPUT clauses are
1812        // passed directly (slice 85 still owns the projection build).
1813        List<StatementGraph> out = new ArrayList<>(inner.getStatements().size() + 1);
1814        List<LineageEdge> outLineage = new ArrayList<>();
1815        assembleInsertGraphAndLineage(
1816                insert, targetTable, targetQName, inner,
1817                "INSERT",
1818                insert.getReturningClause(),
1819                insert.getOutputClause(),
1820                out, outLineage, provider);
1821        return new SemanticProgram(out, outLineage);
1822    }
1823
1824    /**
1825     * Slice 93 — admit a Hive multi-insert block of the form
1826     * {@code FROM src INSERT INTO t1 SELECT col1 INSERT INTO t2 SELECT col2}.
1827     *
1828     * <p>The parser represents the whole block as one {@link TInsertSqlStatement}
1829     * whose first INSERT-SELECT pair is the primary statement and whose
1830     * additional pairs are in {@link TInsertSqlStatement#getMultiInsertStatements()}.
1831     * Crucially, each sub-SELECT already carries the shared FROM source in its own
1832     * {@code fromClause} / {@code fromSourceTable} — no post-processing is needed.
1833     *
1834     * <p>Produces a flat {@link SemanticProgram} containing per-pair blocks of
1835     * statements concatenated in INSERT order: each block contributes its source
1836     * SELECT's inner statements (CTE bodies / FROM-subquery bodies extracted by
1837     * {@link #build}) followed by its outer SELECT followed by an INSERT graph.
1838     * The minimum is {@code 2N} statements (one SELECT + one INSERT per target);
1839     * sub-SELECTs with extracted inner programs produce more. Each INSERT carries
1840     * cross-statement lineage edges pointing at its preceding SELECT via
1841     * {@link LineageRef#statementOutput}; per-pair inner lineage edges are
1842     * rebased by the current {@code out.size()} so absolute statement indices
1843     * remain valid across the concatenated program.
1844     *
1845     * <p>Safety note on the source-table fallback: this method enables
1846     * {@code provider.withSourceTableFallback(true)} so secondary sub-SELECTs
1847     * (which Resolver2 does not traverse) can still bind their column refs.
1848     * The fallback is constrained at the provider level to fire only when
1849     * Phase 2 did not run AND any explicit qualifier matches Phase 1's source —
1850     * see {@link Resolver2NameBindingProvider#bindColumn}. Current Hive
1851     * multi-insert parses always present a single FROM source, so Phase 1's
1852     * unqualified-column resolution is unambiguous in practice.
1853     */
1854    private static SemanticProgram buildHiveMultiInsert(TInsertSqlStatement insert,
1855                                                        NameBindingProvider provider) {
1856        // Slice 93 — source-table fallback strategy for Hive multi-insert.
1857        //
1858        // TSQLResolver2 does NOT process the secondary inserts in
1859        // getMultiInsertStatements(): their column refs have
1860        // resolution == null (Phase 2 did not run) even though Phase 1's
1861        // linkColumnToTable sets sourceTable. To let collectColumnRefs
1862        // accept these bindings, we enable a narrow source-table fallback
1863        // in the provider — but ONLY when every sub-SELECT has a SINGLE
1864        // FROM source (the common Hive multi-insert shape that current
1865        // parser support admits). In single-source contexts, Phase 1's
1866        // unqualified-column resolution is unambiguous; the fallback is
1867        // safe (round-2 codex Q1 BLOCKING).
1868        //
1869        // If any sub-SELECT has multiple FROM sources, Phase 1 may have
1870        // heuristically picked one source for an unqualified column —
1871        // promoting that to EXACT_MATCH could silently mis-bind. In that
1872        // case the fallback stays disabled; users must qualify column
1873        // references in the secondary branch (the qualifier-matches-source
1874        // safety in bindColumn still allows qualified refs through).
1875        boolean singleSource = isSingleSourceMultiInsert(insert);
1876        NameBindingProvider effectiveProvider = singleSource
1877                ? provider.withSourceTableFallback(true)
1878                : provider;
1879
1880        List<StatementGraph> out = new ArrayList<>();
1881        List<LineageEdge> outLineage = new ArrayList<>();
1882
1883        // Slice 109 — outer WITH on multi-insert: build the CTE bodies ONCE
1884        // upfront so each sub-SELECT's `FROM x` resolves against the shared
1885        // cteMap/publishedMap. The parser attaches the outer WITH to the
1886        // primary insert's getCteList(); sub-INSERTs in
1887        // getMultiInsertStatements() carry null cteLists. The AST handoff
1888        // mirrors the slice-104 single-target pattern but only nulls
1889        // insert.getCteList() — there is no source SELECT to move it onto
1890        // because each sub-INSERT has its own.
1891        TCTEList outerCtes = insert.getCteList();
1892        Map<String, Integer> cteMap = new HashMap<>();
1893        Map<String, List<String>> publishedMap = new HashMap<>();
1894        boolean handoffApplied = false;
1895        if (outerCtes != null && outerCtes.size() > 0) {
1896            insert.setCteList(null);
1897            handoffApplied = true;
1898            try {
1899                buildSelectCteList(outerCtes, effectiveProvider, out, outLineage,
1900                        cteMap, publishedMap,
1901                        /*allowShadowOverride=*/ false,
1902                        /*additionalAllCteNames=*/ null);
1903            } catch (RuntimeException ex) {
1904                // Restore eagerly on CTE-build failure so a downstream caller
1905                // observing the AST sees the original cteList.
1906                insert.setCteList(outerCtes);
1907                throw ex;
1908            }
1909        }
1910
1911        try {
1912            // Primary INSERT (first target)
1913            appendOneHiveInsert(insert, effectiveProvider, out, outLineage,
1914                    cteMap, publishedMap);
1915
1916            // Additional INSERTs from getMultiInsertStatements()
1917            for (Object miObj : insert.getMultiInsertStatements()) {
1918                appendOneHiveInsert((TInsertSqlStatement) miObj, effectiveProvider,
1919                        out, outLineage, cteMap, publishedMap);
1920            }
1921        } finally {
1922            if (handoffApplied) {
1923                insert.setCteList(outerCtes);
1924            }
1925        }
1926
1927        return new SemanticProgram(out, outLineage);
1928    }
1929
1930    /**
1931     * Slice 93 — true when every INSERT-SELECT pair in a Hive multi-insert
1932     * block has a single FROM source (i.e., one entry in
1933     * {@code subQuery.getTables()}). Guards the source-table fallback so
1934     * Phase 1's heuristic source assignment is only trusted in contexts
1935     * where it is provably unambiguous (round-2 codex Q1 BLOCKING).
1936     */
1937    private static boolean isSingleSourceMultiInsert(TInsertSqlStatement insert) {
1938        if (!isSingleSourceSubQuery(insert.getSubQuery())) {
1939            return false;
1940        }
1941        for (Object miObj : insert.getMultiInsertStatements()) {
1942            TInsertSqlStatement mi = (TInsertSqlStatement) miObj;
1943            if (!isSingleSourceSubQuery(mi.getSubQuery())) {
1944                return false;
1945            }
1946        }
1947        return true;
1948    }
1949
1950    private static boolean isSingleSourceSubQuery(TSelectSqlStatement sel) {
1951        return sel != null && sel.getTables() != null && sel.getTables().size() == 1;
1952    }
1953
1954    /**
1955     * Build one INSERT-SELECT pair into {@code out} / {@code outLineage}.
1956     * Called by {@link #buildHiveMultiInsert} for the primary and each
1957     * additional INSERT in a Hive multi-insert block. Each call validates
1958     * the target/source, builds the source SELECT via {@link #build}, and
1959     * delegates the post-build INSERT-graph assembly to
1960     * {@link #assembleInsertGraphAndLineage} so the layout exactly mirrors
1961     * the single-target slice-78 INSERT path (the helper also handles
1962     * inner-lineage rebasing when {@code out} is non-empty).
1963     */
1964    private static void appendOneHiveInsert(TInsertSqlStatement insert,
1965                                            NameBindingProvider provider,
1966                                            List<StatementGraph> out,
1967                                            List<LineageEdge> outLineage,
1968                                            Map<String, Integer> cteMap,
1969                                            Map<String, List<String>> publishedMap) {
1970        TTable targetTable = insert.getTargetTable();
1971        if (targetTable == null || targetTable.getTableName() == null) {
1972            throw new SemanticIRBuildException(Diagnostic.error(
1973                    DiagnosticCode.INSERT_TARGET_MISSING,
1974                    "Hive multi-insert: INSERT has no resolvable target table",
1975                    insert));
1976        }
1977        String targetQName = targetTable.getTableName().toString();
1978        if (targetQName.isEmpty()) {
1979            throw new SemanticIRBuildException(Diagnostic.error(
1980                    DiagnosticCode.INSERT_TARGET_MISSING,
1981                    "Hive multi-insert: INSERT target table name is empty",
1982                    insert));
1983        }
1984        TSelectSqlStatement source = insert.getSubQuery();
1985        if (source == null) {
1986            throw new SemanticIRBuildException(Diagnostic.error(
1987                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
1988                    "Hive multi-insert: INSERT has no source SELECT",
1989                    insert));
1990        }
1991
1992        // Slice 109 — when outer CTEs are present (cteMap non-empty), build
1993        // the source SELECT via buildSelectBodyAfterCteWalk directly into
1994        // out/outLineage so it sees the shared cteMap/publishedMap. The
1995        // slice-93 path (no outer CTEs) keeps the build(source, provider) +
1996        // assembleInsertGraphAndLineage flow unchanged.
1997        if (cteMap.isEmpty()) {
1998            // Internal recursion — inherit ambient options (see buildInsertImpl).
1999            SemanticProgram inner = buildImpl(source, provider);
2000            // Hive has no RETURNING/OUTPUT — pass null clauses directly.
2001            assembleInsertGraphAndLineage(
2002                    insert, targetTable, targetQName, inner,
2003                    "Hive multi-insert: INSERT",
2004                    /*returningClause=*/ null,
2005                    /*outputClause=*/ null,
2006                    out, outLineage, provider);
2007            return;
2008        }
2009
2010        // Slice 109 — defensive: parser probe shows sub-SELECTs in Hive
2011        // multi-insert do NOT carry their own cteList. If a future parser
2012        // change ever attached one, mixed outer+inner WITH semantics would
2013        // need slice-107/108-style two-pass walker support; until then the
2014        // shape rejects with the existing mixed-WITH code.
2015        if (source.getCteList() != null && source.getCteList().size() > 0) {
2016            throw new SemanticIRBuildException(Diagnostic.error(
2017                    DiagnosticCode.INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED,
2018                    "Hive multi-insert: mixed outer + inner WITH on a "
2019                            + "sub-SELECT is not supported by "
2020                            + "SemanticIRBuilder.buildHiveMultiInsert; "
2021                            + "slice 109 admits outer-only WITH on multi-insert",
2022                    insert));
2023        }
2024
2025        // Snapshot out.size() so the source SELECT and its inner extractions
2026        // are pinned to known positions. The slice-23 EXISTS-extraction and
2027        // FROM-subquery extraction paths inside buildSelectBodyAfterCteWalk
2028        // append directly to out/outLineage; the source SELECT lands LAST.
2029        int beforeSelectIdx = out.size();
2030        buildSelectBodyAfterCteWalk(source, provider, out, outLineage,
2031                cteMap, publishedMap,
2032                /*hasOuterCteListAlreadyProcessed=*/ true);
2033        if (out.size() <= beforeSelectIdx) {
2034            // Defensive: buildSelectBodyAfterCteWalk always appends at least
2035            // the source SELECT; this branch is unreachable in practice.
2036            throw new SemanticIRBuildException(Diagnostic.error(
2037                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
2038                    "Hive multi-insert: INSERT source built no statements",
2039                    insert));
2040        }
2041        int selectIdx = out.size() - 1;
2042        assembleInsertTargetGraphFromAppended(
2043                insert, targetTable, targetQName, selectIdx,
2044                "Hive multi-insert: INSERT",
2045                /*returningClause=*/ null,
2046                /*outputClause=*/ null,
2047                out, outLineage, provider);
2048    }
2049
2050    /**
2051     * Slice 93 — shared INSERT-graph assembly used by both the slice-78
2052     * single-target {@link #buildInsert} and the slice-93 Hive multi-insert
2053     * {@link #appendOneHiveInsert}. Appends {@code inner.getStatements()}
2054     * to {@code out} (rebasing {@code inner.getLineage()}'s STATEMENT_OUTPUT
2055     * indices when {@code out} is non-empty), then appends an INSERT-kind
2056     * {@link StatementGraph} and per-source-output cross-statement
2057     * {@link LineageEdge}s.
2058     *
2059     * <p>Discriminators between the two callers:
2060     * <ul>
2061     *   <li>{@code diagnosticPrefix} is woven into column-count-mismatch
2062     *       and empty-inner-source error messages so the originating call
2063     *       site is identifiable.</li>
2064     *   <li>{@code returningClause} / {@code outputClause} are passed
2065     *       directly to {@link #buildReturningColumns} (slice 78 supplies
2066     *       the INSERT's RETURNING/OUTPUT clauses; slice 93's Hive path
2067     *       passes {@code null}/{@code null} since Hive has no
2068     *       RETURNING/OUTPUT). Passing the clauses directly keeps the
2069     *       discriminator visible at every call site rather than hidden
2070     *       behind a boolean (round-2 codex Q3 suggestion).</li>
2071     * </ul>
2072     *
2073     * <p>Mutates both {@code out} and {@code outLineage}.
2074     */
2075    private static void assembleInsertGraphAndLineage(
2076            TInsertSqlStatement insert,
2077            TTable targetTable,
2078            String targetQName,
2079            SemanticProgram inner,
2080            String diagnosticPrefix,
2081            TReturningClause returningClause,
2082            TOutputClause outputClause,
2083            List<StatementGraph> out,
2084            List<LineageEdge> outLineage,
2085            NameBindingProvider provider) {
2086        List<StatementGraph> innerStmts = inner.getStatements();
2087        if (innerStmts.isEmpty()) {
2088            // Defensive: build() always returns at least one statement when
2089            // it doesn't throw. This branch is unreachable in practice but
2090            // surfaces a structured diagnostic instead of an
2091            // IndexOutOfBoundsException on the sourceOuter access below.
2092            throw new SemanticIRBuildException(Diagnostic.error(
2093                    DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED,
2094                    diagnosticPrefix + " source built no statements",
2095                    insert));
2096        }
2097
2098        // Rebase inner lineage edges by the current out.size() offset
2099        // (round-2 codex Q4 BLOCKING). For the slice-78 single-target
2100        // path out is empty (offset=0) so rebase is a no-op; for the
2101        // slice-93 Hive path each subsequent INSERT-SELECT pair adds
2102        // an offset matching the absolute position of its inner block.
2103        int offset = out.size();
2104        int selectIdx = offset + innerStmts.size() - 1;
2105        out.addAll(innerStmts);
2106        for (LineageEdge e : inner.getLineage()) {
2107            outLineage.add(rebaseLineageEdge(e, offset));
2108        }
2109
2110        StatementGraph sourceOuter = innerStmts.get(innerStmts.size() - 1);
2111        List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns();
2112        int sourceOutCount = sourceOutputs.size();
2113
2114        // Optional explicit INSERT column list. Verbatim bare-name
2115        // spelling per slice-78 contract; arity mismatch rejects.
2116        TObjectNameList colList = insert.getColumnList();
2117        List<String> targetColumnNames = new ArrayList<>();
2118        if (colList != null && colList.size() > 0) {
2119            for (int i = 0; i < colList.size(); i++) {
2120                TObjectName n = colList.getObjectName(i);
2121                targetColumnNames.add(n == null ? "" : n.toString());
2122            }
2123            if (targetColumnNames.size() != sourceOutCount) {
2124                throw new SemanticIRBuildException(Diagnostic.error(
2125                        DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH,
2126                        diagnosticPrefix + " column list has "
2127                                + targetColumnNames.size()
2128                                + " column(s) but source SELECT produced "
2129                                + sourceOutCount + " output(s)",
2130                        insert));
2131            }
2132        }
2133
2134        // INSERT StatementGraph — slice-78 single-target shape with the
2135        // source SELECT as a SUBQUERY-kind relation entry.
2136        String sourceName = sourceOuter.getName();
2137        String sourceRelAlias = (sourceName != null && !sourceName.isEmpty())
2138                ? sourceName : "__insert_source__";
2139        RelationBinding sourceBinding = new RelationBinding(
2140                RelationKind.SUBQUERY, sourceRelAlias);
2141        List<RelationSource> insertRelations = new ArrayList<>();
2142        insertRelations.add(new RelationSource(sourceRelAlias, sourceBinding));
2143
2144        RelationBinding targetBinding = new RelationBinding(
2145                RelationKind.TABLE, targetQName);
2146        TargetRelation target = new TargetRelation(targetBinding, targetColumnNames);
2147
2148        int insertIdx = out.size();
2149        String insertTargetAlias = effectiveAliasOf(targetTable);
2150        if (insertTargetAlias == null || insertTargetAlias.isEmpty()) {
2151            insertTargetAlias = targetQName;
2152        }
2153        // Slice 85: RETURNING/OUTPUT projections. Clauses are passed
2154        // through directly from the call site (slice-78 single-target
2155        // forwards the INSERT's own clauses; slice-93 Hive multi-insert
2156        // forwards null/null since Hive has no RETURNING/OUTPUT).
2157        List<OutputColumn> returningCols = buildReturningColumns(
2158                returningClause,
2159                outputClause,
2160                "INSERT",
2161                targetQName,
2162                insertTargetAlias,
2163                targetTable,
2164                /*fromSideRelations=*/ Collections.<RelationSource>emptyList(),
2165                /*fromSideAliasToStmtIndex=*/ Collections.<String, Integer>emptyMap(),
2166                provider,
2167                insertIdx,
2168                outLineage,
2169                insert);
2170
2171        StatementGraph insertOuter = new StatementGraph(
2172                /*name=*/ null,
2173                "INSERT",
2174                insertRelations,
2175                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
2176                returningCols,
2177                /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2178                /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2179                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2180                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2181                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2182                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2183                /*distinct=*/ false,
2184                /*setOperator=*/ null,
2185                /*rowLimit=*/ null,
2186                target);
2187        out.add(insertOuter);
2188
2189        // Cross-statement lineage: target.col_i ← STATEMENT_OUTPUT(selectIdx, srcName_i)
2190        for (int i = 0; i < sourceOutCount; i++) {
2191            String srcName = sourceOutputs.get(i).getName();
2192            String tgtName = (i < targetColumnNames.size())
2193                    ? targetColumnNames.get(i) : srcName;
2194            if (tgtName == null || tgtName.isEmpty()) {
2195                continue;
2196            }
2197            outLineage.add(new LineageEdge(
2198                    LineageRef.tableColumn(targetQName, tgtName),
2199                    LineageRef.statementOutput(selectIdx, srcName)));
2200        }
2201    }
2202
2203    /**
2204     * Slice 109 — assemble the INSERT-target half (TargetRelation, INSERT
2205     * StatementGraph, RETURNING/OUTPUT projections, and cross-statement
2206     * lineage edges) when the source SELECT and its inner extractions have
2207     * ALREADY been appended directly to {@code out}/{@code outLineage} by
2208     * {@link #buildSelectBodyAfterCteWalk}. The slice-93
2209     * {@link #assembleInsertGraphAndLineage} helper, by contrast, takes a
2210     * pre-built {@link SemanticProgram} and rebases STATEMENT_OUTPUT
2211     * indices on the way in — that path is unused here because the source
2212     * SELECT was already built into absolute positions in {@code out}.
2213     *
2214     * <p>{@code selectIdx} must be the position of the source SELECT in
2215     * {@code out} (last statement appended by the caller before this helper
2216     * runs). RETURNING/OUTPUT clauses are passed directly (Hive multi-
2217     * insert callers pass {@code null}/{@code null}); other DMLs that
2218     * adopt this helper later can forward their own.
2219     */
2220    private static void assembleInsertTargetGraphFromAppended(
2221            TInsertSqlStatement insert,
2222            TTable targetTable,
2223            String targetQName,
2224            int selectIdx,
2225            String diagnosticPrefix,
2226            TReturningClause returningClause,
2227            TOutputClause outputClause,
2228            List<StatementGraph> out,
2229            List<LineageEdge> outLineage,
2230            NameBindingProvider provider) {
2231        StatementGraph sourceOuter = out.get(selectIdx);
2232        List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns();
2233        int sourceOutCount = sourceOutputs.size();
2234
2235        TObjectNameList colList = insert.getColumnList();
2236        List<String> targetColumnNames = new ArrayList<>();
2237        if (colList != null && colList.size() > 0) {
2238            for (int i = 0; i < colList.size(); i++) {
2239                TObjectName n = colList.getObjectName(i);
2240                targetColumnNames.add(n == null ? "" : n.toString());
2241            }
2242            if (targetColumnNames.size() != sourceOutCount) {
2243                throw new SemanticIRBuildException(Diagnostic.error(
2244                        DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH,
2245                        diagnosticPrefix + " column list has "
2246                                + targetColumnNames.size()
2247                                + " column(s) but source SELECT produced "
2248                                + sourceOutCount + " output(s)",
2249                        insert));
2250            }
2251        }
2252
2253        String sourceName = sourceOuter.getName();
2254        String sourceRelAlias = (sourceName != null && !sourceName.isEmpty())
2255                ? sourceName : "__insert_source__";
2256        RelationBinding sourceBinding = new RelationBinding(
2257                RelationKind.SUBQUERY, sourceRelAlias);
2258        List<RelationSource> insertRelations = new ArrayList<>();
2259        insertRelations.add(new RelationSource(sourceRelAlias, sourceBinding));
2260
2261        RelationBinding targetBinding = new RelationBinding(
2262                RelationKind.TABLE, targetQName);
2263        TargetRelation target = new TargetRelation(targetBinding, targetColumnNames);
2264
2265        int insertIdx = out.size();
2266        String insertTargetAlias = effectiveAliasOf(targetTable);
2267        if (insertTargetAlias == null || insertTargetAlias.isEmpty()) {
2268            insertTargetAlias = targetQName;
2269        }
2270        List<OutputColumn> returningCols = buildReturningColumns(
2271                returningClause,
2272                outputClause,
2273                "INSERT",
2274                targetQName,
2275                insertTargetAlias,
2276                targetTable,
2277                /*fromSideRelations=*/ Collections.<RelationSource>emptyList(),
2278                /*fromSideAliasToStmtIndex=*/ Collections.<String, Integer>emptyMap(),
2279                provider,
2280                insertIdx,
2281                outLineage,
2282                insert);
2283
2284        StatementGraph insertOuter = new StatementGraph(
2285                /*name=*/ null,
2286                "INSERT",
2287                insertRelations,
2288                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
2289                returningCols,
2290                /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2291                /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2292                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2293                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2294                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2295                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2296                /*distinct=*/ false,
2297                /*setOperator=*/ null,
2298                /*rowLimit=*/ null,
2299                target);
2300        out.add(insertOuter);
2301
2302        for (int i = 0; i < sourceOutCount; i++) {
2303            String srcName = sourceOutputs.get(i).getName();
2304            String tgtName = (i < targetColumnNames.size())
2305                    ? targetColumnNames.get(i) : srcName;
2306            if (tgtName == null || tgtName.isEmpty()) {
2307                continue;
2308            }
2309            outLineage.add(new LineageEdge(
2310                    LineageRef.tableColumn(targetQName, tgtName),
2311                    LineageRef.statementOutput(selectIdx, srcName)));
2312        }
2313    }
2314
2315    /**
2316     * Slice 93 — rebase a {@link LineageEdge}'s {@code STATEMENT_OUTPUT}
2317     * statement indices by {@code offset}. {@code TABLE_COLUMN} refs are
2318     * returned unchanged. Used to concatenate inner {@link SemanticProgram}s
2319     * into a larger one (Hive multi-insert: each INSERT-SELECT pair's inner
2320     * program contributes its own block of statements).
2321     */
2322    private static LineageEdge rebaseLineageEdge(LineageEdge e, int offset) {
2323        if (offset == 0) {
2324            return e;
2325        }
2326        LineageRef from = rebaseLineageRef(e.getFrom(), offset);
2327        LineageRef to = rebaseLineageRef(e.getTo(), offset);
2328        if (from == e.getFrom() && to == e.getTo()) {
2329            return e;
2330        }
2331        return new LineageEdge(from, to);
2332    }
2333
2334    private static LineageRef rebaseLineageRef(LineageRef ref, int offset) {
2335        if (ref == null) {
2336            return null;
2337        }
2338        if (ref.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) {
2339            return ref;
2340        }
2341        return LineageRef.statementOutput(
2342                ref.getStatementIndex() + offset, ref.getOutputName());
2343    }
2344
2345    /**
2346     * Slice 79 — admit a single {@code CREATE TABLE target [(c1, ...)] AS
2347     * SELECT ...} (CTAS) statement. Builds the source SELECT via
2348     * {@link #build} unchanged, then appends a {@code "CREATE_TABLE"}-
2349     * kind {@link StatementGraph} carrying the target relation and
2350     * cross-statement lineage edges (mirrors slice-78 INSERT).
2351     *
2352     * <p>Admitted shape: {@code CREATE [OR REPLACE] TABLE target
2353     * [(c1, c2, ...)] AS <subquery-SELECT>}. Plain
2354     * {@code CREATE TABLE target (a INT, b VARCHAR)} (column DDL with
2355     * no AS SELECT) is rejected via
2356     * {@link DiagnosticCode#CREATE_AS_NO_SOURCE_SELECT}. Explicit
2357     * column-list arity mismatch surfaces as
2358     * {@link DiagnosticCode#CREATE_AS_COLUMN_COUNT_MISMATCH}; a
2359     * missing / empty target name surfaces (defensively) as
2360     * {@link DiagnosticCode#CREATE_AS_TARGET_MISSING}.
2361     *
2362     * <p>For CTAS the explicit column-list spellings come from
2363     * {@link TCreateTableSqlStatement#getColumnList()} — only the bare
2364     * column name from each {@link TColumnDefinition} is consumed;
2365     * data-type tokens are ignored by slice 79.
2366     */
2367    /**
2368     * Build under caller-supplied {@code options}; see
2369     * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}.
2370     */
2371    public static SemanticBuildResult buildCreateTableResult(
2372            TCreateTableSqlStatement create, NameBindingProvider provider,
2373            SemanticIRBuildOptions options) {
2374        return executeBuild(options, () -> buildCreateTableImpl(create, provider));
2375    }
2376
2377    /** Build a CTAS and atomically return its program and diagnostics. */
2378    public static SemanticBuildResult buildCreateTableResult(
2379            TCreateTableSqlStatement create, NameBindingProvider provider) {
2380        return buildCreateTableResult(create, provider,
2381                SemanticIRBuildOptions.defaults());
2382    }
2383
2384    @Deprecated
2385    public static SemanticProgram buildCreateTable(TCreateTableSqlStatement create,
2386                                     NameBindingProvider provider,
2387                                     SemanticIRBuildOptions options) {
2388        clearBuildDiagnostics();
2389        return publishLegacyResult(
2390                buildCreateTableResult(create, provider, options));
2391    }
2392
2393    @Deprecated
2394    public static SemanticProgram buildCreateTable(TCreateTableSqlStatement create,
2395                                        NameBindingProvider provider) {
2396        clearBuildDiagnostics();
2397        return publishLegacyResult(buildCreateTableResult(create, provider));
2398    }
2399
2400    private static SemanticProgram buildCreateTableImpl(TCreateTableSqlStatement create,
2401                                                        NameBindingProvider provider) {
2402        if (create == null) {
2403            throw new IllegalArgumentException("create must not be null");
2404        }
2405        if (provider == null) {
2406            throw new IllegalArgumentException("provider must not be null");
2407        }
2408
2409        // Target name extraction. CTAS exposes the target via the
2410        // TCustomSqlStatement-inherited getTargetTable(); the explicit
2411        // getTableName() is a thin wrapper around tables[0].getTableName()
2412        // and also works. Use getTableName() for symmetry with the
2413        // slice-78 INSERT path.
2414        TObjectName targetName = create.getTableName();
2415        if (targetName == null) {
2416            throw new SemanticIRBuildException(Diagnostic.error(
2417                    DiagnosticCode.CREATE_AS_TARGET_MISSING,
2418                    "CREATE TABLE has no resolvable target table name",
2419                    create));
2420        }
2421        String targetQName = targetName.toString();
2422        if (targetQName == null || targetQName.isEmpty()) {
2423            throw new SemanticIRBuildException(Diagnostic.error(
2424                    DiagnosticCode.CREATE_AS_TARGET_MISSING,
2425                    "CREATE TABLE target table name is empty",
2426                    create));
2427        }
2428
2429        TSelectSqlStatement source = create.getSubQuery();
2430        if (source == null) {
2431            throw new SemanticIRBuildException(Diagnostic.error(
2432                    DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT,
2433                    "CREATE TABLE has no AS SELECT subquery; slice 79 admits "
2434                            + "CTAS (CREATE TABLE <target> [(c1, ...)] AS SELECT ...) only",
2435                    create));
2436        }
2437
2438        // Pull explicit column-list spellings BEFORE building the inner
2439        // — keeps the error path cheap for the structural-invalid case
2440        // (CTAS with column count mismatch is detected after the inner
2441        // build because we don't know the source output count yet).
2442        List<String> targetColumnNames = new ArrayList<>();
2443        TColumnDefinitionList colList = create.getColumnList();
2444        if (colList != null && colList.size() > 0) {
2445            for (int i = 0; i < colList.size(); i++) {
2446                TColumnDefinition cd = colList.getColumn(i);
2447                TObjectName n = (cd == null) ? null : cd.getColumnName();
2448                String spelling = (n == null) ? "" : n.toString();
2449                targetColumnNames.add(spelling);
2450            }
2451        }
2452
2453        return assembleCreateLikeProgram(create, source, provider,
2454                "CREATE_TABLE", targetQName, targetColumnNames);
2455    }
2456
2457    /**
2458     * Slice 79 — admit a single
2459     * {@code CREATE [OR REPLACE] VIEW v [(c1, ...)] AS SELECT ...}
2460     * statement. Mirrors {@link #buildCreateTable} except the source
2461     * SELECT is fetched via {@link TCreateViewSqlStatement#getSubquery()}
2462     * (lowercase 'q'), the target name from
2463     * {@link TCreateViewSqlStatement#getViewName()}, and the explicit
2464     * column-list spellings from {@link TViewAliasClause} on the AST.
2465     */
2466    /**
2467     * Build under caller-supplied {@code options}; see
2468     * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}.
2469     */
2470    public static SemanticBuildResult buildCreateViewResult(
2471            TCreateViewSqlStatement create, NameBindingProvider provider,
2472            SemanticIRBuildOptions options) {
2473        return executeBuild(options, () -> buildCreateViewImpl(create, provider));
2474    }
2475
2476    /** Build a CREATE VIEW and atomically return its program and diagnostics. */
2477    public static SemanticBuildResult buildCreateViewResult(
2478            TCreateViewSqlStatement create, NameBindingProvider provider) {
2479        return buildCreateViewResult(create, provider,
2480                SemanticIRBuildOptions.defaults());
2481    }
2482
2483    @Deprecated
2484    public static SemanticProgram buildCreateView(TCreateViewSqlStatement create,
2485                                     NameBindingProvider provider,
2486                                     SemanticIRBuildOptions options) {
2487        clearBuildDiagnostics();
2488        return publishLegacyResult(
2489                buildCreateViewResult(create, provider, options));
2490    }
2491
2492    @Deprecated
2493    public static SemanticProgram buildCreateView(TCreateViewSqlStatement create,
2494                                        NameBindingProvider provider) {
2495        clearBuildDiagnostics();
2496        return publishLegacyResult(buildCreateViewResult(create, provider));
2497    }
2498
2499    private static SemanticProgram buildCreateViewImpl(TCreateViewSqlStatement create,
2500                                                       NameBindingProvider provider) {
2501        if (create == null) {
2502            throw new IllegalArgumentException("create must not be null");
2503        }
2504        if (provider == null) {
2505            throw new IllegalArgumentException("provider must not be null");
2506        }
2507
2508        TObjectName viewName = create.getViewName();
2509        if (viewName == null) {
2510            throw new SemanticIRBuildException(Diagnostic.error(
2511                    DiagnosticCode.CREATE_AS_TARGET_MISSING,
2512                    "CREATE VIEW has no resolvable view name",
2513                    create));
2514        }
2515        String targetQName = viewName.toString();
2516        if (targetQName == null || targetQName.isEmpty()) {
2517            throw new SemanticIRBuildException(Diagnostic.error(
2518                    DiagnosticCode.CREATE_AS_TARGET_MISSING,
2519                    "CREATE VIEW target view name is empty",
2520                    create));
2521        }
2522
2523        TSelectSqlStatement source = create.getSubquery();
2524        if (source == null) {
2525            throw new SemanticIRBuildException(Diagnostic.error(
2526                    DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT,
2527                    "CREATE VIEW has no AS SELECT subquery; slice 79 admits "
2528                            + "CREATE VIEW <target> [(c1, ...)] AS SELECT ... only",
2529                    create));
2530        }
2531
2532        // View-side explicit column aliases via viewAliasClause. Items
2533        // whose alias is null are preserved as empty-string entries so
2534        // a parser-quirk gap doesn't silently collapse the list and
2535        // shift later aliases onto wrong source-output positions —
2536        // count-mismatch detection downstream stays accurate
2537        // (codex diff-review round 1 P2 catch).
2538        List<String> targetColumnNames = new ArrayList<>();
2539        TViewAliasClause aliasClause = create.getViewAliasClause();
2540        if (aliasClause != null) {
2541            TViewAliasItemList items = aliasClause.getViewAliasItemList();
2542            if (items != null) {
2543                for (int i = 0; i < items.size(); i++) {
2544                    TViewAliasItem item = items.getViewAliasItem(i);
2545                    TObjectName alias = (item == null) ? null : item.getAlias();
2546                    String spelling = (alias == null) ? "" : alias.toString();
2547                    targetColumnNames.add(spelling);
2548                }
2549            }
2550        }
2551
2552        return assembleCreateLikeProgram(create, source, provider,
2553                "CREATE_VIEW", targetQName, targetColumnNames);
2554    }
2555
2556    /**
2557     * Shared assembly path for slice-79 CTAS / CREATE VIEW. Given a
2558     * pre-validated target name and the (possibly empty) list of
2559     * explicit column-list spellings, builds the source SELECT,
2560     * validates column-list arity, and emits the outer
2561     * StatementGraph + cross-stmt lineage edges. Mirrors the
2562     * post-source half of slice-78 {@link #buildInsert}.
2563     */
2564    private static SemanticProgram assembleCreateLikeProgram(
2565            TParseTreeNode anchor, TSelectSqlStatement source,
2566            NameBindingProvider provider, String outerKind,
2567            String targetQName, List<String> targetColumnNames) {
2568        // Internal recursion — inherit ambient options (see buildInsertImpl).
2569        SemanticProgram inner = buildImpl(source, provider);
2570        List<StatementGraph> innerStmts = inner.getStatements();
2571        if (innerStmts.isEmpty()) {
2572            throw new SemanticIRBuildException(Diagnostic.error(
2573                    DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT,
2574                    "CREATE source built no statements",
2575                    anchor));
2576        }
2577        int selectIdx = innerStmts.size() - 1;
2578        StatementGraph sourceOuter = innerStmts.get(selectIdx);
2579        List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns();
2580        int sourceOutCount = sourceOutputs.size();
2581
2582        if (!targetColumnNames.isEmpty()
2583                && targetColumnNames.size() != sourceOutCount) {
2584            throw new SemanticIRBuildException(Diagnostic.error(
2585                    DiagnosticCode.CREATE_AS_COLUMN_COUNT_MISMATCH,
2586                    outerKind.equals("CREATE_TABLE")
2587                            ? ("CREATE TABLE column list has " + targetColumnNames.size()
2588                                    + " column(s) but source SELECT produced "
2589                                    + sourceOutCount + " output(s)")
2590                            : ("CREATE VIEW alias list has " + targetColumnNames.size()
2591                                    + " column(s) but source SELECT produced "
2592                                    + sourceOutCount + " output(s)"),
2593                    anchor));
2594        }
2595
2596        String sourceName = sourceOuter.getName();
2597        String sourceRelAlias = (sourceName != null && !sourceName.isEmpty())
2598                ? sourceName : "__create_source__";
2599        RelationBinding sourceBinding = new RelationBinding(
2600                RelationKind.SUBQUERY, sourceRelAlias);
2601        List<RelationSource> createRelations = new ArrayList<>();
2602        createRelations.add(new RelationSource(sourceRelAlias, sourceBinding));
2603
2604        RelationBinding targetBinding = new RelationBinding(
2605                RelationKind.TABLE, targetQName);
2606        TargetRelation target = new TargetRelation(targetBinding, targetColumnNames);
2607
2608        List<StatementGraph> out = new ArrayList<>(innerStmts.size() + 1);
2609        out.addAll(innerStmts);
2610        List<LineageEdge> outLineage = new ArrayList<>(inner.getLineage());
2611
2612        StatementGraph createOuter = new StatementGraph(
2613                /*name=*/ null,
2614                outerKind,
2615                createRelations,
2616                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
2617                /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2618                /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2619                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2620                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2621                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2622                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
2623                /*distinct=*/ false,
2624                /*setOperator=*/ null,
2625                /*rowLimit=*/ null,
2626                target);
2627        out.add(createOuter);
2628
2629        for (int i = 0; i < sourceOutCount; i++) {
2630            String srcName = sourceOutputs.get(i).getName();
2631            String tgtName = (i < targetColumnNames.size())
2632                    ? targetColumnNames.get(i) : srcName;
2633            if (tgtName == null || tgtName.isEmpty()) {
2634                continue;
2635            }
2636            outLineage.add(new LineageEdge(
2637                    LineageRef.tableColumn(targetQName, tgtName),
2638                    LineageRef.statementOutput(selectIdx, srcName)));
2639        }
2640
2641        return new SemanticProgram(out, outLineage);
2642    }
2643
2644    /**
2645     * Slice 80 / 82 — admit {@code UPDATE target SET c1 = expr1,
2646     * c2 = expr2, ... [FROM source_list] [WHERE pred]} statements.
2647     * Emits one {@code "UPDATE"}-kind {@link StatementGraph} carrying
2648     * the target relation plus synthetic {@link OutputColumn} entries
2649     * per SET assignment (output name = SET LHS verbatim spelling;
2650     * sources = column refs collected from the RHS expression).
2651     * Optional WHERE refs surface on
2652     * {@link StatementGraph#getFilterColumnRefs()}.
2653     *
2654     * <p>Slice 82 lifts the slice-80 {@code UPDATE_JOINED_NOT_SUPPORTED}
2655     * reject for the common PG / MSSQL / BigQuery / Snowflake / Redshift
2656     * FROM-side joined UPDATE shapes. The IR shape gains two slots:
2657     * {@code relations[]} now carries TABLE-kind RelationSources for
2658     * FROM-side sources (slice 80 left empty), and
2659     * {@code joinColumnRefs[]} now carries ON-clause column refs from
2660     * FROM-side JOINs. The target stays on
2661     * {@link StatementGraph#getTarget()}; a reference-identity filter
2662     * excludes the target's own TTable instance from {@code relations[]}.
2663     *
2664     * <p>Admitted shape:
2665     * <ul>
2666     *   <li>Single-target UPDATE without FROM (slice 80) —
2667     *       {@code relations[]} stays empty.</li>
2668     *   <li>PG / BQ / SF / RS {@code UPDATE t SET ... FROM source}
2669     *       (single FROM source).</li>
2670     *   <li>PG / BQ {@code UPDATE t SET ... FROM s1, s2, ...}
2671     *       (comma-FROM list).</li>
2672     *   <li>PG / MSSQL {@code UPDATE t SET ... FROM s1 [INNER|LEFT|RIGHT|FULL OUTER] JOIN s2 ON ...}
2673     *       — ON refs populate {@code joinColumnRefs[]}.</li>
2674     *   <li>MSSQL {@code UPDATE t SET ... FROM t INNER JOIN s ON ...}
2675     *       — target may appear in FROM; reference-identity filter
2676     *       excludes the target's own TTable instance from
2677     *       {@code relations[]}.</li>
2678     *   <li>Explicit {@code CROSS JOIN} (no ON; semantically equivalent
2679     *       to comma-FROM).</li>
2680     *   <li>SET LHS is a {@link EExpressionType#simple_object_name_t}
2681     *       column reference (qualified {@code t.x} or bare {@code x}).
2682     *       Oracle tuple {@code SET (a, b) = (...)} (LHS = list_t)
2683     *       rejects via
2684     *       {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}.</li>
2685     *   <li>SET RHS may be any expression NOT containing a scalar
2686     *       subquery and NOT containing a window function. Subqueries
2687     *       reject via
2688     *       {@link DiagnosticCode#UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED};
2689     *       window functions reuse the existing
2690     *       {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK}
2691     *       routed through {@link #rejectWindowFunctionInScope}.</li>
2692     *   <li>Optional WHERE clause — existing WHERE-side rejects
2693     *       (subqueries, window functions) continue to apply via the
2694     *       shared {@link #containsAnySubquery} +
2695     *       {@code rejectWindowFunctionInScope} helpers used by SELECT
2696     *       WHERE.</li>
2697     * </ul>
2698     *
2699     * <p>Slice 82 reject scope, with slice 83 admitting subquery FROM
2700     * sources (the slice-82 {@code UPDATE_FROM_SUBQUERY_NOT_SUPPORTED}
2701     * code stays declared but unreached — slice-71/72
2702     * retain-for-documentation precedent):
2703     * <ul>
2704     *   <li>Subquery as a FROM source — slice 83 admits via the
2705     *       SELECT-side {@code processDirectSubqueryTable} extractor,
2706     *       publishing a SUBQUERY-kind {@link RelationSource} and a
2707     *       cross-statement {@link LineageEdge} per subquery-bound
2708     *       output source.</li>
2709     *   <li>USING in any FROM-side join item —
2710     *       {@link DiagnosticCode#UPDATE_FROM_JOIN_USING_NOT_SUPPORTED}.</li>
2711     *   <li>NATURAL JOIN in any FROM-side join item —
2712     *       {@link DiagnosticCode#UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED}.</li>
2713     *   <li>Subquery in any ON condition —
2714     *       {@link DiagnosticCode#UPDATE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED}.</li>
2715     *   <li>Window function in any ON condition — reuses
2716     *       {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} via
2717     *       {@link #rejectWindowFunctionInScope}.</li>
2718     * </ul>
2719     *
2720     * <p>Deferred (rejected at the outer level before any SET
2721     * processing):
2722     * <ul>
2723     *   <li>Top-level WITH on UPDATE →
2724     *       {@link DiagnosticCode#UPDATE_CTE_NOT_SUPPORTED}.</li>
2725     *   <li>RETURNING projection (PG / Oracle) →
2726     *       {@link DiagnosticCode#UPDATE_RETURNING_CLAUSE_NOT_SUPPORTED}.</li>
2727     *   <li>OUTPUT projection (SQL Server) →
2728     *       {@link DiagnosticCode#UPDATE_OUTPUT_CLAUSE_NOT_SUPPORTED}.</li>
2729     *   <li>ORDER BY / LIMIT on UPDATE (MySQL / Couchbase) →
2730     *       {@link DiagnosticCode#UPDATE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED}.</li>
2731     *   <li>Empty / missing SET clause, Couchbase UNSET-only updates →
2732     *       {@link DiagnosticCode#UPDATE_NO_SET_CLAUSE}.</li>
2733     *   <li>Missing target table (defensive) →
2734     *       {@link DiagnosticCode#UPDATE_TARGET_MISSING}.</li>
2735     * </ul>
2736     *
2737     * <p>Cross-statement {@link LineageEdge}s, one per SET assignment:
2738     * <pre>
2739     *   from = LineageRef.tableColumn(targetQName, target_col_i)
2740     *   to   = LineageRef.statementOutput(0, output_name_i)
2741     * </pre>
2742     * Statement index 0 is the UPDATE statement itself — the synthetic
2743     * output IS the per-assignment "projection" that flows into the
2744     * target column. This is the slice-78 INSERT contract
2745     * (TABLE_COLUMN → STATEMENT_OUTPUT) with the source SELECT replaced
2746     * by the UPDATE's own per-assignment outputs; consumers read
2747     * {@code outputs[i].sources} to enumerate the RHS column refs that
2748     * feed the target column.
2749     */
2750    /**
2751     * Build under caller-supplied {@code options}; see
2752     * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}.
2753     */
2754    public static SemanticBuildResult buildUpdateResult(
2755            TUpdateSqlStatement update, NameBindingProvider provider,
2756            SemanticIRBuildOptions options) {
2757        return executeBuild(options, () -> buildUpdateImpl(update, provider));
2758    }
2759
2760    /** Build an UPDATE and atomically return its program and diagnostics. */
2761    public static SemanticBuildResult buildUpdateResult(
2762            TUpdateSqlStatement update, NameBindingProvider provider) {
2763        return buildUpdateResult(update, provider,
2764                SemanticIRBuildOptions.defaults());
2765    }
2766
2767    @Deprecated
2768    public static SemanticProgram buildUpdate(TUpdateSqlStatement update,
2769                                     NameBindingProvider provider,
2770                                     SemanticIRBuildOptions options) {
2771        clearBuildDiagnostics();
2772        return publishLegacyResult(buildUpdateResult(update, provider, options));
2773    }
2774
2775    @Deprecated
2776    public static SemanticProgram buildUpdate(TUpdateSqlStatement update,
2777                                        NameBindingProvider provider) {
2778        clearBuildDiagnostics();
2779        return publishLegacyResult(buildUpdateResult(update, provider));
2780    }
2781
2782    private static SemanticProgram buildUpdateImpl(TUpdateSqlStatement update,
2783                                                   NameBindingProvider provider) {
2784        if (update == null) {
2785            throw new IllegalArgumentException("update must not be null");
2786        }
2787        if (provider == null) {
2788            throw new IllegalArgumentException("provider must not be null");
2789        }
2790
2791        // Slice 86 — defensive UsingScope reset at entry so a parent
2792        // scope cannot leak into UPDATE's binding decisions. Mirrors
2793        // SELECT-side buildSelectStatementImpl (slice 65). The UPDATE's
2794        // own UsingScope is installed after the FROM-join walker (step
2795        // 5.8 below).
2796        provider = provider.withUsingScope(UsingScope.EMPTY);
2797
2798        // 1) Slice 105 — admit top-level WITH on UPDATE. Walks the CTE
2799        // list left-to-right, building each body as a preceding
2800        // StatementGraph and producing cteNameToStatementIndex +
2801        // ctePublishedColumns for the FROM-as-CTE branch in
2802        // buildUpdateRelation below. Mirrors the slice-101 MERGE walker.
2803        // `stmts` / `lineage` allocated here (hoisted from the prior
2804        // slice-83 location) so the CTE walker can append.
2805        // UPDATE_CTE_NOT_SUPPORTED stays declared-but-unreached
2806        // (slice 71/72/82/86/95/96/97/98/99/100/101/102/103/104 precedent).
2807        List<StatementGraph> stmts = new ArrayList<>();
2808        List<LineageEdge> lineage = new ArrayList<>();
2809        Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>();
2810        Map<String, Integer> cteNameToStatementIndex = buildUpdateCteList(
2811                update, provider, stmts, lineage, ctePublishedColumns);
2812
2813        // 2) Target table — defensive (parser usually rejects first).
2814        TTable targetTable = update.getTargetTable();
2815        if (targetTable == null || targetTable.getTableName() == null) {
2816            throw new SemanticIRBuildException(Diagnostic.error(
2817                    DiagnosticCode.UPDATE_TARGET_MISSING,
2818                    "UPDATE statement has no resolvable target table",
2819                    update));
2820        }
2821        String targetQName = targetTable.getTableName().toString();
2822        if (targetQName == null || targetQName.isEmpty()) {
2823            throw new SemanticIRBuildException(Diagnostic.error(
2824                    DiagnosticCode.UPDATE_TARGET_MISSING,
2825                    "UPDATE target table name is empty",
2826                    update));
2827        }
2828
2829        // 3) Slice 82 — FROM-side joined UPDATE is now admitted. The
2830        // slice-80 UPDATE_JOINED_NOT_SUPPORTED rejects (which fired on
2831        // update.tables.size() > 1 and update.getFromSourceJoin() != null)
2832        // are removed. The shape-specific rejects below (subquery in
2833        // FROM, USING, NATURAL, subquery in ON, window in ON) replace
2834        // them. UPDATE_JOINED_NOT_SUPPORTED remains declared but
2835        // unreached for API stability (the residual join-form-target
2836        // shape `UPDATE (a JOIN b) SET ...` does not parse in any
2837        // supported dialect — verified by AST probe).
2838        //
2839        // Reject ordering within buildUpdate: WITH / target-missing /
2840        // RETURNING / OUTPUT / ORDER BY / LIMIT / SET-empty all run
2841        // before the per-source FROM walk so a single rejection wins
2842        // on multi-violation shapes (e.g. `UPDATE t ... FROM s
2843        // RETURNING ...` rejects RETURNING before the FROM walk).
2844
2845        // 4) Slice 85 lifts the RETURNING / OUTPUT rejects — projections
2846        // are now admitted via {@link #buildReturningColumns} called after
2847        // SET / WHERE / FROM walks complete (the projection expressions
2848        // need the providerWithStar binding constructed in step 5.5).
2849        // The cheap statement-level OUTPUT_INTO reject fires here so a
2850        // multi-violation shape (OUTPUT … INTO target with RETURNING
2851        // content errors) routes to the cheaper structural code first.
2852        // {@code UPDATE_RETURNING_CLAUSE_NOT_SUPPORTED} and
2853        // {@code UPDATE_OUTPUT_CLAUSE_NOT_SUPPORTED} stay declared but
2854        // unreached (slice 71/72 retain-for-documentation precedent).
2855        if (update.getOutputClause() != null
2856                && update.getOutputClause().getIntoTable() != null) {
2857            throw new SemanticIRBuildException(Diagnostic.error(
2858                    DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED,
2859                    "UPDATE OUTPUT ... INTO <target> writes a second target; "
2860                            + "slice 85 admits projection-only OUTPUT",
2861                    update));
2862        }
2863        if (update.getOrderByClause() != null
2864                || update.getLimitClause() != null) {
2865            throw new SemanticIRBuildException(Diagnostic.error(
2866                    DiagnosticCode.UPDATE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED,
2867                    "UPDATE with ORDER BY / LIMIT (MySQL / Couchbase) is "
2868                            + "not supported by SemanticIRBuilder.buildUpdate; "
2869                            + "slice 80 admits no row-pruning on UPDATE",
2870                    update));
2871        }
2872
2873        // 5) SET / UNSET — slice 80 requires a non-empty SET clause; a
2874        // Couchbase UNSET-only update (UnSetTerms populated, SET empty)
2875        // routes through the same code with discriminating message text.
2876        TResultColumnList sets = update.getResultColumnList();
2877        boolean hasUnSet = update.getUnSetTerms() != null
2878                && update.getUnSetTerms().size() > 0;
2879        if (sets == null || sets.size() == 0) {
2880            String reason = hasUnSet
2881                    ? "UPDATE has only an UNSET clause (Couchbase); slice 80 "
2882                            + "requires a non-empty SET clause"
2883                    : "UPDATE has no SET clause";
2884            throw new SemanticIRBuildException(Diagnostic.error(
2885                    DiagnosticCode.UPDATE_NO_SET_CLAUSE,
2886                    reason,
2887                    update));
2888        }
2889
2890        // 5.5) Slice 83 — extract FROM subqueries as their own
2891        // StatementGraphs (after slice 105's CTE walker so the CTE
2892        // bodies precede any extracted FROM-subquery in the program).
2893        //
2894        // The extractor reuses the SELECT-side
2895        // {@link #processDirectSubqueryTable} verbatim — passing the
2896        // slice-105 cteNameToStatementIndex + ctePublishedColumns so a
2897        // nested SELECT inside a FROM-subquery can still resolve outer
2898        // CTE references through CTEScope (Resolver2 already binds CTE
2899        // refs in UPDATE correctly; the maps are passed for parity with
2900        // the SELECT/MERGE call sites). Inner predicate subqueries in
2901        // WHERE / JOIN ON / GROUP BY are caught by the slice-17 leak
2902        // guard ({@link #rejectSubqueriesInFromSubqueryBodyClauses}).
2903        //
2904        // No snapshot/rollback wrapper here (codex round-1 Q5 NICE):
2905        // buildUpdate owns fresh local stmts/lineage lists and
2906        // propagates exceptions to the caller — no observer can see
2907        // partial mutation.
2908        //
2909        // Slice 110 — decorate `provider` with `withCteContext` BEFORE
2910        // passing it to `extractUpdateFromSubqueries` so a nested SELECT
2911        // inside an extracted FROM-subquery body (e.g.
2912        // `UPDATE t SET col = sub.x FROM (SELECT id, x FROM cte) sub`)
2913        // routes CTE refs through `RelationKind.CTE`. Mirrors the
2914        // slice-106 DELETE-side `providerWithCte` pattern at line ~3205
2915        // (codex round-2 Q2 BLOCKING fix in slice 106). The slice-105
2916        // UPDATE site missed this decoration; slice 110 closes the gap
2917        // here since it also adds the same decoration on the WHERE-side
2918        // predicate-subquery extraction (line ~2370 below).
2919        NameBindingProvider providerWithCte = cteNameToStatementIndex.isEmpty()
2920                ? provider
2921                : provider.withCteContext(cteNameToStatementIndex.keySet());
2922        Map<String, Integer> subqueryAliasToIndex =
2923                extractUpdateFromSubqueries(update, providerWithCte, stmts, lineage,
2924                        cteNameToStatementIndex, ctePublishedColumns);
2925        // Build the in-scope map (subquery-alias → published column
2926        // names, plus CTE-bound alias → CTE published columns) so
2927        // `provider.withInScopeRelationColumns(map)` recognises
2928        // `sub.x` AND `cte.x` for the consuming UPDATE. Base-table
2929        // FROM-side relations don't need an entry; their column
2930        // resolution stays on the Resolver2 catalog path.
2931        Map<String, List<String>> updateInScope = buildUpdateInScopeMap(
2932                update, subqueryAliasToIndex, stmts,
2933                cteNameToStatementIndex, ctePublishedColumns);
2934        // Slice 110 — base `providerWithStar` on `providerWithCte`
2935        // (instead of raw `provider`) so SET RHS / WHERE / RETURNING
2936        // collectors and the slice-86 USING/NATURAL walker all see the
2937        // outer CTE context. Without this, a CTE-bound reference inside
2938        // a JOIN ON expression or a SET RHS scalar would bind as TABLE-
2939        // kind even when the CTE is declared at the UPDATE level.
2940        NameBindingProvider providerWithStar = updateInScope.isEmpty()
2941                ? providerWithCte
2942                : providerWithCte.withInScopeRelationColumns(updateInScope);
2943
2944        // 5.7) Slice 86 — relocated from slice-82 step 8. The FROM-side
2945        // join walker now runs BEFORE SET RHS / WHERE collection so the
2946        // slice-86 UsingScope (step 5.8 below) can be applied to those
2947        // collectors. Slice 65 SELECT-side ordering: buildRelations →
2948        // buildUsingScope → buildOutputColumns / buildFilter / etc.
2949        // The join walker uses `providerWithStar` (inScope only — no
2950        // UsingScope yet) because USING/NATURAL emit joinColumnRefs[]
2951        // directly via emitMergedJoinRefs without consulting UsingScope.
2952        //
2953        // The walker treats `update.getJoins()` as the authoritative
2954        // FROM-list representation:
2955        //  - PG plain `FROM s` →   joins=[{table=s, items=[]}]
2956        //  - PG comma-FROM   →     joins=[{s1, items=[]}, {s2, items=[]}, ...]
2957        //  - PG / MSSQL explicit JOIN → joins=[{driver, items=[item1,...]}]
2958        //  - MSSQL target-in-FROM → joins=[{target_alias, items=[other,...]}]
2959        //
2960        // For each TJoin: the driver table goes through buildUpdateRelation
2961        // (which applies the slice-82 FROM-source rejects + identity
2962        // filter); each JoinItem is walked through buildUpdateJoinItem
2963        // which (slice 86) admits USING / NATURAL via slice-64/65/66
2964        // shared helpers in addition to ON / CROSS.
2965        List<RelationSource> relations = new ArrayList<>();
2966        // Slice 82 codex round-1 Q2 BLOCKING — LinkedHashSet dedup spans
2967        // the whole FROM so a column appearing in two ON clauses
2968        // produces one entry. Slice 86 USING/NATURAL emit refs also flow
2969        // through this dedup.
2970        java.util.LinkedHashSet<ColumnRef> joinRefsSet =
2971                new java.util.LinkedHashSet<>();
2972        for (TJoin join : update.getJoins()) {
2973            TTable leftTable = join.getTable();
2974            buildUpdateRelation(leftTable, targetTable, relations, update,
2975                    cteNameToStatementIndex);
2976            TJoinItemList items = join.getJoinItems();
2977            if (items == null) continue;
2978            // Slice 86 — per top-level TJoin LeftOutputState seeded
2979            // with providerWithStar (codex round-1 B2 BLOCKING: inScope
2980            // installed so extracted FROM-subquery drivers' published
2981            // columns are visible to lookupRelationColumnNames for
2982            // NATURAL inference). Reset between top-level TJoins so
2983            // comma-FROM groups stay independent (matches SELECT-side
2984            // buildRelations slice-66 behavior).
2985            LeftOutputState leftState = new LeftOutputState();
2986            seedLeftOutput(leftState, leftTable, providerWithStar);
2987            for (int i = 0; i < items.size(); i++) {
2988                TJoinItem item = items.getJoinItem(i);
2989                // Slice 86 — extended buildUpdateJoinItem signature
2990                // threads the join context (topJoin / items / itemIndex)
2991                // and LeftOutputState to the USING/NATURAL admit paths
2992                // so they can call the SELECT-side slice-64/65/66
2993                // shared helpers verbatim.
2994                // Slice 105 — threads cteNameToStatementIndex so the
2995                // join walker's per-item buildUpdateRelation call can
2996                // route objectname-typed CTE references to a SUBQUERY-
2997                // kind RelationSource pointing at the CTE statement.
2998                buildUpdateJoinItem(join, items, i, targetTable,
2999                        providerWithStar, relations, joinRefsSet, leftState,
3000                        update, cteNameToStatementIndex);
3001            }
3002        }
3003        List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet);
3004
3005        // 5.8) Slice 86 — install the UPDATE's own UsingScope on
3006        // providerWithStar AFTER the join walker so SET RHS / WHERE /
3007        // RETURNING refs see merged-key resolution (mirrors SELECT-side
3008        // buildSelectStatementImpl slice 65 ordering). The join walker
3009        // itself emits joinColumnRefs via direct emit-refs helpers, so
3010        // UsingScope is irrelevant to ON refs (matches SELECT-side
3011        // contract).
3012        UsingScope updateUsingScope = buildUpdateUsingScope(update, providerWithStar);
3013        if (!updateUsingScope.isEmpty()) {
3014            providerWithStar = providerWithStar.withUsingScope(updateUsingScope);
3015        }
3016
3017        // 5.9) Slice 115 — extract uncorrelated scalar subqueries on SET
3018        // RHS as their own <scalar_subquery_<idx>> StatementGraphs
3019        // appended to `stmts` BEFORE the UPDATE statement. Mirrors slice
3020        // 11 SELECT-side scalar projection extraction. A SET assignment
3021        // whose RHS is exactly a top-level subquery_t admits as a scalar
3022        // SET RHS: the body is built via buildSelectStatement (with the
3023        // slice-11 scalar-body invariants: allowFromSubqueries=false,
3024        // allowScalarProjectionSubqueries=false, allowWindowProjection=
3025        // false), inner predicate-leak guards run, and the resulting
3026        // ScalarInfo (extracted body index + inner output name) is stored
3027        // for the per-assignment loop and lineage emission below.
3028        //
3029        // Correlated scalar subqueries (whose inner refs would resolve to
3030        // an outer alias such as the UPDATE target or a FROM-side
3031        // relation) STILL reject via the slice-11 promoter called with
3032        // EnclosingScope.empty() — the inner ref's alias does not match
3033        // any local relation and no enclosing scope is provided, so
3034        // promoteCorrelatedRefsToOuterReference throws
3035        // SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS. Lifting UPDATE-side
3036        // correlation is a follow-up slice (slice 14 SELECT analogue
3037        // extended to UPDATE).
3038        Map<Integer, List<ScalarInfo>> setRhsScalarInfo =
3039                extractScalarSubqueriesFromUpdateSetRhs(update, providerWithStar,
3040                        stmts, lineage, cteNameToStatementIndex,
3041                        subqueryAliasToIndex);
3042
3043        // 6) Per-assignment processing. Each TResultColumn carries an
3044        // assignment_t TExpression whose leftOperand is the SET LHS
3045        // (target column reference) and whose rightOperand is the value
3046        // expression. We collect:
3047        //   - target column spelling   → TargetRelation.columns[i]
3048        //   - synthetic output name    → outputs[i].name (verbatim LHS
3049        //                                   spelling, mirrors slice-78
3050        //                                   INSERT column-list contract)
3051        //   - RHS source column refs   → outputs[i].sources
3052        List<OutputColumn> outputs = new ArrayList<>();
3053        List<String> targetColumnNames = new ArrayList<>();
3054        for (int i = 0; i < sets.size(); i++) {
3055            TResultColumn rc = sets.getResultColumn(i);
3056            TExpression assignment = (rc == null) ? null : rc.getExpr();
3057            // Defensive: per TUpdateSqlStatement's javadoc each SET term
3058            // is an assignment_t. If the parser produced something else
3059            // (no AST shape observed in the tested corpora) we still
3060            // route through TUPLE_ASSIGNMENT_NOT_SUPPORTED so an
3061            // unexpected shape surfaces a stable diagnostic.
3062            if (assignment == null
3063                    || assignment.getExpressionType() != EExpressionType.assignment_t) {
3064                throw new SemanticIRBuildException(Diagnostic.error(
3065                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
3066                        "UPDATE SET assignment #" + (i + 1) + " is not a "
3067                                + "simple column-value assignment_t; slice 80 "
3068                                + "admits target_col = expr assignments only",
3069                        update));
3070            }
3071            TExpression lhs = assignment.getLeftOperand();
3072            TExpression rhs = assignment.getRightOperand();
3073            if (lhs == null || rhs == null) {
3074                throw new SemanticIRBuildException(Diagnostic.error(
3075                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
3076                        "UPDATE SET assignment #" + (i + 1)
3077                                + " is missing an operand",
3078                        update));
3079            }
3080            // Tuple LHS (Oracle) - SET (a, b) = (SELECT c1, c2 FROM ...)
3081            // surfaces as list_t. Reject before any subquery-on-RHS
3082            // walk so the diagnostic clearly identifies the tuple shape.
3083            if (lhs.getExpressionType() == EExpressionType.list_t) {
3084                throw new SemanticIRBuildException(Diagnostic.error(
3085                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
3086                        "UPDATE SET tuple assignment '(a, b) = ...' is not "
3087                                + "supported by SemanticIRBuilder.buildUpdate; "
3088                                + "slice 80 admits target_col = expr only",
3089                        update));
3090            }
3091            if (lhs.getExpressionType() != EExpressionType.simple_object_name_t) {
3092                throw new SemanticIRBuildException(Diagnostic.error(
3093                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
3094                        "UPDATE SET assignment #" + (i + 1) + " LHS is "
3095                                + "expressionType=" + lhs.getExpressionType()
3096                                + "; slice 80 admits simple column references only",
3097                        update));
3098            }
3099            TObjectName targetCol = lhs.getObjectOperand();
3100            if (targetCol == null) {
3101                throw new SemanticIRBuildException(Diagnostic.error(
3102                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
3103                        "UPDATE SET assignment #" + (i + 1) + " LHS has no "
3104                                + "TObjectName operand",
3105                        update));
3106            }
3107            String colSpelling = targetCol.toString();
3108
3109            // Slice 115 — top-level subquery_t SET RHS already extracted
3110            // in step 5.9 as a <scalar_subquery_<idx>> StatementGraph.
3111            // OutputColumn carries empty sources; slice-115/119 cross-stmt
3112            // edge below wires the consumer to the extracted body.
3113            if (rhs.getExpressionType() == EExpressionType.subquery_t) {
3114                targetColumnNames.add(colSpelling);
3115                outputs.add(new OutputColumn(colSpelling,
3116                        /*derived=*/ true, /*aggregate=*/ false,
3117                        Collections.<ColumnRef>emptyList()));
3118                continue;
3119            }
3120            // Slice 119 — mixed-expression scalar subquery path: subquery
3121            // nested inside a compound RHS (e.g. `SET col = (SELECT...) + 1`).
3122            // The scalar(s) were already extracted in step 5.9; collect only
3123            // the non-subquery column refs by skipping extracted subq nodes.
3124            if (containsAnySubqueryExpression(rhs)) {
3125                List<TExpression> subqRootsList =
3126                        collectNestedSubqueryExpressions(rhs);
3127                if (subqRootsList.isEmpty()) {
3128                    // P2-1 codex-review: containsAnySubqueryExpression
3129                    // returned true (via getSubQuery() != null) but no
3130                    // subquery_t nodes were found by acceptChildren
3131                    // traversal (e.g. EXISTS or non-scalar predicate
3132                    // subquery). Preserve the original reject so lineage
3133                    // is not silently dropped.
3134                    throw new SemanticIRBuildException(Diagnostic.error(
3135                            DiagnosticCode.UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED,
3136                            "UPDATE SET assignment #" + (i + 1) + " right-hand "
3137                                    + "side contains a non-scalar subquery "
3138                                    + "(slice 119 admits only scalar subquery_t "
3139                                    + "inside compound expressions)",
3140                            update));
3141                }
3142                Set<TExpression> subqRoots = Collections.newSetFromMap(
3143                        new IdentityHashMap<TExpression, Boolean>());
3144                subqRoots.addAll(subqRootsList);
3145                // P2-2 codex-review: window functions in the non-subquery
3146                // part of a compound RHS are still illegal. Use the
3147                // skipping variant so scalar body contents are not scanned
3148                // (window functions inside a scalar SELECT are legitimate).
3149                rejectWindowFunctionInScopeSkipping(rhs, "UPDATE SET RHS",
3150                        subqRoots);
3151                List<ColumnRef> sources = collectColumnRefsSkipping(
3152                        rhs, providerWithStar, subqRoots);
3153                targetColumnNames.add(colSpelling);
3154                outputs.add(new OutputColumn(colSpelling,
3155                        /*derived=*/ true, /*aggregate=*/ false, sources));
3156                continue;
3157            }
3158            // Window function on RHS — reuse the existing scope reject.
3159            rejectWindowFunctionInScope(rhs, "UPDATE SET RHS");
3160
3161            // Collect physical column refs from the RHS expression.
3162            List<ColumnRef> sources = collectColumnRefs(rhs, providerWithStar);
3163            boolean derived =
3164                    rhs.getExpressionType() != EExpressionType.simple_object_name_t;
3165            targetColumnNames.add(colSpelling);
3166            outputs.add(new OutputColumn(colSpelling,
3167                    derived, /*aggregate=*/ false, sources));
3168        }
3169
3170        // 7) WHERE refs — slice 110 lifts the slice-80 blanket subquery
3171        // reject by routing uncorrelated predicate-subquery wrappers
3172        // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
3173        // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction pipeline
3174        // refactored to take a PredicateClauseContext. Each extracted
3175        // wrapper lands as its own <predicate_subquery_<i>> StatementGraph
3176        // BEFORE the UPDATE statement (so updateIdx already accounts for
3177        // them via stmts.size() below). Remaining non-subquery refs flow
3178        // into filterColumnRefs via collectColumnRefsSkipping. SET-RHS
3179        // subqueries still reject (slice-110 scope excludes SET RHS).
3180        // Window functions in non-subquery subtrees still reject via
3181        // the existing rejectWindowFunctionInScopeSkipping helper.
3182        List<ColumnRef> filterRefs;
3183        TWhereClause where = update.getWhereClause();
3184        if (where == null || where.getCondition() == null) {
3185            filterRefs = Collections.<ColumnRef>emptyList();
3186        } else {
3187            Set<TExpression> extractedWhereRoots =
3188                    Collections.<TExpression>emptySet();
3189            if (containsAnySubquery(where)) {
3190                // Slice 110 — `providerWithStar` already carries
3191                // `withCteContext(cteNameToStatementIndex.keySet())`
3192                // (applied at the providerWithCte → providerWithStar
3193                // chain above) so the predicate body's inner SELECT's
3194                // `FROM cte` refs route through `RelationKind.CTE`.
3195                // Without that, emitLineageForStatement would lose the
3196                // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge to the CTE
3197                // body.
3198                extractedWhereRoots =
3199                        extractUncorrelatedPredicateSubqueriesFromClause(
3200                                where.getCondition(), providerWithStar,
3201                                stmts, lineage, cteNameToStatementIndex,
3202                                PredicateClauseContext.UPDATE_WHERE);
3203                rejectAnyRemainingSubqueriesFromClause(
3204                        where.getCondition(), extractedWhereRoots,
3205                        PredicateClauseContext.UPDATE_WHERE);
3206            }
3207            rejectWindowFunctionInScopeSkipping(where, "WHERE clause",
3208                    extractedWhereRoots);
3209            // Slice 83 — providerWithStar so WHERE refs against
3210            // extracted subquery aliases bind correctly. Slice 110 —
3211            // skip extracted predicate-subquery subtrees so inner refs
3212            // do not leak into outer filterColumnRefs (mirrors the
3213            // slice-23 JOIN-ON ref collector).
3214            filterRefs = collectColumnRefsSkipping(where, providerWithStar,
3215                    extractedWhereRoots);
3216        }
3217
3218        // (Step 8 of slice 82 was relocated to step 5.7 by slice 86 so
3219        // the slice-86 UsingScope built at step 5.8 can apply to the
3220        // SET RHS / WHERE collectors above. The walker logic itself is
3221        // unchanged from slice 82's contract — only its position moved.)
3222
3223        RelationBinding targetBinding = new RelationBinding(
3224                RelationKind.TABLE, targetQName);
3225        TargetRelation target = new TargetRelation(targetBinding, targetColumnNames);
3226
3227        // Slice 85 — build RETURNING / OUTPUT projection columns BEFORE
3228        // the StatementGraph so the new returningColumns slot can be
3229        // populated. updateIdx is computed first (deterministic — the
3230        // DML's position is stmts.size() at the moment of the
3231        // upcoming stmts.add(updateStmt)). LineageEdges are emitted
3232        // here via the shared helper (consumer ← producer).
3233        int updateIdx = stmts.size();
3234        // UPDATE target alias = effective alias from the target's
3235        // TTable (slice-82 / slice-83 use the same convention for
3236        // FROM-side reference identity). FROM-side relations is the
3237        // walked `relations[]` list already built above.
3238        String updateTargetAlias = effectiveAliasOf(targetTable);
3239        if (updateTargetAlias == null || updateTargetAlias.isEmpty()) {
3240            updateTargetAlias = targetQName;
3241        }
3242        // Slice 123 — hoist the slice-105 combined map (slice-83
3243        // subqueryAliasToIndex + slice-105 CTE-as-relation alias→cteIdx)
3244        // ABOVE buildReturningColumns so the slice-85 walker can promote a
3245        // RETURNING/OUTPUT ref to a SUBQUERY-kind FROM-side relation column
3246        // to a cross-stmt STATEMENT_OUTPUT → STATEMENT_OUTPUT edge. The same
3247        // map instance is reused below for emitUpdateSubquerySourceEdges
3248        // (slice 105), so the hoist is behaviour-preserving for that call.
3249        Map<String, Integer> combinedAliasToSubIdx =
3250                buildUpdateCombinedAliasToSubIdx(update,
3251                        subqueryAliasToIndex, cteNameToStatementIndex);
3252        List<OutputColumn> returningColumns = buildReturningColumns(
3253                update.getReturningClause(),
3254                update.getOutputClause(),
3255                "UPDATE",
3256                targetQName,
3257                updateTargetAlias,
3258                /*targetTable=*/ targetTable,
3259                relations,
3260                combinedAliasToSubIdx,
3261                providerWithStar,
3262                updateIdx,
3263                lineage,
3264                update);
3265
3266        StatementGraph updateStmt = new StatementGraph(
3267                /*name=*/ null,
3268                "UPDATE",
3269                relations,
3270                outputs,
3271                returningColumns,
3272                filterRefs,
3273                /*joinColumnRefs=*/ joinRefs,
3274                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
3275                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
3276                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
3277                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
3278                /*distinct=*/ false,
3279                /*setOperator=*/ null,
3280                /*rowLimit=*/ null,
3281                target);
3282
3283        // Slice 83 — updateIdx is dynamic: stmts already contains any
3284        // extracted FROM-subquery statements from step 5.5. The
3285        // slice-78/80 contract `target.col_i ← STATEMENT_OUTPUT(idx,
3286        // out_i)` is preserved by indexing the UPDATE's own statement
3287        // position rather than the slice-80 hardcoded 0.
3288        stmts.add(updateStmt);
3289
3290        // Slice 78/80 cross-stmt edges — one per SET assignment:
3291        //   target.col_i ← STATEMENT_OUTPUT(updateIdx, out_i)
3292        for (int i = 0; i < outputs.size(); i++) {
3293            String tgtName = targetColumnNames.get(i);
3294            String outName = outputs.get(i).getName();
3295            if (tgtName == null || tgtName.isEmpty()
3296                    || outName == null || outName.isEmpty()) {
3297                // Defensive — both should be the same verbatim spelling.
3298                continue;
3299            }
3300            lineage.add(new LineageEdge(
3301                    LineageRef.tableColumn(targetQName, tgtName),
3302                    LineageRef.statementOutput(updateIdx, outName)));
3303        }
3304
3305        // Slice 115 — for each SET assignment whose RHS was extracted as
3306        // a top-level scalar subquery in step 5.9, emit the cross-stmt
3307        // wire edge:
3308        //   STATEMENT_OUTPUT(updateIdx, outName) →
3309        //       STATEMENT_OUTPUT(scalarIdx, innerOutputName)
3310        // mirrors the SELECT-side slice-11 emission in
3311        // emitLineageForStatement (line ~7440). Runs AFTER the slice-78/
3312        // 80 target edge loop so the target edge is always emitted
3313        // first; the scalar-bound assignment's OutputColumn.sources is
3314        // empty by construction so the slice-83
3315        // emitUpdateSubquerySourceEdges call below is a no-op for these
3316        // outputs.
3317        // Slice 115/119 — one edge per extracted scalar per SET assignment:
3318        //   STATEMENT_OUTPUT(updateIdx, outName) → STATEMENT_OUTPUT(scalarIdx, innerOutputName)
3319        if (!setRhsScalarInfo.isEmpty()) {
3320            for (Map.Entry<Integer, List<ScalarInfo>> e : setRhsScalarInfo.entrySet()) {
3321                int ord = e.getKey();
3322                if (ord < 0 || ord >= outputs.size()) continue;
3323                String outName = outputs.get(ord).getName();
3324                if (outName == null || outName.isEmpty()) continue;
3325                for (ScalarInfo info : e.getValue()) {
3326                    lineage.add(new LineageEdge(
3327                            LineageRef.statementOutput(updateIdx, outName),
3328                            LineageRef.statementOutput(info.statementIndex,
3329                                    info.innerOutputName)));
3330                }
3331            }
3332        }
3333
3334        // Slice 83 — emit STATEMENT_OUTPUT(updateIdx, out_i) →
3335        // STATEMENT_OUTPUT(subIdx, col) edges for output sources that
3336        // bind to a SUBQUERY-kind relation in this UPDATE's
3337        // relations[]. Base-table FROM-side sources stay as
3338        // outputs[i].sources only — preserves the slice-82 contract
3339        // that joined UPDATE without subqueries emits exactly ONE
3340        // cross-stmt edge per SET assignment (the target edge above).
3341        // Slice 105 — combine the slice-83 subqueryAliasToIndex with
3342        // the slice-105 CTE-as-relation alias→cteIdx entries so a SET
3343        // RHS reference to a CTE column (which lives on a SUBQUERY-
3344        // kind relation per slice 105) still produces a cross-stmt
3345        // STATEMENT_OUTPUT edge to the CTE body. Without the merge the
3346        // visible OutputColumn.sources stays correct but lineage[]
3347        // silently drops the canonical edge (codex round-2 Q5).
3348        // Slice 123 — combinedAliasToSubIdx is now built above (hoisted
3349        // for buildReturningColumns); reuse it here.
3350        if (!combinedAliasToSubIdx.isEmpty()) {
3351            emitUpdateSubquerySourceEdges(updateStmt, updateIdx,
3352                    combinedAliasToSubIdx, lineage);
3353        }
3354
3355        return new SemanticProgram(stmts, lineage);
3356    }
3357
3358    /**
3359     * Slice 83 — emit STATEMENT_OUTPUT → STATEMENT_OUTPUT edges from
3360     * each UPDATE output to its subquery-bound source column. Walks
3361     * {@code outputs[i].sources} and, for any source whose
3362     * {@code relationAlias} matches a SUBQUERY-kind entry in the
3363     * statement's {@link RelationSource} list, emits an edge to the
3364     * corresponding extracted subquery's STATEMENT_OUTPUT position.
3365     *
3366     * <p>Why not call {@link #emitLineageForStatement}? The SELECT-path
3367     * helper emits edges for ALL output sources (TABLE-kind →
3368     * TABLE_COLUMN; CTE/SUBQUERY-kind → STATEMENT_OUTPUT). For UPDATE
3369     * the slice-78/80 contract is intentionally narrower: the only
3370     * cross-stmt edge per SET assignment is the target edge. Adding
3371     * STATEMENT_OUTPUT → TABLE_COLUMN edges for base-table FROM-side
3372     * sources would change the cross-stmt edge count contract that
3373     * slice-82 tests assert ({@code edges.size() == numAssignments}).
3374     * The slice-83 emitter therefore is SUBQUERY-only — base-table
3375     * FROM-side refs continue to surface via {@code outputs[i].sources}
3376     * but emit no extra LineageEdge.
3377     */
3378    private static void emitUpdateSubquerySourceEdges(
3379            StatementGraph updateStmt,
3380            int updateIdx,
3381            Map<String, Integer> subqueryAliasToIndex,
3382            List<LineageEdge> lineage) {
3383        // Codex slice-83 diff-review Q1 BLOCKING — both the map and
3384        // the lookup must use the same casing policy so SQL like
3385        // `... FROM (SELECT ...) sub WHERE ... SUB.x = …` (resolver-2
3386        // may surface either case in `src.getRelationAlias()` depending
3387        // on dialect and quoting) still finds the SUBQUERY-kind entry.
3388        // The slice-83 inScope map and `subqueryAliasToIndex` are both
3389        // keyed lowercase; do the same here. (SELECT-side
3390        // `emitLineageForStatement` uses case-sensitive equality —
3391        // pre-existing limitation; a separate refactor.)
3392        Map<String, RelationSource> aliasToRelation = new HashMap<>();
3393        for (RelationSource rs : updateStmt.getRelations()) {
3394            String key = rs.getAlias();
3395            // Skip null / empty aliases — empty-string would produce a
3396            // vacuous "" key and could spuriously match other empty-alias
3397            // relations (codex round-2 Q2 advisory).
3398            if (key == null || key.isEmpty()) continue;
3399            aliasToRelation.put(key.toLowerCase(Locale.ROOT), rs);
3400        }
3401        for (OutputColumn out : updateStmt.getOutputColumns()) {
3402            String outName = out.getName();
3403            if (outName == null || outName.isEmpty()) continue;
3404            for (ColumnRef src : out.getSources()) {
3405                String srcAlias = src.getRelationAlias();
3406                if (srcAlias == null || srcAlias.isEmpty()) continue;
3407                RelationSource rel = aliasToRelation.get(
3408                        srcAlias.toLowerCase(Locale.ROOT));
3409                if (rel == null) continue;
3410                if (rel.getBinding() == null
3411                        || rel.getBinding().getKind() != RelationKind.SUBQUERY) {
3412                    continue;
3413                }
3414                Integer subIdx = subqueryAliasToIndex.get(
3415                        rel.getAlias().toLowerCase(Locale.ROOT));
3416                if (subIdx == null) continue;
3417                lineage.add(new LineageEdge(
3418                        LineageRef.statementOutput(updateIdx, outName),
3419                        LineageRef.statementOutput(subIdx, src.getColumnName())));
3420            }
3421        }
3422    }
3423
3424    /**
3425     * Slice 115 — walk the UPDATE's SET clause and extract each
3426     * assignment whose RHS is exactly a top-level
3427     * {@link EExpressionType#subquery_t} as its own
3428     * {@code <scalar_subquery_<idx>>} {@link StatementGraph} appended to
3429     * {@code stmts} BEFORE the UPDATE. Mirrors the SELECT-side
3430     * {@link #extractScalarSubqueriesAsStatementsInternal} slice-11
3431     * pipeline but iterates SET assignments instead of result columns.
3432     * Returns {@code assignmentOrdinal → ScalarInfo} so {@link #buildUpdate}
3433     * can wire the cross-stmt edge for each extracted body.
3434     *
3435     * <p>Scope rejects (mirroring slice 11):
3436     * <ul>
3437     *   <li>Multi-column inner SELECT —
3438     *       {@link DiagnosticCode#SCALAR_SUBQUERY_COLUMN_COUNT}.</li>
3439     *   <li>Inner projection has no alias and no column name —
3440     *       {@link DiagnosticCode#SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED}.</li>
3441     *   <li>Subqueries in scalar body's WHERE / JOIN ON / GROUP BY —
3442     *       slice-11 {@link #rejectSubqueriesInScalarBodyClauses}.</li>
3443     *   <li>FROM-subqueries inside scalar body —
3444     *       {@code allowFromSubqueries=false} (slice-15 invariant).</li>
3445     *   <li>Nested scalar projections inside scalar body —
3446     *       {@code allowScalarProjectionSubqueries=false} (set-op-branch
3447     *       precedent; slice 115 initial scope).</li>
3448     *   <li>Window functions in scalar body —
3449     *       {@code allowWindowProjection=false} (slice-11 precedent).</li>
3450     *   <li>Correlated scalar subqueries (inner refs to outer aliases) —
3451     *       {@link #promoteCorrelatedRefsToOuterReference} called with
3452     *       {@link EnclosingScope#empty()} throws
3453     *       {@link DiagnosticCode#SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS}.
3454     *       Lifting UPDATE-side correlation is a follow-up slice
3455     *       (slice 14 SELECT analogue extended to UPDATE).</li>
3456     * </ul>
3457     *
3458     * <p>Snapshot/rollback wrapper around the loop body mirrors
3459     * {@link #extractScalarSubqueriesAsStatements} so a partial
3460     * extraction (e.g. second of two scalar SET RHS fails on shape
3461     * validation) truncates {@code stmts}/{@code lineage} back to the
3462     * pre-call boundary.
3463     *
3464     * <p>Assignments whose RHS is not a top-level {@code subquery_t} are
3465     * silently skipped here; they fall through to the per-assignment
3466     * loop's existing slice-80 / slice-115 mixed-expression reject path.
3467     */
3468    private static Map<Integer, List<ScalarInfo>> extractScalarSubqueriesFromUpdateSetRhs(
3469            TUpdateSqlStatement update,
3470            NameBindingProvider provider,
3471            List<StatementGraph> stmts,
3472            List<LineageEdge> lineage,
3473            Map<String, Integer> cteNameToStatementIndex,
3474            Map<String, Integer> subqueryAliasToIndex) {
3475        TResultColumnList sets = update.getResultColumnList();
3476        if (sets == null || sets.size() == 0) {
3477            return Collections.<Integer, List<ScalarInfo>>emptyMap();
3478        }
3479        // Fast pre-scan: any SET RHS that contains a subquery (top-level
3480        // subquery_t or nested inside a compound expression)? Avoids the
3481        // snapshot/rollback wrapper overhead when none are present.
3482        // Slice 115 handled top-level subquery_t only; slice 119 extends
3483        // to mixed-expression RHS (e.g. `SET col = (SELECT...) + 1`).
3484        // Tuple-LHS assignments (Oracle SET (a, b) = ...) are
3485        // intentionally skipped so the per-assignment loop's
3486        // UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED reject (slice 80
3487        // contract) wins.
3488        boolean anySubquery = false;
3489        for (int i = 0; i < sets.size(); i++) {
3490            TResultColumn rc = sets.getResultColumn(i);
3491            if (rc == null || rc.getExpr() == null) continue;
3492            TExpression assignment = rc.getExpr();
3493            if (assignment.getExpressionType() != EExpressionType.assignment_t) {
3494                continue;
3495            }
3496            TExpression lhs = assignment.getLeftOperand();
3497            if (lhs == null
3498                    || lhs.getExpressionType() == EExpressionType.list_t) {
3499                continue;
3500            }
3501            TExpression rhs = assignment.getRightOperand();
3502            if (rhs != null && containsAnySubqueryExpression(rhs)) {
3503                anySubquery = true;
3504                break;
3505            }
3506        }
3507        if (!anySubquery) {
3508            return Collections.<Integer, List<ScalarInfo>>emptyMap();
3509        }
3510        int stmtsSnapshot = stmts.size();
3511        int lineageSnapshot = lineage.size();
3512        try {
3513            return extractScalarSubqueriesFromUpdateSetRhsInternal(
3514                    update, provider, stmts, lineage,
3515                    cteNameToStatementIndex, subqueryAliasToIndex, sets);
3516        } catch (RuntimeException ex) {
3517            while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1);
3518            while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1);
3519            throw ex;
3520        }
3521    }
3522
3523    /**
3524     * Internal body of {@link #extractScalarSubqueriesFromUpdateSetRhs};
3525     * wrapped with snapshot/rollback by the public entry point. Do not
3526     * call directly from non-wrapper sites.
3527     */
3528    private static Map<Integer, List<ScalarInfo>> extractScalarSubqueriesFromUpdateSetRhsInternal(
3529            TUpdateSqlStatement update,
3530            NameBindingProvider provider,
3531            List<StatementGraph> stmts,
3532            List<LineageEdge> lineage,
3533            Map<String, Integer> cteNameToStatementIndex,
3534            Map<String, Integer> subqueryAliasToIndex,
3535            TResultColumnList sets) {
3536        Map<Integer, List<ScalarInfo>> ordinalToInfo = new HashMap<>();
3537        for (int i = 0; i < sets.size(); i++) {
3538            TResultColumn rc = sets.getResultColumn(i);
3539            if (rc == null || rc.getExpr() == null) continue;
3540            TExpression assignment = rc.getExpr();
3541            if (assignment.getExpressionType() != EExpressionType.assignment_t) {
3542                continue;
3543            }
3544            TExpression lhs = assignment.getLeftOperand();
3545            // Skip tuple-LHS assignments — the per-assignment loop's
3546            // slice-80 UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED reject
3547            // should win for these (e.g. Oracle `SET (a, b) = (SELECT
3548            // c1, c2 FROM ...)`). Without this skip, the inner SELECT's
3549            // multi-column projection would surface as
3550            // SCALAR_SUBQUERY_COLUMN_COUNT here instead.
3551            if (lhs == null
3552                    || lhs.getExpressionType() == EExpressionType.list_t) {
3553                continue;
3554            }
3555            TExpression rhs = assignment.getRightOperand();
3556            if (rhs == null || !containsAnySubqueryExpression(rhs)) {
3557                continue; // no subquery in this RHS — handled by per-assignment loop
3558            }
3559            // "outer alias" used in diagnostic messages — the SET LHS
3560            // column spelling. Mirrors the slice-11 `outerAlias` role.
3561            String outerAlias = (lhs.getExpressionType() == EExpressionType.simple_object_name_t
3562                    && lhs.getObjectOperand() != null)
3563                    ? lhs.getObjectOperand().toString()
3564                    : ("SET assignment #" + (i + 1));
3565
3566            // Determine which subquery TExpression nodes to extract.
3567            // Slice 115 path: RHS is exactly a top-level subquery_t →
3568            //   single-element list.
3569            // Slice 119 path: RHS is a compound expression (arithmetic,
3570            //   CASE, function) containing one or more subquery_t nodes
3571            //   at any depth → list in traversal order.
3572            List<TExpression> subqExprs;
3573            if (rhs.getExpressionType() == EExpressionType.subquery_t) {
3574                subqExprs = Collections.singletonList(rhs);
3575            } else {
3576                subqExprs = collectNestedSubqueryExpressions(rhs);
3577            }
3578            if (subqExprs.isEmpty()) continue; // defensive (containsAnySubqueryExpression true but none found)
3579
3580            // Build the UPDATE-side enclosing scope once per assignment
3581            // (used by each per-scalar correlation promotion below).
3582            EnclosingScope innerEnclosing = buildUpdateEnclosingScope(update,
3583                    cteNameToStatementIndex, subqueryAliasToIndex,
3584                    /*parent=*/ null);
3585
3586            List<ScalarInfo> infos = new ArrayList<>();
3587            for (TExpression subqExpr : subqExprs) {
3588                TSelectSqlStatement inner = subqExpr.getSubQuery();
3589                if (inner == null) {
3590                    throw new SemanticIRBuildException(
3591                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_NO_INNER_SELECT,
3592                            "scalar subquery on UPDATE SET RHS for '" + outerAlias
3593                                    + "' has no inner SELECT", rc));
3594                }
3595                // Pre-recursion validation (matches slice 11 ordering):
3596                // inspect inner column count and naming before recursive
3597                // build so the diagnostic is scalar-specific.
3598                TResultColumnList innerRcl = inner.getResultColumnList();
3599                if (innerRcl == null || innerRcl.size() == 0) {
3600                    throw new SemanticIRBuildException(
3601                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT,
3602                            "scalar subquery on UPDATE SET RHS for '" + outerAlias
3603                                    + "' must project exactly one column, got 0", rc));
3604                }
3605                if (innerRcl.size() != 1) {
3606                    throw new SemanticIRBuildException(
3607                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT,
3608                            "scalar subquery on UPDATE SET RHS for '" + outerAlias
3609                                    + "' must project exactly one column, got "
3610                                    + innerRcl.size(), rc));
3611                }
3612                TResultColumn innerCol = innerRcl.getResultColumn(0);
3613                String innerAlias = innerCol.getColumnAlias();
3614                String innerColName = innerCol.getColumnNameOnly();
3615                boolean innerHasName =
3616                        (innerAlias != null && !innerAlias.isEmpty())
3617                                || (innerColName != null && !innerColName.isEmpty());
3618                if (!innerHasName && !isConstantExpression(innerCol.getExpr())) {
3619                    throw new SemanticIRBuildException(
3620                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED,
3621                            "scalar subquery on UPDATE SET RHS for '" + outerAlias
3622                                    + "' inner projection has no alias and no column "
3623                                    + "name; add an explicit alias inside the subquery",
3624                            rc));
3625                }
3626                // Predicate-leak guard: scalar body's WHERE / JOIN ON /
3627                // GROUP BY must not contain subqueries. Slice 121: the
3628                // UPDATE SET-RHS scalar path keeps the WHERE reject
3629                // (allowWherePredicateSubqueries=false) — only the
3630                // projection scalar path lifts WHERE predicate subqueries.
3631                rejectSubqueriesInScalarBodyClauses(inner, outerAlias,
3632                        /*allowWherePredicateSubqueries=*/ false);
3633
3634                // Slice 117 / 119 — decorate provider with the inner
3635                // SELECT's local FROM aliases for tolerant outer-binding.
3636                Set<String> innerLocalAliases = precomputeInnerLocalAliases(inner);
3637                NameBindingProvider tolerantProvider = innerLocalAliases.isEmpty()
3638                        ? provider
3639                        : provider.withTolerantOuterBinding(innerLocalAliases);
3640
3641                String scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">";
3642                StatementGraph innerStmt = buildSelectStatement(inner, tolerantProvider,
3643                        scalarName,
3644                        /*hasOuterCteListAlreadyProcessed=*/ false,
3645                        /*allowFromSubqueries=*/ false,
3646                        /*allowScalarProjectionSubqueries=*/ false,
3647                        /*allowWindowProjection=*/ false);
3648                innerStmt = promoteCorrelatedRefsToOuterReference(
3649                        innerStmt, outerAlias, innerEnclosing);
3650                int idx = stmts.size();
3651                stmts.add(innerStmt);
3652                String innerOutName = effectiveOutputName(innerCol);
3653                infos.add(new ScalarInfo(idx, innerOutName));
3654                emitLineageForStatement(innerStmt, idx, lineage,
3655                        cteNameToStatementIndex,
3656                        innerEnclosing.flattenSubqueryAliasToIndex(),
3657                        Collections.<Integer, ScalarInfo>emptyMap());
3658            }
3659            ordinalToInfo.put(i, infos);
3660        }
3661        return ordinalToInfo;
3662    }
3663
3664    /**
3665     * Slice 82 — process one FROM-side source table for joined
3666     * {@link #buildUpdate}. Applies the slice-82 reject contract for
3667     * non-table FROM sources, then appends a TABLE-kind
3668     * {@link RelationSource} unless the table is the target
3669     * (reference-identity filter — clean IR semantics: relations[]
3670     * models read-side sources only).
3671     */
3672    private static void buildUpdateRelation(TTable t, TTable targetTable,
3673                                            List<RelationSource> relations,
3674                                            TUpdateSqlStatement update,
3675                                            Map<String, Integer> cteNameToStatementIndex) {
3676        if (t == null) {
3677            return; // defensive — parser should never produce a null table
3678        }
3679        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
3680            // Slice 83 — admit FROM-side subqueries. The inner SELECT
3681            // has already been extracted as its own StatementGraph by
3682            // {@link #extractUpdateFromSubqueries} (step 5.5 of
3683            // buildUpdate). Here we publish the SUBQUERY-kind
3684            // {@link RelationSource} so {@code outputs[i].sources}
3685            // resolved via the inScope-enhanced provider can route to
3686            // it. Alias and qualifiedName both use
3687            // {@code effectiveAliasOf(t)} — matching slice-14 / slice-58
3688            // SUBQUERY-kind convention used by SELECT.
3689            //
3690            // {@code UPDATE_FROM_SUBQUERY_NOT_SUPPORTED} stays declared
3691            // but unreached (slice-71/72 retain-for-documentation
3692            // precedent — keeps the public DiagnosticCode enum stable
3693            // for consumers that route by code).
3694            String subAlias = effectiveAliasOf(t);
3695            if (subAlias != null && !subAlias.isEmpty()) {
3696                relations.add(new RelationSource(subAlias,
3697                        new RelationBinding(RelationKind.SUBQUERY, subAlias)));
3698            }
3699            return;
3700        }
3701        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
3702            // Defensive: TTable wrapping a TJoin. Not reached by any
3703            // observed parser path on the supported dialects (slice-82
3704            // probe set), but gets its own DiagnosticCode so consumers
3705            // can route this distinct shape without parsing message
3706            // text — per slice-80's message-text-discrimination
3707            // contract (codex round-1 Q4 BLOCKING).
3708            throw new SemanticIRBuildException(Diagnostic.error(
3709                    DiagnosticCode.UPDATE_FROM_NESTED_JOIN_NOT_SUPPORTED,
3710                    "UPDATE FROM source is a nested join wrapper; "
3711                            + "slice 82 admits simple table FROM sources only",
3712                    update));
3713        }
3714        // Reference-identity filter: target's own TTable instance is
3715        // excluded from relations[]. In MSSQL `UPDATE T2 ... FROM Table2 T2 …`,
3716        // tables[0] / joins[0].getTable() IS the same instance as
3717        // update.getTargetTable(); excluding it keeps the IR clean
3718        // (relations[] models reads, target models writes). The
3719        // catalog-miss WARN walker's target-first ordering handles the
3720        // cross-instance-same-name MSSQL self-join edge case where two
3721        // distinct TTable instances share the same qualified name.
3722        if (t == targetTable) {
3723            return;
3724        }
3725        TObjectName tName = t.getTableName();
3726        if (tName == null) {
3727            return; // defensive
3728        }
3729        // Slice 105 — FROM-side CTE detection. When the FROM-side table
3730        // is an objectname-typed reference whose bare name matches a
3731        // declared CTE in this UPDATE's outer WITH clause, emit a
3732        // SUBQUERY-kind RelationSource pointing at the CTE statement
3733        // (mirrors MERGE USING-as-CTE in slice 101). The slice-77
3734        // catalog-miss WARN walker filters to RelationKind.TABLE so
3735        // CTE-bound relations are naturally skipped, even when the
3736        // catalog also declares the same name (codex round-2 Q4
3737        // confirmed YES). The cross-stmt lineage edge from
3738        // STATEMENT_OUTPUT(updateIdx,col) → STATEMENT_OUTPUT(cteIdx,col)
3739        // is emitted by emitUpdateSubquerySourceEdges using the
3740        // combined alias→subIdx map.
3741        if (cteNameToStatementIndex != null
3742                && !cteNameToStatementIndex.isEmpty()) {
3743            String bareName = tName.toString();
3744            if (bareName != null && !bareName.isEmpty()) {
3745                String bareNameLower = bareName.toLowerCase(Locale.ROOT);
3746                if (cteNameToStatementIndex.containsKey(bareNameLower)) {
3747                    String cteAlias = effectiveAliasOf(t);
3748                    if (cteAlias == null || cteAlias.isEmpty()) {
3749                        cteAlias = bareName;
3750                    }
3751                    relations.add(new RelationSource(cteAlias,
3752                            new RelationBinding(RelationKind.SUBQUERY, cteAlias)));
3753                    return;
3754                }
3755            }
3756        }
3757        // effectiveAliasOf returns the SQL-written alias if present,
3758        // else the table name. RelationSource requires a non-empty
3759        // alias; this matches the slice-58/59 buildRelation contract.
3760        relations.add(new RelationSource(effectiveAliasOf(t),
3761                new RelationBinding(RelationKind.TABLE, tName.toString())));
3762    }
3763
3764    /**
3765     * Slice 82 (extended by slice 86) — process one {@link TJoinItem}
3766     * for joined {@link #buildUpdate}. Routes USING / NATURAL JoinItems
3767     * through the SELECT-side slice-64/65/66 shared helpers
3768     * ({@link #populateUsingJoinRefs} / {@link #emitMergedJoinRefs} /
3769     * {@link #naturalSharedKeys}) so the UPDATE join walker emits the
3770     * same {@code joinColumnRefs[]} shape as a SELECT body. ON / CROSS
3771     * JoinItems retain the slice-82 reject contract (subquery in ON,
3772     * window in ON) and ref-collection path.
3773     *
3774     * <p>Slice 86 signature extension: the join context
3775     * ({@code topJoin}, {@code items}, {@code itemIndex}) and the
3776     * per-top-level-TJoin {@link LeftOutputState} are required by the
3777     * shared helpers — the prior-relations chain for emit-refs and the
3778     * accumulated left row type for NATURAL inference.
3779     *
3780     * <p>USING / NATURAL shape conflicts (USING+ON, NATURAL+USING,
3781     * NATURAL+ON) reuse the existing slice-64/66 codes
3782     * ({@link DiagnosticCode#JOIN_WITH_BOTH_ON_AND_USING},
3783     * {@link DiagnosticCode#NATURAL_WITH_USING},
3784     * {@link DiagnosticCode#NATURAL_WITH_ON}) rather than introducing
3785     * UPDATE-specific codes — matching slice 86's "reuse SELECT-side
3786     * machinery verbatim" architecture.
3787     *
3788     * <p>Slice 82's lifted reject codes
3789     * ({@link DiagnosticCode#UPDATE_FROM_JOIN_USING_NOT_SUPPORTED} and
3790     * {@link DiagnosticCode#UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED})
3791     * stay declared-but-unreached for API stability — slice 71/72/82
3792     * retain-for-documentation precedent.
3793     */
3794    private static void buildUpdateJoinItem(TJoin topJoin,
3795                                            TJoinItemList items,
3796                                            int itemIndex,
3797                                            TTable targetTable,
3798                                            NameBindingProvider provider,
3799                                            List<RelationSource> relations,
3800                                            java.util.LinkedHashSet<ColumnRef> joinRefs,
3801                                            LeftOutputState leftState,
3802                                            TUpdateSqlStatement update,
3803                                            Map<String, Integer> cteNameToStatementIndex) {
3804        if (items == null) return;
3805        TJoinItem item = items.getJoinItem(itemIndex);
3806        if (item == null) return;
3807
3808        TObjectNameList usingCols = item.getUsingColumns();
3809        boolean hasUsing = usingCols != null && usingCols.size() > 0;
3810        boolean isNatural = isNaturalJoinType(item.getJoinType());
3811        boolean hasOn = item.getOnCondition() != null;
3812
3813        // Slice 86 — USING/NATURAL admit paths. Shape conflicts use the
3814        // slice-64/66 SELECT-side codes verbatim; the UPDATE-specific
3815        // lifted codes (UPDATE_FROM_JOIN_USING_NOT_SUPPORTED /
3816        // UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED) are no longer thrown
3817        // (declared-but-unreached for API stability).
3818        if (isNatural && hasUsing) {
3819            throw new SemanticIRBuildException(Diagnostic.error(
3820                    DiagnosticCode.NATURAL_WITH_USING,
3821                    "NATURAL JOIN must not carry a USING clause; choose "
3822                            + "either NATURAL or USING, not both", item));
3823        }
3824        if (isNatural && hasOn) {
3825            throw new SemanticIRBuildException(Diagnostic.error(
3826                    DiagnosticCode.NATURAL_WITH_ON,
3827                    "NATURAL JOIN must not carry an ON condition; rewrite "
3828                            + "as JOIN ... ON, or drop the NATURAL keyword", item));
3829        }
3830        if (hasUsing && hasOn) {
3831            throw new SemanticIRBuildException(Diagnostic.error(
3832                    DiagnosticCode.JOIN_WITH_BOTH_ON_AND_USING,
3833                    "JOIN cannot carry both ON and USING; choose one", item));
3834        }
3835
3836        if (hasUsing) {
3837            // Right-side table first: applies slice-82 source-shape
3838            // rejects + identity filter exactly as the ON path.
3839            buildUpdateRelation(item.getTable(), targetTable, relations, update,
3840                    cteNameToStatementIndex);
3841            // Slice 64 emit-refs: left-then-right per key, walking
3842            // priorRelations = topJoin.getTable() + items[0..itemIndex-1].
3843            List<ColumnRef> usingRefs = new ArrayList<>();
3844            populateUsingJoinRefs(topJoin, items, itemIndex, item.getTable(),
3845                    usingCols, provider, usingRefs);
3846            joinRefs.addAll(usingRefs);
3847            // Slice 66 LeftOutputState update: merge right's columns
3848            // into accumulated state so a subsequent NATURAL JoinItem
3849            // sees the row type (matches SELECT-side
3850            // {@code buildRelations}).
3851            List<String> usingKeyNames = new ArrayList<>(usingCols.size());
3852            for (int k = 0; k < usingCols.size(); k++) {
3853                TObjectName key = usingCols.getObjectName(k);
3854                if (key == null) continue;
3855                String keyName = key.getColumnNameOnly();
3856                if (keyName != null && !keyName.isEmpty()) {
3857                    usingKeyNames.add(keyName);
3858                }
3859            }
3860            mergeRightIntoLeftOutput(leftState, item.getTable(), provider,
3861                    usingKeyNames);
3862            return;
3863        }
3864
3865        if (isNatural) {
3866            // Right-side table first; identity filter excludes target.
3867            buildUpdateRelation(item.getTable(), targetTable, relations, update,
3868                    cteNameToStatementIndex);
3869            // Slice 66 catalog-required NATURAL inference. When either
3870            // side lacks resolvable column metadata the shared-column list
3871            // cannot be computed; DEGRADE (mirrors the SELECT-side
3872            // buildRelations path) — skip the merged join refs, record a
3873            // non-fatal NATURAL_CATALOG_REQUIRED warning, and append the
3874            // right's columns to the running state for consistency.
3875            NaturalKeyResult r = naturalSharedKeys(leftState, item.getTable(), provider);
3876            if (r.kind != NaturalKeyResult.Kind.SUCCESS) {
3877                recordNaturalDegradeWarning(r, item);
3878                appendRightToLeftOutput(leftState, item.getTable(), provider);
3879                return;
3880            }
3881            List<ColumnRef> naturalRefs = new ArrayList<>();
3882            emitMergedJoinRefs(JoinKind.NATURAL, r.keys, topJoin, items,
3883                    itemIndex, item.getTable(), provider, naturalRefs);
3884            joinRefs.addAll(naturalRefs);
3885            // Update LeftOutputState with the right's columns (merging
3886            // shared keys into existing slots, appending non-shared
3887            // columns as new entries).
3888            mergeRightIntoLeftOutput(leftState, item.getTable(), provider, r.keys);
3889            return;
3890        }
3891
3892        // ON / CROSS branch — slice-82 contract preserved.
3893        buildUpdateRelation(item.getTable(), targetTable, relations, update,
3894                    cteNameToStatementIndex);
3895        // Slice 86 — append right to LeftOutputState so subsequent
3896        // NATURAL JoinItems in the same top-level TJoin observe the
3897        // accumulated row type. CROSS / ON contribute non-merged
3898        // columns to state (matches SELECT-side appendRightToLeftOutput).
3899        appendRightToLeftOutput(leftState, item.getTable(), provider);
3900        TExpression onCond = item.getOnCondition();
3901        if (onCond == null) return; // CROSS JOIN: no ON.
3902        if (containsAnySubqueryExpression(onCond)) {
3903            throw new SemanticIRBuildException(Diagnostic.error(
3904                    DiagnosticCode.UPDATE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED,
3905                    "UPDATE FROM JOIN ON condition contains a subquery; "
3906                            + "slice 82 admits scalar predicates only",
3907                    item));
3908        }
3909        rejectWindowFunctionInScope(onCond, "UPDATE FROM JOIN ON");
3910        joinRefs.addAll(collectColumnRefs(onCond, provider));
3911    }
3912
3913    /**
3914     * Slice 83 — extract every FROM-side subquery in
3915     * {@code update.getJoins()} as its own {@link StatementGraph}
3916     * appended to {@code stmts} before the UPDATE itself. Walks both
3917     * the driver TTable of each TJoin AND each JoinItem's right table.
3918     * Returns an alias → stmts-index map so the consuming UPDATE can
3919     * (a) build its in-scope column map via
3920     * {@link #buildUpdateInScopeMap}, and (b) emit
3921     * STATEMENT_OUTPUT → STATEMENT_OUTPUT edges via
3922     * {@link #emitUpdateSubquerySourceEdges}.
3923     *
3924     * <p>Reuses the SELECT-side {@link #processDirectSubqueryTable}
3925     * verbatim, passing empty CTE maps because slice 80 already
3926     * rejects top-level WITH on UPDATE
3927     * ({@link DiagnosticCode#UPDATE_CTE_NOT_SUPPORTED}). The inner
3928     * SELECT's own FROM-subqueries are handled recursively by the
3929     * helper. Inner predicate subqueries in WHERE / JOIN ON /
3930     * GROUP BY are caught by the slice-17 leak guard
3931     * ({@link #rejectSubqueriesInFromSubqueryBodyClauses}). Inner
3932     * top-level WITH is rejected by
3933     * {@code buildSelectStatement(hasOuterCteListAlreadyProcessed=false)}.
3934     * Inner scalar projection subqueries are rejected by
3935     * {@code buildSelectStatement(allowScalarProjectionSubqueries=false)}.
3936     *
3937     * <p>No mutation-guard wrapper here: buildUpdate owns fresh local
3938     * lists and exceptions propagate to the caller (codex round-1 Q5
3939     * NICE).
3940     */
3941    private static Map<String, Integer> extractUpdateFromSubqueries(
3942            TUpdateSqlStatement update,
3943            NameBindingProvider provider,
3944            List<StatementGraph> stmts,
3945            List<LineageEdge> lineage,
3946            Map<String, Integer> cteNameToStatementIndex,
3947            Map<String, List<String>> ctePublishedColumns) {
3948        Map<String, Integer> aliasToIndex = new HashMap<>();
3949        TJoinList joins = update.getJoins();
3950        if (joins == null) return aliasToIndex;
3951        // Slice 105 — forward the outer-WITH CTE maps so a nested SELECT
3952        // inside an extracted FROM-subquery body can resolve outer-WITH
3953        // CTE references. Resolver2 wires CTEScope already; the maps are
3954        // forwarded for parity with the SELECT / MERGE call sites.
3955        Map<String, Integer> cteMap = cteNameToStatementIndex == null
3956                ? Collections.<String, Integer>emptyMap()
3957                : cteNameToStatementIndex;
3958        Map<String, List<String>> ctePublished = ctePublishedColumns == null
3959                ? Collections.<String, List<String>>emptyMap()
3960                : ctePublishedColumns;
3961        for (TJoin join : joins) {
3962            // Driver table — may be a subquery (PG / Snowflake / BQ /
3963            // Redshift `UPDATE t SET … FROM (SELECT …) sub` shape).
3964            processDirectSubqueryTable(join.getTable(), provider,
3965                    stmts, lineage, cteMap, ctePublished, aliasToIndex);
3966            TJoinItemList items = join.getJoinItems();
3967            if (items == null) continue;
3968            for (int i = 0; i < items.size(); i++) {
3969                TJoinItem item = items.getJoinItem(i);
3970                if (item == null) continue;
3971                // Right-side table of a JoinItem — may be a subquery
3972                // (MSSQL / PG `UPDATE t SET … FROM x JOIN (SELECT …)
3973                // sub ON …` shape).
3974                processDirectSubqueryTable(item.getTable(), provider,
3975                        stmts, lineage, cteMap, ctePublished, aliasToIndex);
3976            }
3977        }
3978        return aliasToIndex;
3979    }
3980
3981    /**
3982     * Slice 83 — build an effective-alias-keyed in-scope map publishing
3983     * each extracted FROM-subquery's output column names. The consuming
3984     * UPDATE wraps its provider via
3985     * {@code provider.withInScopeRelationColumns(map)} so {@code sub.x}
3986     * resolves to the subquery's published column rather than failing
3987     * resolution against the catalog.
3988     *
3989     * <p>Base-table FROM-side relations do not need an entry: their
3990     * column resolution stays on the Resolver2 catalog path. Slice 60's
3991     * SELECT-side {@link #buildEffectiveAliasInScopeMap} also skips
3992     * base-table relations.
3993     *
3994     * <p>Slice 105 — when an outer WITH clause declares a CTE and a
3995     * FROM-side relation references that CTE by its bare name, publish
3996     * the CTE's column names against the FROM-side effective alias so
3997     * SET RHS / WHERE / ON refs against the CTE alias bind correctly.
3998     */
3999    private static Map<String, List<String>> buildUpdateInScopeMap(
4000            TUpdateSqlStatement update,
4001            Map<String, Integer> subqueryAliasToIndex,
4002            List<StatementGraph> stmts,
4003            Map<String, Integer> cteNameToStatementIndex,
4004            Map<String, List<String>> ctePublishedColumns) {
4005        Map<String, List<String>> result = new HashMap<>();
4006        boolean haveSubq = subqueryAliasToIndex != null
4007                && !subqueryAliasToIndex.isEmpty();
4008        boolean haveCte = cteNameToStatementIndex != null
4009                && !cteNameToStatementIndex.isEmpty();
4010        if (!haveSubq && !haveCte) {
4011            return result;
4012        }
4013        TJoinList joins = update.getJoins();
4014        if (joins == null) return result;
4015        for (TJoin join : joins) {
4016            addUpdateRelationToInScopeMap(join.getTable(),
4017                    subqueryAliasToIndex, stmts, result,
4018                    cteNameToStatementIndex, ctePublishedColumns);
4019            TJoinItemList items = join.getJoinItems();
4020            if (items == null) continue;
4021            for (int i = 0; i < items.size(); i++) {
4022                TJoinItem item = items.getJoinItem(i);
4023                if (item == null) continue;
4024                addUpdateRelationToInScopeMap(item.getTable(),
4025                        subqueryAliasToIndex, stmts, result,
4026                        cteNameToStatementIndex, ctePublishedColumns);
4027            }
4028        }
4029        return result;
4030    }
4031
4032    private static void addUpdateRelationToInScopeMap(TTable t,
4033            Map<String, Integer> subqueryAliasToIndex,
4034            List<StatementGraph> stmts,
4035            Map<String, List<String>> result,
4036            Map<String, Integer> cteNameToStatementIndex,
4037            Map<String, List<String>> ctePublishedColumns) {
4038        if (t == null) return;
4039        // Slice 105 — CTE-as-FROM-relation in-scope publication. When
4040        // the FROM-side table is an objectname-typed reference whose
4041        // bare name matches a declared outer CTE, publish the CTE's
4042        // own column names against the FROM-side effective alias so
4043        // SET RHS / WHERE refs against the CTE alias bind correctly.
4044        if (cteNameToStatementIndex != null
4045                && !cteNameToStatementIndex.isEmpty()
4046                && ctePublishedColumns != null
4047                && t.getTableType()
4048                        == gudusoft.gsqlparser.ETableSource.objectname) {
4049            TObjectName tName = t.getTableName();
4050            if (tName != null) {
4051                String bare = tName.toString();
4052                if (bare != null && !bare.isEmpty()) {
4053                    String bareLower = bare.toLowerCase(Locale.ROOT);
4054                    if (cteNameToStatementIndex.containsKey(bareLower)) {
4055                        String aliasKey = effectiveAliasLowerCaseOrNull(t);
4056                        if (aliasKey == null) aliasKey = bareLower;
4057                        List<String> cols = ctePublishedColumns.get(bareLower);
4058                        if (cols != null) {
4059                            result.put(aliasKey, cols);
4060                        }
4061                        return;
4062                    }
4063                }
4064            }
4065        }
4066        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) {
4067            return;
4068        }
4069        if (subqueryAliasToIndex == null) {
4070            return;
4071        }
4072        String key = effectiveAliasLowerCaseOrNull(t);
4073        if (key == null) return;
4074        Integer idx = subqueryAliasToIndex.get(key);
4075        if (idx == null) return;
4076        result.put(key, outputColumnNames(stmts.get(idx)));
4077    }
4078
4079    /**
4080     * Slice 81 / slice 84 — admit single-target and joined
4081     * {@code DELETE} statements and produce a {@code "DELETE"}-kind
4082     * {@link StatementGraph} (§8.1.4 row D11 follow-up via slice 84's
4083     * joined-DELETE candidate (a)).
4084     *
4085     * <p>Structurally mirrors slice-80 + slice-82 + slice-83
4086     * {@link #buildUpdate} but with no SET clause and an empty
4087     * {@code outputColumns} list — DELETE has no projection of its
4088     * own (RETURNING / OUTPUT projections are deferred to a later
4089     * slice). The target relation is exposed via the slice-78
4090     * {@link TargetRelation} slot; its {@code columns} list is
4091     * intentionally empty because DELETE removes whole rows rather
4092     * than writing specific columns.
4093     *
4094     * <p>WHERE-side reads still surface on
4095     * {@link StatementGraph#getFilterColumnRefs()} so downstream
4096     * governance can audit "what predicates does this DELETE depend
4097     * on". Cross-statement {@link LineageEdge}s are NOT emitted (the
4098     * slice-78 / slice-80 {@code target.col_i ← STATEMENT_OUTPUT(…)}
4099     * contract has no DELETE analogue: there is no source
4100     * projection).
4101     *
4102     * <p>Slice 84 admit scope (lifts slice-81's blanket joined-DELETE
4103     * reject for the common PG / MSSQL FROM-side shapes; mirrors
4104     * slice 82 + slice 83 onto DELETE):
4105     * <ul>
4106     *   <li>PG / Snowflake / BQ / Redshift {@code DELETE FROM t USING
4107     *       source_list [WHERE]} — {@code source_list} = simple table,
4108     *       comma-separated tables, or chain of explicit JOIN ... ON
4109     *       (driver is taken from {@code referenceJoins}).</li>
4110     *   <li>MSSQL {@code DELETE FROM t FROM driver_table [JOIN other
4111     *       ON ...] [WHERE]} — the target may itself appear in the
4112     *       FROM-FROM clause as a different TTable instance.</li>
4113     *   <li>MSSQL {@code DELETE alias FROM t alias INNER JOIN ... ON …}
4114     *       — the alias-form DELETE where target is matched by alias.</li>
4115     *   <li>CROSS JOIN inside USING — no ON; semantically equivalent
4116     *       to comma-FROM.</li>
4117     *   <li>{@code DELETE FROM t USING (SELECT …) s [WHERE]} —
4118     *       FROM-subquery as a USING source; mirrors slice-83 UPDATE
4119     *       FROM-subquery extraction.</li>
4120     * </ul>
4121     *
4122     * <p>Slice 84 reject scope (preserves slice-81 reject coverage
4123     * for shapes that still need a refinement slice):
4124     * <ul>
4125     *   <li>{@link DiagnosticCode#DELETE_JOINED_NOT_SUPPORTED} — any
4126     *       shape with {@code delete.getJoins().size() &gt; 0}: MySQL
4127     *       multi-target {@code DELETE T1, T2 FROM …}, MySQL
4128     *       self-reference {@code DELETE T1 FROM T1}, MySQL
4129     *       multi-USING {@code DELETE FROM T1 USING T1, T2}.
4130     *       Candidates (c) and (d) in §8.1.4 lift these later.</li>
4131     *   <li>{@link DiagnosticCode#DELETE_FROM_JOIN_USING_NOT_SUPPORTED}
4132     *       — {@code USING(col1, col2)} on a FROM-side join item;
4133     *       mirror of slice-82 {@code UPDATE_FROM_JOIN_USING_*}.</li>
4134     *   <li>{@link DiagnosticCode#DELETE_FROM_JOIN_NATURAL_NOT_SUPPORTED}
4135     *       — {@code NATURAL JOIN} on a FROM-side join item.</li>
4136     *   <li>{@link DiagnosticCode#DELETE_FROM_NESTED_JOIN_NOT_SUPPORTED}
4137     *       — defensive: TTable wrapping a TJoin in the FROM source
4138     *       (not reached by any observed parser path on supported
4139     *       dialects, but kept distinct from the subquery code per
4140     *       slice-80 message-text-discrimination contract).</li>
4141     *   <li>{@link DiagnosticCode#DELETE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED}
4142     *       — subquery in a JOIN ON predicate.</li>
4143     * </ul>
4144     *
4145     * <p>Other rejected shapes (slice-81 baseline preserved):
4146     * {@link DiagnosticCode#DELETE_CTE_NOT_SUPPORTED},
4147     * {@link DiagnosticCode#DELETE_TARGET_MISSING},
4148     * {@link DiagnosticCode#DELETE_RETURNING_CLAUSE_NOT_SUPPORTED},
4149     * {@link DiagnosticCode#DELETE_OUTPUT_CLAUSE_NOT_SUPPORTED},
4150     * {@link DiagnosticCode#DELETE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED}.
4151     *
4152     * <p>WHERE-side subqueries reuse the existing
4153     * {@link DiagnosticCode#WHERE_HAS_SUBQUERY_NOT_SUPPORTED} (no
4154     * new DELETE-side code) — consistent with slice-80 UPDATE WHERE
4155     * handling. Window functions in WHERE / ON reuse
4156     * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK}.
4157     *
4158     * <p>IR shape (slice 84 changes from slice 81):
4159     * <ul>
4160     *   <li>{@code relations[]} — now carries TABLE-kind
4161     *       {@link RelationSource}s for joined-DELETE FROM-side
4162     *       sources, plus SUBQUERY-kind sources for {@code USING
4163     *       (SELECT …)} extractions. Slice 81 left it empty.
4164     *       Reference-identity filter excludes the target's own
4165     *       TTable instance; the slice-82 walker-order swap (target
4166     *       before relations[] in
4167     *       {@link gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer#collectCatalogMissWarnings})
4168     *       handles same-qualified-name target+driver collisions
4169     *       (e.g. MSSQL {@code DELETE FROM t FROM t spqh JOIN sp}).</li>
4170     *   <li>{@code joinColumnRefs[]} — now carries ON-clause refs
4171     *       collected from each JoinItem under a per-DELETE
4172     *       {@link java.util.LinkedHashSet} for cross-JoinItem dedup
4173     *       (slice-82 codex round-1 Q2 BLOCKING precedent).</li>
4174     *   <li>The DELETE itself emits NO new cross-stmt
4175     *       {@link LineageEdge}s — empty {@code outputColumns[]}
4176     *       means there is no STATEMENT_OUTPUT(deleteIdx, …) anchor
4177     *       for slice-83's SUBQUERY-kind emitter. Extracted
4178     *       FROM-subqueries DO emit their own internal lineage edges
4179     *       via {@code emitLineageForStatement} inside
4180     *       {@link #processDirectSubqueryTable}.</li>
4181     * </ul>
4182     */
4183    /**
4184     * Build under caller-supplied {@code options}; see
4185     * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}.
4186     */
4187    public static SemanticBuildResult buildDeleteResult(
4188            TDeleteSqlStatement delete, NameBindingProvider provider,
4189            SemanticIRBuildOptions options) {
4190        return executeBuild(options, () -> buildDeleteImpl(delete, provider));
4191    }
4192
4193    /** Build a DELETE and atomically return its program and diagnostics. */
4194    public static SemanticBuildResult buildDeleteResult(
4195            TDeleteSqlStatement delete, NameBindingProvider provider) {
4196        return buildDeleteResult(delete, provider,
4197                SemanticIRBuildOptions.defaults());
4198    }
4199
4200    @Deprecated
4201    public static SemanticProgram buildDelete(TDeleteSqlStatement delete,
4202                                     NameBindingProvider provider,
4203                                     SemanticIRBuildOptions options) {
4204        clearBuildDiagnostics();
4205        return publishLegacyResult(buildDeleteResult(delete, provider, options));
4206    }
4207
4208    @Deprecated
4209    public static SemanticProgram buildDelete(TDeleteSqlStatement delete,
4210                                        NameBindingProvider provider) {
4211        clearBuildDiagnostics();
4212        return publishLegacyResult(buildDeleteResult(delete, provider));
4213    }
4214
4215    private static SemanticProgram buildDeleteImpl(TDeleteSqlStatement delete,
4216                                                   NameBindingProvider provider) {
4217        if (delete == null) {
4218            throw new IllegalArgumentException("delete must not be null");
4219        }
4220        if (provider == null) {
4221            throw new IllegalArgumentException("provider must not be null");
4222        }
4223
4224        // 1) Slice 106 — admit top-level WITH on DELETE. Walks the CTE
4225        // list left-to-right, building each body as a preceding
4226        // StatementGraph and producing cteNameToStatementIndex +
4227        // ctePublishedColumns for the FROM-as-CTE branch in
4228        // buildDeleteRelation below. Mirrors the slice-105 UPDATE
4229        // walker. `stmts` / `lineage` allocated here (hoisted from the
4230        // prior slice-84 location) so the CTE walker can append.
4231        // DELETE_CTE_NOT_SUPPORTED stays declared-but-unreached
4232        // (slice 71/72/82/86/95/96/97/98/99/100/101/102/103/104/105
4233        // precedent).
4234        List<StatementGraph> stmts = new ArrayList<>();
4235        List<LineageEdge> lineage = new ArrayList<>();
4236        Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>();
4237        Map<String, Integer> cteNameToStatementIndex = buildDeleteCteList(
4238                delete, provider, stmts, lineage, ctePublishedColumns);
4239
4240        // 2) Target table — defensive (parser usually rejects first).
4241        TTable targetTable = delete.getTargetTable();
4242        if (targetTable == null || targetTable.getTableName() == null) {
4243            throw new SemanticIRBuildException(Diagnostic.error(
4244                    DiagnosticCode.DELETE_TARGET_MISSING,
4245                    "DELETE statement has no resolvable target table",
4246                    delete));
4247        }
4248        String targetQName = targetTable.getTableName().toString();
4249        if (targetQName == null || targetQName.isEmpty()) {
4250            throw new SemanticIRBuildException(Diagnostic.error(
4251                    DiagnosticCode.DELETE_TARGET_MISSING,
4252                    "DELETE target table name is empty",
4253                    delete));
4254        }
4255
4256        // 3) Slice 84 / Slice 92 — joined-DELETE discriminator.
4257        // Parser-probe-verified shapes:
4258        //   - Admit (slice 84): PG `DELETE FROM t USING j` / MSSQL
4259        //     `DELETE FROM t FROM t spqh JOIN sp` / MSSQL `DELETE spqh
4260        //     FROM t spqh JOIN sp` / Snowflake DELETE-USING — all have
4261        //     joins.size=0 and referenceJoins.size > 0.
4262        //   - Admit (slice 92): MySQL `DELETE T1 FROM T1 [WHERE pred]`
4263        //     self-reference — joins.size=1, refJoins.size=1, and all
4264        //     three names (joins[0].table, refJoins[0].table, target)
4265        //     agree case-insensitively. Semantically identical to
4266        //     `DELETE FROM T1 [WHERE pred]`; produces the same IR shape.
4267        //   - Reject (slice-81 code preserved for non-self-ref):
4268        //     MySQL `DELETE T1, T2 FROM …` (joins.size=2) and
4269        //     MySQL `DELETE FROM T1 USING T1, T2` (refJoins.size=2).
4270        //
4271        // Slice 84 drops the slice-81 `tables.size > 1` and
4272        // `fromSourceJoin != null` blanket rejects (both fire for
4273        // admit shapes; probe confirms no parser-reachable shape
4274        // needs them when joins.size == 0). Candidate (d) in §8.1.4
4275        // (Hive multi-insert) remains open for a future slice.
4276        boolean mysqlSelfRef = false;
4277        if (delete.joins != null && delete.joins.size() > 0) {
4278            // Slice 92 — admit MySQL self-reference form:
4279            //   DELETE T1 FROM T1 [WHERE …]
4280            // The check requires all three names to match (codex
4281            // plan-review rounds Q1+Q5 BLOCKING fix: checking only
4282            // joins[0] is insufficient — DELETE T1 FROM T2 would
4283            // incorrectly admit because joins[0]=T1=target but
4284            // refJoins[0]=T2≠target).
4285            mysqlSelfRef = isMysqlSelfReferenceDelete(delete, targetQName);
4286            if (!mysqlSelfRef) {
4287                throw new SemanticIRBuildException(Diagnostic.error(
4288                        DiagnosticCode.DELETE_JOINED_NOT_SUPPORTED,
4289                        "DELETE with multi-target / multi-USING clause is "
4290                                + "not supported by SemanticIRBuilder.buildDelete; "
4291                                + "slice 84 admits PG `DELETE FROM t USING j` and "
4292                                + "MSSQL `DELETE FROM t FROM t JOIN s` shapes; "
4293                                + "slice 92 admits MySQL "
4294                                + "`DELETE T1 FROM T1 [WHERE …]` self-reference",
4295                        delete));
4296            }
4297        }
4298
4299        // 4) Slice 85 lifts the RETURNING / OUTPUT rejects on DELETE.
4300        // The cheap statement-level OUTPUT_INTO reject fires here so a
4301        // multi-violation shape routes to the cheaper structural code
4302        // first. {@code DELETE_RETURNING_CLAUSE_NOT_SUPPORTED} and
4303        // {@code DELETE_OUTPUT_CLAUSE_NOT_SUPPORTED} stay declared but
4304        // unreached (slice 71/72 retain-for-documentation precedent).
4305        if (delete.getOutputClause() != null
4306                && delete.getOutputClause().getIntoTable() != null) {
4307            throw new SemanticIRBuildException(Diagnostic.error(
4308                    DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED,
4309                    "DELETE OUTPUT ... INTO <target> writes a second target; "
4310                            + "slice 85 admits projection-only OUTPUT",
4311                    delete));
4312        }
4313        if (delete.getOrderByClause() != null
4314                || delete.getLimitClause() != null) {
4315            throw new SemanticIRBuildException(Diagnostic.error(
4316                    DiagnosticCode.DELETE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED,
4317                    "DELETE with ORDER BY / LIMIT (MySQL) is not "
4318                            + "supported by SemanticIRBuilder.buildDelete; "
4319                            + "slice 81 admits no row-pruning on DELETE",
4320                    delete));
4321        }
4322
4323        // 4.7) Slice 84 — extract FROM-subqueries from referenceJoins
4324        // (after slice 106's CTE walker so the CTE bodies precede any
4325        // extracted FROM-subquery in the program). Mirrors slice-83
4326        // UPDATE FROM-subquery extraction (which uses
4327        // update.getJoins()); here we use delete.getReferenceJoins().
4328        // buildDelete owns fresh local stmts/lineage lists (allocated
4329        // in step 1 above) so exceptions propagate cleanly to the
4330        // caller — no snapshot/rollback wrapper.
4331        //
4332        // Slice 106 — forward cteNameToStatementIndex +
4333        // ctePublishedColumns so a nested SELECT inside an extracted
4334        // FROM-subquery body can resolve outer-WITH CTE references
4335        // (Resolver2 wires CTEScope; the maps are forwarded for parity
4336        // with the SELECT / MERGE / UPDATE call sites and so the
4337        // §N test for `USING (SELECT … FROM cte) sub` produces the
4338        // expected cross-stmt edge to the CTE body).
4339        //
4340        // Decorate the provider with the outer-WITH CTE name set so
4341        // the SELECT-side {@link #buildRelation} routes references to
4342        // those names through {@link RelationKind#CTE} (rather than
4343        // TABLE), which in turn makes
4344        // {@link #emitLineageForStatement} emit the cross-stmt
4345        // {@code STATEMENT_OUTPUT(subIdx,col) →
4346        // STATEMENT_OUTPUT(cteIdx,col)} edge required by §N. This
4347        // mirrors the SELECT-side outer-WITH walker
4348        // (see {@link #build}'s {@code outerProvider}).
4349        NameBindingProvider providerWithCte = cteNameToStatementIndex.isEmpty()
4350                ? provider
4351                : provider.withCteContext(cteNameToStatementIndex.keySet());
4352        Map<String, Integer> subqueryAliasToIndex =
4353                extractDeleteFromSubqueries(delete, providerWithCte, stmts, lineage,
4354                        cteNameToStatementIndex, ctePublishedColumns);
4355        Map<String, List<String>> deleteInScope = buildDeleteInScopeMap(
4356                delete, subqueryAliasToIndex, stmts,
4357                cteNameToStatementIndex, ctePublishedColumns);
4358        NameBindingProvider providerWithStar = deleteInScope.isEmpty()
4359                ? providerWithCte
4360                : providerWithCte.withInScopeRelationColumns(deleteInScope);
4361
4362        // 5) WHERE refs — slice 111 lifts the slice-81 blanket subquery
4363        // reject by routing uncorrelated predicate-subquery wrappers
4364        // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
4365        // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction pipeline
4366        // refactored by slice 110 to take a PredicateClauseContext. The
4367        // new DELETE_WHERE constant carries clause-specific
4368        // DiagnosticCode IDs (8 new DELETE_WHERE_* codes) and a
4369        // "DELETE WHERE clause" label. Each extracted wrapper lands as
4370        // its own <predicate_subquery_<i>> StatementGraph BEFORE the
4371        // DELETE (deleteIdx below = stmts.size() naturally accounts for
4372        // them — slice-83 dynamic-index pattern, slice 110 UPDATE
4373        // precedent). Remaining non-subquery refs flow into
4374        // filterColumnRefs via collectColumnRefsSkipping (or
4375        // collectColumnRefsTolerant on the slice-92 MySQL self-ref
4376        // path). Window functions in non-subquery subtrees still reject
4377        // via rejectWindowFunctionInScopeSkipping. Slice 84 —
4378        // providerWithStar so WHERE refs against extracted subquery
4379        // aliases bind correctly (slice-83 precedent).
4380        //
4381        // Slice 106 — providerWithCte (then providerWithStar on top of
4382        // it) already decorates the provider with the outer-WITH CTE
4383        // name set so the predicate body's inner SELECT routes
4384        // `FROM cte` refs through RelationKind.CTE and
4385        // emitLineageForStatement emits the
4386        // STATEMENT_OUTPUT(subIdx,col) → STATEMENT_OUTPUT(cteIdx,col)
4387        // cross-stmt edge (slice 110 UPDATE precedent).
4388        List<ColumnRef> filterRefs;
4389        TWhereClause where = delete.getWhereClause();
4390        if (where == null || where.getCondition() == null) {
4391            filterRefs = Collections.<ColumnRef>emptyList();
4392        } else {
4393            Set<TExpression> extractedWhereRoots =
4394                    Collections.<TExpression>emptySet();
4395            if (containsAnySubquery(where)) {
4396                extractedWhereRoots =
4397                        extractUncorrelatedPredicateSubqueriesFromClause(
4398                                where.getCondition(), providerWithStar,
4399                                stmts, lineage, cteNameToStatementIndex,
4400                                PredicateClauseContext.DELETE_WHERE);
4401                rejectAnyRemainingSubqueriesFromClause(
4402                        where.getCondition(), extractedWhereRoots,
4403                        PredicateClauseContext.DELETE_WHERE);
4404            }
4405            rejectWindowFunctionInScopeSkipping(where, "WHERE clause",
4406                    extractedWhereRoots);
4407            // Codex diff-review P1 fix: for MySQL self-reference DELETE the
4408            // MySQL parser puts 3 T1 instances in stmt.tables (target +
4409            // joins[0] + refJoins[0]), making Resolver2's inferredCandidates
4410            // see 3 candidates for any unqualified column → NOT_FOUND →
4411            // COLUMN_BINDING_NON_EXACT. Use a tolerant collector for the
4412            // self-ref path: EXACT_MATCH bindings (qualified refs) are
4413            // preserved verbatim; non-exact bindings emit the column ref with
4414            // the SQL-written qualifier (null for unqualified refs) instead of
4415            // throwing. Qualified refs like WHERE T1.id = 1 still get full
4416            // EXACT_MATCH treatment; only WHERE id = 1 (no qualifier) falls
4417            // back to the tolerant path. Slice 111 — both helpers now skip
4418            // extracted predicate-subquery subtrees so inner refs do not
4419            // leak into outer filterColumnRefs.
4420            filterRefs = mysqlSelfRef
4421                    ? collectColumnRefsTolerant(where, providerWithStar,
4422                            targetQName, extractedWhereRoots)
4423                    : collectColumnRefsSkipping(where, providerWithStar,
4424                            extractedWhereRoots);
4425        }
4426
4427        // 5.5) Slice 84 — walk delete.getReferenceJoins() to populate
4428        // relations[] (TABLE-kind FROM-side sources, target excluded
4429        // by reference identity; SUBQUERY-kind for USING (SELECT …))
4430        // and joinColumnRefs[] (ON-clause refs across all JoinItems).
4431        // Mirrors slice-82 buildUpdate's FROM walk, with the
4432        // `update.getJoins()` source replaced by
4433        // `delete.getReferenceJoins()`.
4434        //
4435        // Slice 92 — for MySQL self-reference DELETE T1 FROM T1,
4436        // refJoins[0] is the same table as the target; skip the loop
4437        // so relations[] stays empty (mirrors the slice-81 single-target
4438        // contract). Resolver2's ScopeBuilder has already registered
4439        // the FROM-clause table (including any alias) via the
4440        // `referenceJoins` walk in preVisit(TDeleteSqlStatement), so
4441        // WHERE refs resolve correctly even without a relations[] entry.
4442        List<RelationSource> relations = new ArrayList<>();
4443        // Slice-82 codex round-1 Q2 BLOCKING precedent — joinRefs
4444        // accumulates across multiple JoinItems in chained-JOIN
4445        // shapes. LinkedHashSet ensures cross-JoinItem dedup.
4446        java.util.LinkedHashSet<ColumnRef> joinRefsSet =
4447                new java.util.LinkedHashSet<>();
4448        TJoinList refJoins = delete.getReferenceJoins();
4449        if (!mysqlSelfRef && refJoins != null) {
4450            for (int ji = 0; ji < refJoins.size(); ji++) {
4451                TJoin join = refJoins.getJoin(ji);
4452                TTable leftTable = join.getTable();
4453                // Slice 106 — threads cteNameToStatementIndex so the
4454                // FROM-driver buildDeleteRelation call can route
4455                // objectname-typed CTE references to a SUBQUERY-kind
4456                // RelationSource pointing at the CTE statement.
4457                buildDeleteRelation(leftTable, targetTable, relations, delete,
4458                        cteNameToStatementIndex);
4459                TJoinItemList items = join.getJoinItems();
4460                if (items == null) continue;
4461                for (int i = 0; i < items.size(); i++) {
4462                    TJoinItem item = items.getJoinItem(i);
4463                    // Slice 106 — threads cteNameToStatementIndex through
4464                    // the JoinItem walker so JOIN-side CTE refs (MSSQL
4465                    // `FROM target t JOIN cte ON …`) get SUBQUERY-kind
4466                    // RelationSource emission.
4467                    buildDeleteJoinItem(item, targetTable, providerWithStar,
4468                            relations, joinRefsSet, delete,
4469                            cteNameToStatementIndex);
4470                }
4471            }
4472        }
4473        List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet);
4474
4475        // 6) Build the DELETE outer.
4476        // - relations[] may be non-empty for joined DELETE (slice 84);
4477        //   empty for single-target DELETE (slice 81 contract).
4478        // - target.columns empty by design — DELETE removes whole rows.
4479        RelationBinding targetBinding = new RelationBinding(
4480                RelationKind.TABLE, targetQName);
4481        TargetRelation target = new TargetRelation(
4482                targetBinding, Collections.<String>emptyList());
4483
4484        // Slice 85 — build RETURNING / OUTPUT projection columns BEFORE
4485        // the StatementGraph so the new returningColumns slot can be
4486        // populated. deleteIdx mirrors the slice-84 stmts.size() pattern.
4487        int deleteIdx = stmts.size();
4488        // DELETE target alias = effective alias from the target's
4489        // TTable (slice-84 convention).
4490        String deleteTargetAlias = effectiveAliasOf(targetTable);
4491        if (deleteTargetAlias == null || deleteTargetAlias.isEmpty()) {
4492            deleteTargetAlias = targetQName;
4493        }
4494        // Slice 123 — combine slice-84 FROM-subquery aliases with slice-106
4495        // CTE-as-relation aliases so a RETURNING/OUTPUT ref to a SUBQUERY-
4496        // kind FROM-side relation column emits the cross-stmt
4497        // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge into the producer.
4498        Map<String, Integer> returningAliasToSubIdx =
4499                buildDeleteCombinedAliasToSubIdx(delete,
4500                        subqueryAliasToIndex, cteNameToStatementIndex);
4501        List<OutputColumn> returningColumns = buildReturningColumns(
4502                delete.getReturningClause(),
4503                delete.getOutputClause(),
4504                "DELETE",
4505                targetQName,
4506                deleteTargetAlias,
4507                /*targetTable=*/ targetTable,
4508                relations,
4509                returningAliasToSubIdx,
4510                providerWithStar,
4511                deleteIdx,
4512                lineage,
4513                delete);
4514
4515        StatementGraph deleteStmt = new StatementGraph(
4516                /*name=*/ null,
4517                "DELETE",
4518                relations,
4519                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
4520                returningColumns,
4521                filterRefs,
4522                joinRefs,
4523                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4524                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4525                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4526                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
4527                /*distinct=*/ false,
4528                /*setOperator=*/ null,
4529                /*rowLimit=*/ null,
4530                target);
4531
4532        stmts.add(deleteStmt);
4533        // Slice 85 — extracted FROM-subqueries have already emitted
4534        // their own internal lineage edges into `lineage` via
4535        // processDirectSubqueryTable; buildReturningColumns also
4536        // already appended STATEMENT_OUTPUT(deleteIdx, retName) →
4537        // TABLE_COLUMN(targetQName, baseCol) edges above. No further
4538        // edges are needed.
4539        return new SemanticProgram(stmts, lineage);
4540    }
4541
4542    /**
4543     * Slice 94 — admit the single-target MERGE skeleton:
4544     * <pre>
4545     *     MERGE INTO target [AS] tgt
4546     *     USING (source_table | (SELECT ...) ) [AS] src
4547     *     ON  &lt;join condition&gt;
4548     *     WHEN MATCHED      [AND &lt;cond&gt;] THEN UPDATE SET c1 = expr1 [, ...]
4549     *     WHEN NOT MATCHED  [AND &lt;cond&gt;] THEN INSERT [(c1, ...)] VALUES (expr1, ...)
4550     *     WHEN MATCHED      [AND &lt;cond&gt;] THEN DELETE
4551     * </pre>
4552     *
4553     * <p>Emits one {@code "MERGE"}-kind {@link StatementGraph} carrying:
4554     * <ul>
4555     *   <li>{@link TargetRelation} on {@code getTarget()} only — slice
4556     *       78/80 contract: target lives on the dedicated target slot,
4557     *       NOT in {@code relations[]}. The slice-77/79 catalog walker
4558     *       fires the kind-discriminated "MERGE target relation 'X'"
4559     *       message via {@code targetWarnMessage("MERGE")}.</li>
4560     *   <li>{@code relations[]} = one entry for the USING source
4561     *       (TABLE-kind base table or SUBQUERY-kind aliased subquery).
4562     *       The slice-77 FROM walker fires "FROM relation 'X'" for
4563     *       missing source.</li>
4564     *   <li>{@code outputColumns[]} = empty (MERGE has no projection).</li>
4565     *   <li>{@code joinColumnRefs[]} = ON condition refs + per-WHEN AND
4566     *       condition refs, LinkedHashSet-deduplicated (slice 82
4567     *       pattern).</li>
4568     *   <li>{@code filterColumnRefs[]} = per-WHEN action WHERE refs
4569     *       (UPDATE WHERE, UPDATE...DELETE WHERE, INSERT WHERE; slice
4570     *       95). Empty when no action WHERE is present.</li>
4571     * </ul>
4572     *
4573     * <p>Per-WHEN action lineage:
4574     * <ul>
4575     *   <li>{@code WHEN MATCHED THEN UPDATE SET col_i = expr_i}: emit
4576     *       one {@link LineageEdge} per (target col, RHS source ref)
4577     *       pair as {@code TABLE_COLUMN(target,col) ← <ref>} — direct,
4578     *       no STATEMENT_OUTPUT intermediate (MERGE has no SELECT
4579     *       projection). Codex round-2 Q4 confirmed YES.</li>
4580     *   <li>{@code WHEN NOT MATCHED THEN INSERT (c1, ...) VALUES (e1, ...)}:
4581     *       same pattern — one edge per (insert col, source ref).</li>
4582     *   <li>{@code WHEN MATCHED THEN DELETE}: no per-column lineage
4583     *       (slice 81 DELETE contract).</li>
4584     *   <li>{@code WHEN MATCHED [AND <cond>] THEN DO NOTHING} (PG 15+,
4585     *       slice 96): admitted as a no-op action. No per-column
4586     *       lineage (slice 81 DELETE precedent). Per-WHEN AND
4587     *       condition refs still feed {@code joinColumnRefs[]} via
4588     *       the pre-dispatch block.</li>
4589     *   <li>{@code WHEN NOT MATCHED BY SOURCE [AND <cond>] THEN
4590     *       UPDATE SET ... | DELETE} (SQL Server, slice 97):
4591     *       admitted with the SQL Server semantic invariant that
4592     *       SET RHS and per-WHEN AND cond may not reference USING
4593     *       source columns (no source row exists when the action
4594     *       fires). Source-side refs reject with
4595     *       {@link DiagnosticCode#MERGE_NOT_MATCHED_BY_SOURCE_REFERENCES_SOURCE}.
4596     *       INSERT on BY SOURCE is parser-admitted but semantically
4597     *       invalid; rejects with
4598     *       {@link DiagnosticCode#MERGE_NOT_MATCHED_BY_SOURCE_INSERT_NOT_VALID}.
4599     *       UPDATE target self-refs ({@code t.a = t.b}) emit no
4600     *       lineage edges (slice-94 alias-filter convention; codex
4601     *       round-1 Q2 confirmed). PG 17+ BY SOURCE syntax still
4602     *       parses as type 2 plain NOT MATCHED in parser 4.1.5.0
4603     *       — that parser gap is not addressed in slice 97.</li>
4604     * </ul>
4605     *
4606     * <p>For USING-subquery, the inner SELECT is built via {@link #build}
4607     * and appended as a preceding {@link StatementGraph}; its inner
4608     * lineage edges are rebased by the current statement-list offset so
4609     * STATEMENT_OUTPUT indices stay valid (slice 78 INSERT pattern).
4610     *
4611     * <p>Resolver2 already handles MERGE via {@link gudusoft.gsqlparser.resolver2.scope.MergeScope}
4612     * — both USING base tables and USING subqueries surface as
4613     * {@code sourceTable + EXACT_MATCH} bindings on RHS / VALUES /
4614     * ON / WHEN-AND refs. Codex round-2 Q5 BLOCKING fix: we install
4615     * an explicit slice-83-style published-column map only for
4616     * USING subqueries (deterministic; cheap; matches the SELECT-
4617     * side FROM-subquery pattern even when redundant).
4618     */
4619    /**
4620     * Build under caller-supplied {@code options}; see
4621     * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}.
4622     */
4623    public static SemanticBuildResult buildMergeResult(
4624            TMergeSqlStatement merge, NameBindingProvider provider,
4625            SemanticIRBuildOptions options) {
4626        return executeBuild(options, () -> buildMergeImpl(merge, provider));
4627    }
4628
4629    /** Build a MERGE and atomically return its program and diagnostics. */
4630    public static SemanticBuildResult buildMergeResult(
4631            TMergeSqlStatement merge, NameBindingProvider provider) {
4632        return buildMergeResult(merge, provider,
4633                SemanticIRBuildOptions.defaults());
4634    }
4635
4636    @Deprecated
4637    public static SemanticProgram buildMerge(TMergeSqlStatement merge,
4638                                     NameBindingProvider provider,
4639                                     SemanticIRBuildOptions options) {
4640        clearBuildDiagnostics();
4641        return publishLegacyResult(buildMergeResult(merge, provider, options));
4642    }
4643
4644    @Deprecated
4645    public static SemanticProgram buildMerge(TMergeSqlStatement merge,
4646                                        NameBindingProvider provider) {
4647        clearBuildDiagnostics();
4648        return publishLegacyResult(buildMergeResult(merge, provider));
4649    }
4650
4651    private static SemanticProgram buildMergeImpl(TMergeSqlStatement merge,
4652                                                  NameBindingProvider provider) {
4653        if (merge == null) {
4654            throw new IllegalArgumentException("merge must not be null");
4655        }
4656        if (provider == null) {
4657            throw new IllegalArgumentException("provider must not be null");
4658        }
4659        // Slice 94 — defensive UsingScope reset at entry. MERGE does
4660        // not produce its own UsingScope but a parent context might
4661        // (e.g. nested-statement contexts); mirrors slice 80 / 86
4662        // buildUpdate hygiene.
4663        provider = provider.withUsingScope(UsingScope.EMPTY);
4664
4665        // Slice 101 — hoist allocations earlier so buildMergeCteList can
4666        // append CTE bodies as preceding statements. The slice-94 reject
4667        // at this location is replaced by the CTE walker below.
4668        List<StatementGraph> stmts = new ArrayList<>();
4669        List<LineageEdge> lineage = new ArrayList<>();
4670
4671        // 1) Slice 101 — admit top-level WITH on MERGE. Walks CTE list
4672        // left-to-right, building each body as a preceding statement.
4673        // Produces cteNameToStatementIndex + ctePublishedColumns for the
4674        // USING-as-CTE branch below. Mirrors SELECT-side build() at
4675        // lines 516-653. `MERGE_CTE_NOT_SUPPORTED` stays declared-but-
4676        // unreached for API stability (slice 71/72/82/86/95/96/97/98/99/100
4677        // precedent).
4678        Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>();
4679        Map<String, Integer> cteNameToStatementIndex = buildMergeCteList(
4680                merge, provider, stmts, lineage, ctePublishedColumns);
4681
4682        // 2) Target table — defensive.
4683        TTable targetTable = merge.getTargetTable();
4684        if (targetTable == null || targetTable.getTableName() == null) {
4685            throw new SemanticIRBuildException(Diagnostic.error(
4686                    DiagnosticCode.MERGE_TARGET_MISSING,
4687                    "MERGE statement has no resolvable target table",
4688                    merge));
4689        }
4690        String targetQName = targetTable.getTableName().toString();
4691        if (targetQName == null || targetQName.isEmpty()) {
4692            throw new SemanticIRBuildException(Diagnostic.error(
4693                    DiagnosticCode.MERGE_TARGET_MISSING,
4694                    "MERGE target table name is empty",
4695                    merge));
4696        }
4697
4698        // 3) USING source — defensive.
4699        TTable usingTable = merge.getUsingTable();
4700        if (usingTable == null) {
4701            throw new SemanticIRBuildException(Diagnostic.error(
4702                    DiagnosticCode.MERGE_USING_SOURCE_MISSING,
4703                    "MERGE statement has no USING source",
4704                    merge));
4705        }
4706
4707        // 4) ON condition — defensive (parser usually rejects first).
4708        TExpression onCondition = merge.getCondition();
4709        if (onCondition == null) {
4710            throw new SemanticIRBuildException(Diagnostic.error(
4711                    DiagnosticCode.MERGE_ON_CONDITION_MISSING,
4712                    "MERGE statement has no ON condition",
4713                    merge));
4714        }
4715
4716        // 5) OUTPUT INTO / RETURNING / LIMIT / error logging rejects.
4717        // Slice 98 lifts MSSQL MERGE OUTPUT projection (non-INTO) via
4718        // the slice-85 buildReturningColumns walker; the actual call
4719        // is deferred until after step 8 because the walker needs the
4720        // populated relations[] (USING source). OUTPUT INTO continues
4721        // to reject (writes a second target). The RETURNING-clause
4722        // branch stays declared-but-unreached: PG parser drops
4723        // MERGE RETURNING silently, Oracle PARSE_FAILED, Couchbase
4724        // has no test reach (slice 71/72/82/86/95/96/97 precedent).
4725        if (merge.getOutputClause() != null
4726                && merge.getOutputClause().getIntoTable() != null) {
4727            throw new SemanticIRBuildException(Diagnostic.error(
4728                    DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED,
4729                    "MERGE OUTPUT ... INTO <target> writes a second "
4730                            + "target; slice 98 admits OUTPUT projection only",
4731                    merge));
4732        }
4733        if (merge.getReturningClause() != null) {
4734            throw new SemanticIRBuildException(Diagnostic.error(
4735                    DiagnosticCode.MERGE_RETURNING_CLAUSE_NOT_SUPPORTED,
4736                    "MERGE RETURNING projection (Oracle / Couchbase) is "
4737                            + "not supported by SemanticIRBuilder.buildMerge",
4738                    merge));
4739        }
4740        if (merge.getLimitClause() != null) {
4741            throw new SemanticIRBuildException(Diagnostic.error(
4742                    DiagnosticCode.MERGE_LIMIT_NOT_SUPPORTED,
4743                    "MERGE with LIMIT (Couchbase) is not supported by "
4744                            + "SemanticIRBuilder.buildMerge",
4745                    merge));
4746        }
4747        if (merge.getErrorLoggingClause() != null) {
4748            throw new SemanticIRBuildException(Diagnostic.error(
4749                    DiagnosticCode.MERGE_ERROR_LOGGING_NOT_SUPPORTED,
4750                    "MERGE LOG ERRORS INTO (Oracle) is not supported by "
4751                            + "SemanticIRBuilder.buildMerge",
4752                    merge));
4753        }
4754
4755        // 6) Build USING source RelationSource. If USING is a subquery,
4756        // extract it as a preceding StatementGraph and emit a SUBQUERY-
4757        // kind RelationSource that points at it; slice-83 pattern.
4758        // Otherwise emit a TABLE-kind RelationSource.
4759        // Slice 101 — `stmts` / `lineage` were hoisted to the top of
4760        // buildMerge so the CTE walker can append its preceding CTE
4761        // body statements first. Do NOT re-declare them here.
4762        String usingAlias = effectiveAliasOf(usingTable);
4763        if (usingAlias == null || usingAlias.isEmpty()) {
4764            usingAlias = (usingTable.getName() == null
4765                    || usingTable.getName().toString().isEmpty())
4766                    ? "__merge_using__"
4767                    : usingTable.getName().toString();
4768        }
4769        boolean usingIsSubquery = usingTable.getTableType()
4770                == gudusoft.gsqlparser.ETableSource.subquery;
4771        List<RelationSource> relations = new ArrayList<>();
4772        Map<String, List<String>> mergeInScope = new LinkedHashMap<>();
4773        NameBindingProvider providerWithStar = provider;
4774        // Slice 94 — alias resolution maps for the per-WHEN action
4775        // lineage emitter. TABLE-kind sources map alias → qualifiedName;
4776        // SUBQUERY-kind sources map alias → statement index of the
4777        // extracted inner SELECT. A SEPARATE `targetAliases` set
4778        // identifies refs whose relationAlias is the target alias
4779        // (codex round-1 diff Q1 BLOCKING — without this separation,
4780        // a self-merge where USING happens to share the target's name
4781        // would mis-classify the source alias as the target alias).
4782        Map<String, String> aliasToTableQName = new HashMap<>();
4783        Map<String, Integer> aliasToSubIdx = new HashMap<>();
4784        Set<String> targetAliases = new HashSet<>();
4785        String targetAlias = effectiveAliasOf(targetTable);
4786        if (targetAlias != null && !targetAlias.isEmpty()) {
4787            targetAliases.add(targetAlias.toLowerCase(Locale.ROOT));
4788        }
4789        targetAliases.add(targetQName.toLowerCase(Locale.ROOT));
4790        if (usingIsSubquery) {
4791            TSelectSqlStatement usingSelect = usingTable.getSubquery();
4792            if (usingSelect == null) {
4793                throw new SemanticIRBuildException(Diagnostic.error(
4794                        DiagnosticCode.MERGE_SOURCE_NOT_SUPPORTED,
4795                        "MERGE USING declared as subquery but no inner "
4796                                + "SELECT statement was attached",
4797                        merge));
4798            }
4799            // Slice 122 — close the slice-110 documented parity gap so a
4800            // USING body that references an outer MERGE CTE
4801            // (`WITH cte AS (...) MERGE INTO t USING (SELECT ... FROM cte) s`)
4802            // binds CTE-kind and emits the cross-stmt edge into the CTE body.
4803            // Build the USING body directly into the SHARED stmts/lineage via
4804            // buildSelectProgramInto (no rebase — see its javadoc for the full
4805            // rationale), seeded with a COPY of the outer MERGE's CTE maps.
4806            // The COPY (not the live map) keeps a USING-body-own CTE from
4807            // leaking into the outer cteNameToStatementIndex consulted later
4808            // by the slice-116 action-WHERE extractor and the USING-as-CTE
4809            // `else` branch — matching pre-slice-122, where own-WITH CTEs were
4810            // never registered in the outer map.
4811            Map<String, Integer> usingCteMap =
4812                    new HashMap<>(cteNameToStatementIndex);
4813            Map<String, List<String>> usingCtePublished =
4814                    new LinkedHashMap<>(ctePublishedColumns);
4815            int subIdx = buildSelectProgramInto(usingSelect, provider,
4816                    stmts, lineage, usingCteMap, usingCtePublished);
4817            // Codex round-2 Q5 BLOCKING fix: install slice-83-style
4818            // in-scope map for USING subquery columns, scoped only to
4819            // the USING alias (codex round-3 Q2: ensure scoped to
4820            // USING alias only, no override of target / base-table).
4821            StatementGraph usingOuter = stmts.get(subIdx);
4822            List<String> publishedCols = new ArrayList<>();
4823            for (OutputColumn oc : usingOuter.getOutputColumns()) {
4824                if (oc.getName() != null && !oc.getName().isEmpty()) {
4825                    publishedCols.add(oc.getName());
4826                }
4827            }
4828            mergeInScope.put(
4829                    usingAlias.toLowerCase(Locale.ROOT), publishedCols);
4830            providerWithStar = provider.withInScopeRelationColumns(
4831                    mergeInScope);
4832            relations.add(new RelationSource(usingAlias,
4833                    new RelationBinding(RelationKind.SUBQUERY, usingAlias)));
4834            aliasToSubIdx.put(
4835                    usingAlias.toLowerCase(Locale.ROOT), subIdx);
4836        } else {
4837            // Slice 101 — USING-as-CTE detection. When MERGE has a WITH
4838            // clause and the USING bare name matches a CTE declared in
4839            // that WITH clause, route to a SUBQUERY-kind RelationSource
4840            // pointing at the CTE's already-built statement index. This
4841            // ensures:
4842            //   (a) lineage edges flow to STATEMENT_OUTPUT(cteIdx, col),
4843            //       not the fictitious TABLE_COLUMN(cteName, col);
4844            //   (b) the slice-77 catalog-miss WARN walker (which walks
4845            //       only TABLE-kind RelationSources) skips the CTE name;
4846            //   (c) Resolver2-bound CTE refs (probe 2026-05-17: status
4847            //       EXACT_MATCH with sourceTable=<cteName>) flow through
4848            //       the same emitMergeLineageEdge dispatch.
4849            // Case-insensitive lookup matches SQL identifier semantics.
4850            String usingBareName = (usingTable.getName() == null)
4851                    ? ""
4852                    : usingTable.getName().toString();
4853            String usingBareNameLower =
4854                    usingBareName.toLowerCase(Locale.ROOT);
4855            Integer cteIdx = usingBareNameLower.isEmpty()
4856                    ? null
4857                    : cteNameToStatementIndex.get(usingBareNameLower);
4858            if (cteIdx != null) {
4859                // USING references a declared CTE.
4860                List<String> publishedCols = ctePublishedColumns.get(
4861                        usingBareNameLower);
4862                if (publishedCols == null) {
4863                    publishedCols = new ArrayList<>();
4864                }
4865                mergeInScope.put(
4866                        usingAlias.toLowerCase(Locale.ROOT),
4867                        publishedCols);
4868                providerWithStar = provider.withInScopeRelationColumns(
4869                        mergeInScope);
4870                relations.add(new RelationSource(usingAlias,
4871                        new RelationBinding(
4872                                RelationKind.SUBQUERY, usingAlias)));
4873                aliasToSubIdx.put(
4874                        usingAlias.toLowerCase(Locale.ROOT), cteIdx);
4875                // Also register the bare CTE name in case the SQL
4876                // omits the alias (e.g. `USING src ON ...` with no
4877                // trailing alias). Mirrors the TABLE-kind branch
4878                // (line below) which also registers the bare name.
4879                aliasToSubIdx.put(usingBareNameLower, cteIdx);
4880            } else {
4881                // TABLE-kind USING — use the source table's qualified name
4882                // as the binding's qualifiedName so the slice-77 catalog
4883                // walker can find it.
4884                String usingQName = (usingTable.getTableName() == null)
4885                        ? usingAlias
4886                        : usingTable.getTableName().toString();
4887                relations.add(new RelationSource(usingAlias,
4888                        new RelationBinding(RelationKind.TABLE, usingQName)));
4889                aliasToTableQName.put(
4890                        usingAlias.toLowerCase(Locale.ROOT), usingQName);
4891                // Also register the bare name in case the SQL omits the
4892                // alias (e.g. `USING managers ON ...` without `s`).
4893                aliasToTableQName.put(
4894                        usingQName.toLowerCase(Locale.ROOT), usingQName);
4895            }
4896        }
4897
4898        // 7) Walk ON condition + per-WHEN AND conditions to build
4899        // joinColumnRefs[] with LinkedHashSet dedup (slice 82 pattern).
4900        // Reject ON-side subqueries: not supported in this slice; users
4901        // can still use a USING subquery for complex source logic.
4902        if (containsAnySubqueryExpression(onCondition)) {
4903            throw new SemanticIRBuildException(Diagnostic.error(
4904                    DiagnosticCode.MERGE_WHEN_CONDITION_HAS_SUBQUERY_NOT_SUPPORTED,
4905                    "MERGE ON condition contains a subquery; slice 94 "
4906                            + "admits scalar-only ON conditions",
4907                    merge));
4908        }
4909        rejectWindowFunctionInScope(onCondition, "MERGE ON condition");
4910        LinkedHashSet<ColumnRef> joinRefsSet = new LinkedHashSet<>();
4911        joinRefsSet.addAll(collectColumnRefs(onCondition, providerWithStar));
4912        // Slice 95 — per-WHEN action WHERE refs (UPDATE WHERE,
4913        // UPDATE...DELETE WHERE, INSERT WHERE) accumulate here.
4914        // Slice 94 left these refs silently dropped; slice 95 routes
4915        // them through filterColumnRefs[] (slice-80 UPDATE WHERE
4916        // precedent) — distinct from joinColumnRefs[] which holds
4917        // ON + WHEN-AND match conditions.
4918        LinkedHashSet<ColumnRef> filterRefsSet = new LinkedHashSet<>();
4919
4920        // 8) Per-WHEN clause loop. Validate type, dispatch to action
4921        // builder, accumulate joinColumnRefs and lineage edges.
4922        TargetRelation targetRel = null;
4923        List<String> targetColumnNames = new ArrayList<>();
4924        // Stable-order map: target col spelling (lower-cased) →
4925        // verbatim spelling encountered first. Iterating WHEN clauses
4926        // in order naturally produces SET-LHS first, then INSERT
4927        // column-list, matching the plan v3 column ordering rule.
4928        Map<String, String> seenTargetCols = new LinkedHashMap<>();
4929        // LineageEdge dedup spans the whole MERGE on
4930        // (target column lower-case, source ref lower-case key).
4931        Set<String> emittedEdgeKeys = new HashSet<>();
4932
4933        if (merge.getWhenClauses() == null
4934                || merge.getWhenClauses().size() == 0) {
4935            throw new SemanticIRBuildException(Diagnostic.error(
4936                    DiagnosticCode.MERGE_WHEN_NO_ACTION,
4937                    "MERGE statement has no WHEN clauses",
4938                    merge));
4939        }
4940        // Slice 116 — providerWithCteForActionWhere decorates
4941        // providerWithStar with withCteContext so the predicate body's
4942        // inner SELECT's `FROM cte` refs route through
4943        // RelationKind.CTE (slice 110 documented this is required for
4944        // emitLineageForStatement to emit STATEMENT_OUTPUT →
4945        // STATEMENT_OUTPUT edges into the CTE body). Hoisted here once
4946        // (cteNameToStatementIndex is finalized by line 3856 well
4947        // before the per-WHEN loop; recomputing per-WHEN would be
4948        // wasteful — codex diff-review Q1 advisory). The decoration
4949        // is scoped to collectMergeActionWhere only; providerWithStar
4950        // elsewhere stays unchanged so the WHEN AND condition (line
4951        // ~4153) and per-action SET/INSERT walkers see the original
4952        // provider — they already have their own slice-94 subquery
4953        // rejects so no asymmetric resolution surfaces.
4954        final NameBindingProvider providerWithCteForActionWhere =
4955                cteNameToStatementIndex.isEmpty()
4956                        ? providerWithStar
4957                        : providerWithStar.withCteContext(
4958                                cteNameToStatementIndex.keySet());
4959        // Slice 118 — build the MERGE correlation scope once (target +
4960        // USING source + outer CTEs) and thread through every per-WHEN
4961        // action WHERE call so correlated predicate subqueries promote
4962        // outer-aliased refs into OUTER_REFERENCE relations instead of
4963        // rejecting them. Mirrors the slice-117 pattern (UPDATE
4964        // SET-RHS correlated scalars). The scope is null-safe — every
4965        // value type inside flows from already-computed buildMerge
4966        // state (targetTable / usingTable / aliasToSubIdx /
4967        // cteNameToStatementIndex).
4968        final EnclosingScope mergeCorrelationScope =
4969                buildMergeEnclosingScope(merge, cteNameToStatementIndex,
4970                        aliasToSubIdx);
4971        for (int wi = 0; wi < merge.getWhenClauses().size(); wi++) {
4972            TMergeWhenClause when = merge.getWhenClauses().getElement(wi);
4973            // Slice 97 — BY SOURCE variants (SQL Server admits parser
4974            // types 7 / 8) are now admitted. PG 17+ syntax still parses
4975            // as type 2 (parser gap; slice 97 does not address). The
4976            // legacy MERGE_WHEN_NOT_MATCHED_BY_SOURCE_NOT_SUPPORTED
4977            // code stays declared-but-unreached (slice 71/72/82/86/95/96
4978            // precedent).
4979            boolean isNotMatchedBySource =
4980                    when.getType() == TMergeWhenClause.not_matched_by_source
4981                    || when.getType()
4982                        == TMergeWhenClause.not_matched_by_source_with_condition;
4983            // Per-WHEN AND condition (matched_with_condition,
4984            // not_matched_with_condition, not_matched_by_target_with_condition,
4985            // not_matched_by_source_with_condition).
4986            TExpression whenCond = when.getCondition();
4987            if (whenCond != null) {
4988                if (containsAnySubqueryExpression(whenCond)) {
4989                    throw new SemanticIRBuildException(Diagnostic.error(
4990                            DiagnosticCode.MERGE_WHEN_CONDITION_HAS_SUBQUERY_NOT_SUPPORTED,
4991                            "MERGE WHEN AND condition contains a subquery; "
4992                                    + "slice 94 admits scalar-only WHEN "
4993                                    + "conditions",
4994                            merge));
4995                }
4996                rejectWindowFunctionInScope(whenCond, "MERGE WHEN AND condition");
4997                List<ColumnRef> condRefs =
4998                        collectColumnRefs(whenCond, providerWithStar);
4999                // Slice 97 — BY SOURCE branches forbid source-side
5000                // refs in the AND condition (no source row exists).
5001                if (isNotMatchedBySource) {
5002                    rejectSourceRefsForBySource(condRefs, aliasToTableQName,
5003                            aliasToSubIdx, "MERGE WHEN NOT MATCHED BY SOURCE "
5004                                    + "AND condition", merge);
5005                }
5006                joinRefsSet.addAll(condRefs);
5007            }
5008            // Dispatch to action. Slice 96 — DO NOTHING is a no-op
5009            // action (PG 15+): no SET/INSERT VALUES, no per-column
5010            // lineage (slice-81 DELETE precedent). Per-WHEN AND
5011            // condition refs were already collected into joinRefsSet
5012            // above. MERGE_DO_NOTHING_NOT_SUPPORTED stays declared-
5013            // but-unreached for API stability (slice 71/72/82/86/95
5014            // precedent).
5015            boolean isDoNothingAction = when.getDoNothingClause() != null;
5016            TMergeUpdateClause upd = when.getUpdateClause();
5017            TMergeInsertClause ins = when.getInsertClause();
5018            boolean isDeleteAction = when.getDeleteClause() != null;
5019            if (upd == null && ins == null && !isDeleteAction
5020                    && !isDoNothingAction) {
5021                throw new SemanticIRBuildException(Diagnostic.error(
5022                        DiagnosticCode.MERGE_WHEN_NO_ACTION,
5023                        "MERGE WHEN clause #" + (wi + 1)
5024                                + " has no UPDATE / INSERT / DELETE / "
5025                                + "DO NOTHING action",
5026                        merge));
5027            }
5028            // Slice 95 — collect per-WHEN action WHERE refs into
5029            // filterRefsSet. Slice 116 — uncorrelated predicate-subquery
5030            // wrappers in those WHEREs now extract through the slice-23+
5031            // pipeline via PredicateClauseContext.MERGE_WHEN_WHERE
5032            // (mirrors slice 110-114 lifts on UPDATE / DELETE / SELECT /
5033            // set-op branch / CTE-body WHEREs). Window functions still
5034            // reject via rejectWindowFunctionInScopeSkipping (slice 95
5035            // contract preserved). MERGE_UPDATE_DELETE_WHERE_NOT_SUPPORTED
5036            // remains declared-but-unreached (slice 71/72/82/86
5037            // precedent). The providerWithCteForActionWhere decoration
5038            // is hoisted ABOVE the per-WHEN loop (codex diff-review Q1
5039            // advisory) since cteNameToStatementIndex is finalized
5040            // before the loop.
5041            if (upd != null) {
5042                collectMergeActionWhere(upd.getUpdateWhereClause(),
5043                        "MERGE WHEN action UPDATE WHERE",
5044                        providerWithCteForActionWhere, filterRefsSet,
5045                        stmts, lineage, cteNameToStatementIndex, merge,
5046                        mergeCorrelationScope);
5047                collectMergeActionWhere(upd.getDeleteWhereClause(),
5048                        "MERGE WHEN action DELETE WHERE",
5049                        providerWithCteForActionWhere, filterRefsSet,
5050                        stmts, lineage, cteNameToStatementIndex, merge,
5051                        mergeCorrelationScope);
5052            }
5053            if (ins != null) {
5054                collectMergeActionWhere(ins.getInsertWhereClause(),
5055                        "MERGE WHEN action INSERT WHERE",
5056                        providerWithCteForActionWhere, filterRefsSet,
5057                        stmts, lineage, cteNameToStatementIndex, merge,
5058                        mergeCorrelationScope);
5059            }
5060            // Slice 97 — BY SOURCE forbids INSERT semantically. MSSQL
5061            // parser admits the shape, so Semantic IR rejects.
5062            if (isNotMatchedBySource && ins != null) {
5063                throw new SemanticIRBuildException(Diagnostic.error(
5064                        DiagnosticCode.MERGE_NOT_MATCHED_BY_SOURCE_INSERT_NOT_VALID,
5065                        "MERGE WHEN NOT MATCHED BY SOURCE THEN INSERT is "
5066                                + "not a valid SQL Server action; INSERT only "
5067                                + "applies when the source row has no target "
5068                                + "match. Slice 97 admits UPDATE / DELETE on "
5069                                + "BY SOURCE branches.",
5070                        merge));
5071            }
5072            if (upd != null) {
5073                // Slice 97 — pre-walk SET RHS refs for BY SOURCE
5074                // branches to reject source-side refs before lineage
5075                // emission (Q1 in plan-review: keep helper branch-
5076                // agnostic; double-collect cost is bounded since
5077                // BY SOURCE UPDATEs are small).
5078                if (isNotMatchedBySource) {
5079                    rejectBySourceSetRhsRefs(upd, providerWithStar,
5080                            aliasToTableQName, aliasToSubIdx, merge);
5081                }
5082                buildMergeUpdateAction(upd, targetQName, targetTable,
5083                        providerWithStar, seenTargetCols, lineage,
5084                        emittedEdgeKeys, aliasToTableQName,
5085                        aliasToSubIdx, targetAliases, merge);
5086            }
5087            if (ins != null) {
5088                buildMergeInsertAction(ins, targetQName, targetTable,
5089                        providerWithStar, seenTargetCols, lineage,
5090                        emittedEdgeKeys, aliasToTableQName,
5091                        aliasToSubIdx, targetAliases, merge);
5092            }
5093            // DELETE action: no per-column lineage (slice 81 contract).
5094        }
5095
5096        // 9) Build TargetRelation from accumulated target column spellings.
5097        for (String spelling : seenTargetCols.values()) {
5098            targetColumnNames.add(spelling);
5099        }
5100        targetRel = new TargetRelation(
5101                new RelationBinding(RelationKind.TABLE, targetQName),
5102                targetColumnNames);
5103
5104        // 9.5) Slice 98 — MSSQL MERGE OUTPUT projection. Reuses the
5105        // slice-85 buildReturningColumns walker with dmlKind="MERGE":
5106        // Pass 1.5's INSERT/DELETE pseudo-table mismatch check naturally
5107        // skips (MERGE is action-polymorphic — INSERTED and DELETED
5108        // may both legitimately appear). The walker handles $action via
5109        // a slice-98-specific short-circuit (derived OutputColumn, no
5110        // sources, no edges). mergeIdx = stmts.size() because the MERGE
5111        // StatementGraph is appended below; for USING-subquery shapes,
5112        // step 6 has already appended the extracted SELECT so the index
5113        // points at the upcoming MERGE position (slice-83 dynamic-index
5114        // pattern). Pass relations[] as fromSideRelations so unique
5115        // USING-alias matches resolve to the source qname (codex Q4).
5116        int mergeIdx = stmts.size();
5117        String mergeTargetAlias = effectiveAliasOf(targetTable);
5118        if (mergeTargetAlias == null || mergeTargetAlias.isEmpty()) {
5119            mergeTargetAlias = targetQName;
5120        }
5121        List<OutputColumn> returningCols = buildReturningColumns(
5122                /*ret=*/ null,
5123                /*out=*/ merge.getOutputClause(),
5124                "MERGE",
5125                targetQName,
5126                mergeTargetAlias,
5127                targetTable,
5128                /*fromSideRelations=*/ relations,
5129                // Slice 124 — MERGE OUTPUT USING-source lineage unification
5130                // (symmetric follow-up to slice 123, which lifted DELETE +
5131                // UPDATE). aliasToSubIdx is already the MERGE combined
5132                // alias→stmt-index map: it holds the USING alias for both the
5133                // USING-(SELECT) subquery branch (alias→subIdx) and the
5134                // USING-as-CTE branch (alias→cteIdx); both add a SUBQUERY-kind
5135                // relation to relations[], so returningSubquerySourceIndex
5136                // promotes a MERGE OUTPUT ref bound to the USING source to the
5137                // canonical STATEMENT_OUTPUT(mergeIdx, ret) →
5138                // STATEMENT_OUTPUT(subOrCteIdx, col) edge instead of the
5139                // fictitious TABLE_COLUMN(<usingAlias>, col). TABLE-kind USING
5140                // adds nothing to aliasToSubIdx, and INSERTED/DELETED/target
5141                // refs are excluded by the gate, so those paths keep their
5142                // slice-85/98 TABLE_COLUMN edge.
5143                /*fromSideAliasToStmtIndex=*/ aliasToSubIdx,
5144                providerWithStar,
5145                mergeIdx,
5146                lineage,
5147                merge);
5148
5149        // 10) Emit StatementGraph. joinColumnRefs[] = ON + WHEN-AND refs.
5150        // Slice 95: filterColumnRefs[] = per-WHEN action WHERE refs
5151        // (UPDATE WHERE, UPDATE...DELETE WHERE, INSERT WHERE).
5152        // Slice 98: returningColumns[] = MERGE OUTPUT projection.
5153        List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet);
5154        List<ColumnRef> filterRefs = new ArrayList<>(filterRefsSet);
5155        StatementGraph mergeStmt = new StatementGraph(
5156                /*name=*/ null,
5157                "MERGE",
5158                relations,
5159                /*outputColumns=*/ Collections.<OutputColumn>emptyList(),
5160                returningCols,
5161                filterRefs,
5162                joinRefs,
5163                /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
5164                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
5165                /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(),
5166                /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(),
5167                /*distinct=*/ false,
5168                /*setOperator=*/ null,
5169                /*rowLimit=*/ null,
5170                targetRel);
5171        stmts.add(mergeStmt);
5172
5173        return new SemanticProgram(stmts, lineage);
5174    }
5175
5176    /**
5177     * Slice 95 — collect column refs from a per-WHEN action WHERE
5178     * predicate ({@code TMergeUpdateClause.updateWhereClause},
5179     * {@code TMergeUpdateClause.deleteWhereClause}, or
5180     * {@code TMergeInsertClause.insertWhereClause}) into the supplied
5181     * {@code filterRefsSet}.
5182     *
5183     * <p>Slice 116 — lifts the slice-95 blanket subquery reject by
5184     * routing uncorrelated predicate-subquery wrappers (IN-SELECT /
5185     * EXISTS / NOT EXISTS / scalar comparison / ANY-ALL-SOME) through
5186     * the slice-23+ JOIN-ON extraction pipeline refactored by slice
5187     * 110 to take a {@link PredicateClauseContext}. The
5188     * {@link PredicateClauseContext#MERGE_WHEN_WHERE} constant reuses
5189     * the {@code SELECT_WHERE_*} DiagnosticCode family (slice 113/114
5190     * precedent — a MERGE-action WHERE IS a SELECT WHERE in shape) so
5191     * the enum count stays at 279; only the {@code clauseLabel}
5192     * differs so diagnostic messages can identify the MERGE-action
5193     * host context.
5194     *
5195     * <p>Each extracted wrapper lands as its own
5196     * {@code <predicate_subquery_<i>>} StatementGraph BEFORE the
5197     * MERGE statement (so {@code mergeIdx = stmts.size()} below in
5198     * {@code buildMerge} already accounts for them — slice-83
5199     * dynamic-index pattern, slice 110/111 precedent). Remaining
5200     * non-subquery refs flow into {@code filterRefsSet} via
5201     * {@link #collectColumnRefsSkipping}. Window functions in
5202     * non-subquery subtrees still reject via
5203     * {@link #rejectWindowFunctionInScopeSkipping} (slice 95
5204     * window-function contract preserved).
5205     *
5206     * <p>The supplied {@code provider} must carry
5207     * {@code withCteContext(cteMap.keySet())} so the predicate body's
5208     * inner SELECT's {@code FROM cte} refs route through
5209     * {@link RelationKind#CTE} — without that decoration,
5210     * {@code emitLineageForStatement} would lose the
5211     * STATEMENT_OUTPUT &rarr; STATEMENT_OUTPUT edge to the CTE body
5212     * (slice 110 documented gap for UPDATE WHERE — same applies here).
5213     * {@code buildMerge} composes the decoration once before the
5214     * per-WHEN loop.
5215     *
5216     * <p>Null-safe: returns immediately when the WHERE expression is
5217     * absent (slice-94 default; most WHEN clauses have no action
5218     * WHERE).
5219     */
5220    private static void collectMergeActionWhere(TExpression expr,
5221                                                String label,
5222                                                NameBindingProvider provider,
5223                                                LinkedHashSet<ColumnRef> filterRefsSet,
5224                                                List<StatementGraph> stmts,
5225                                                List<LineageEdge> lineage,
5226                                                Map<String, Integer> cteMap,
5227                                                TMergeSqlStatement merge,
5228                                                EnclosingScope correlationScope) {
5229        if (expr == null) {
5230            return;
5231        }
5232        Set<TExpression> extractedRoots = Collections.<TExpression>emptySet();
5233        if (containsAnySubqueryExpression(expr)) {
5234            extractedRoots =
5235                    extractUncorrelatedPredicateSubqueriesFromClause(
5236                            expr, provider, stmts, lineage, cteMap,
5237                            PredicateClauseContext.MERGE_WHEN_WHERE,
5238                            correlationScope);
5239            rejectAnyRemainingSubqueriesFromClause(expr, extractedRoots,
5240                    PredicateClauseContext.MERGE_WHEN_WHERE);
5241        }
5242        rejectWindowFunctionInScopeSkipping(expr, label, extractedRoots);
5243        filterRefsSet.addAll(collectColumnRefsSkipping(expr, provider,
5244                extractedRoots));
5245    }
5246
5247    /**
5248     * Slice 97 — reject any source-aliased column ref in a
5249     * WHEN NOT MATCHED BY SOURCE branch. SQL Server forbids
5250     * source-side references in this branch because there is no
5251     * matching source row when the action fires.
5252     *
5253     * <p>A ref is "source-aliased" iff its
5254     * {@code relationAlias.toLowerCase(Locale.ROOT)} appears in
5255     * either alias map (TABLE-kind or SUBQUERY-kind USING source).
5256     * Refs whose alias is unknown to both maps are assumed to be
5257     * target-bound (slice-94 alias-filter convention) and are
5258     * left alone.
5259     *
5260     * <p>Walks all refs so a source ref nested inside an arbitrary
5261     * function call (e.g. {@code COALESCE(s.code, t.code)}) is
5262     * caught — {@code collectColumnRefs} descends through arbitrary
5263     * scalar expressions (codex round-1 Q3 confirmed YES).
5264     */
5265    private static void rejectSourceRefsForBySource(List<ColumnRef> refs,
5266                                                    Map<String, String> aliasToTableQName,
5267                                                    Map<String, Integer> aliasToSubIdx,
5268                                                    String label,
5269                                                    TMergeSqlStatement merge) {
5270        if (refs == null || refs.isEmpty()) {
5271            return;
5272        }
5273        for (ColumnRef r : refs) {
5274            String alias = r.getRelationAlias();
5275            if (alias == null || alias.isEmpty()) {
5276                continue;
5277            }
5278            String key = alias.toLowerCase(Locale.ROOT);
5279            if (aliasToTableQName.containsKey(key)
5280                    || aliasToSubIdx.containsKey(key)) {
5281                throw new SemanticIRBuildException(Diagnostic.error(
5282                        DiagnosticCode.MERGE_NOT_MATCHED_BY_SOURCE_REFERENCES_SOURCE,
5283                        label + " references USING source column '"
5284                                + r + "'; WHEN NOT MATCHED BY SOURCE "
5285                                + "branches must only reference target "
5286                                + "columns or constants.",
5287                        merge));
5288            }
5289        }
5290    }
5291
5292    /**
5293     * Slice 97 — pre-walk the SET RHS of every assignment in a BY SOURCE
5294     * UPDATE action and reject any source-side ref. Called before
5295     * {@link #buildMergeUpdateAction} so the existing slice-94 helper
5296     * remains BY-SOURCE-agnostic.
5297     *
5298     * <p>Skips assignments that aren't shaped as a simple
5299     * {@code assignment_t} expression; those defects are caught by
5300     * {@link #buildMergeUpdateAction} with
5301     * {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}.
5302     */
5303    private static void rejectBySourceSetRhsRefs(TMergeUpdateClause upd,
5304                                                 NameBindingProvider provider,
5305                                                 Map<String, String> aliasToTableQName,
5306                                                 Map<String, Integer> aliasToSubIdx,
5307                                                 TMergeSqlStatement merge) {
5308        TResultColumnList sets = upd.getUpdateColumnList();
5309        if (sets == null || sets.size() == 0) {
5310            return;
5311        }
5312        for (int i = 0; i < sets.size(); i++) {
5313            TResultColumn rc = sets.getResultColumn(i);
5314            TExpression assignment = (rc == null) ? null : rc.getExpr();
5315            if (assignment == null
5316                    || assignment.getExpressionType() != EExpressionType.assignment_t) {
5317                continue;
5318            }
5319            TExpression rhs = assignment.getRightOperand();
5320            if (rhs == null) {
5321                continue;
5322            }
5323            // Subquery RHS would short-circuit later with
5324            // UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED; ignore here.
5325            if (containsAnySubqueryExpression(rhs)) {
5326                continue;
5327            }
5328            List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider);
5329            rejectSourceRefsForBySource(rhsRefs, aliasToTableQName,
5330                    aliasToSubIdx,
5331                    "MERGE WHEN NOT MATCHED BY SOURCE UPDATE SET "
5332                            + "assignment #" + (i + 1) + " RHS",
5333                    merge);
5334        }
5335    }
5336
5337    /**
5338     * Slice 94 — process one WHEN MATCHED THEN UPDATE SET action.
5339     * Each {@code TResultColumn} carries an assignment_t TExpression
5340     * whose leftOperand is the SET LHS (target column reference) and
5341     * whose rightOperand is the value expression. We emit one
5342     * {@link LineageEdge} per (target col, RHS source ref) pair.
5343     */
5344    private static void buildMergeUpdateAction(TMergeUpdateClause upd,
5345                                               String targetQName,
5346                                               TTable targetTable,
5347                                               NameBindingProvider provider,
5348                                               Map<String, String> seenTargetCols,
5349                                               List<LineageEdge> lineage,
5350                                               Set<String> emittedEdgeKeys,
5351                                               Map<String, String> aliasToTableQName,
5352                                               Map<String, Integer> aliasToSubIdx,
5353                                               Set<String> targetAliases,
5354                                               TMergeSqlStatement merge) {
5355        TResultColumnList sets = upd.getUpdateColumnList();
5356        if (sets == null || sets.size() == 0) {
5357            return;
5358        }
5359        for (int i = 0; i < sets.size(); i++) {
5360            TResultColumn rc = sets.getResultColumn(i);
5361            TExpression assignment = (rc == null) ? null : rc.getExpr();
5362            if (assignment == null
5363                    || assignment.getExpressionType() != EExpressionType.assignment_t) {
5364                throw new SemanticIRBuildException(Diagnostic.error(
5365                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
5366                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
5367                                + " is not a simple column-value assignment_t",
5368                        merge));
5369            }
5370            TExpression lhs = assignment.getLeftOperand();
5371            TExpression rhs = assignment.getRightOperand();
5372            if (lhs == null || rhs == null) {
5373                throw new SemanticIRBuildException(Diagnostic.error(
5374                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
5375                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
5376                                + " is missing an operand",
5377                        merge));
5378            }
5379            if (lhs.getExpressionType() == EExpressionType.list_t) {
5380                throw new SemanticIRBuildException(Diagnostic.error(
5381                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
5382                        "MERGE WHEN MATCHED UPDATE SET tuple assignment "
5383                                + "'(a, b) = ...' is not supported",
5384                        merge));
5385            }
5386            if (lhs.getExpressionType() != EExpressionType.simple_object_name_t) {
5387                throw new SemanticIRBuildException(Diagnostic.error(
5388                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
5389                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
5390                                + " LHS is expressionType=" + lhs.getExpressionType()
5391                                + "; slice 94 admits simple column references only",
5392                        merge));
5393            }
5394            TObjectName targetCol = lhs.getObjectOperand();
5395            if (targetCol == null) {
5396                throw new SemanticIRBuildException(Diagnostic.error(
5397                        DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
5398                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
5399                                + " LHS has no TObjectName operand",
5400                        merge));
5401            }
5402            // Codex round-1 diff Q3 NO fix: SET LHS qualifier must be
5403            // either the target alias or the target qualified name.
5404            // A foreign qualifier (e.g. `s.name` on a SET LHS pointing
5405            // at source `s`) silently treated as a target column would
5406            // produce a wrong target column spelling.
5407            String rawSpelling = targetCol.toString();
5408            String colSpelling = validateAndStripSetLhsQualifier(
5409                    rawSpelling, targetTable, targetQName, merge);
5410            // Subquery / window on RHS — reuse existing codes per
5411            // plan v3 §B (codex round-1 Q2 NO fix).
5412            if (containsAnySubqueryExpression(rhs)) {
5413                throw new SemanticIRBuildException(Diagnostic.error(
5414                        DiagnosticCode.UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED,
5415                        "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1)
5416                                + " right-hand side contains a subquery; "
5417                                + "slice 94 admits scalar-only RHS expressions",
5418                        merge));
5419            }
5420            rejectWindowFunctionInScope(rhs, "MERGE WHEN MATCHED UPDATE SET RHS");
5421
5422            String lowerKey = colSpelling.toLowerCase(Locale.ROOT);
5423            if (!seenTargetCols.containsKey(lowerKey)) {
5424                seenTargetCols.put(lowerKey, colSpelling);
5425            }
5426            // Per-WHEN action lineage: TABLE_COLUMN(target,col) ← <RHS ref>
5427            List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider);
5428            for (ColumnRef src : rhsRefs) {
5429                emitMergeLineageEdge(targetQName, colSpelling, src,
5430                        lineage, emittedEdgeKeys, aliasToTableQName,
5431                        aliasToSubIdx, targetAliases);
5432            }
5433        }
5434    }
5435
5436    /**
5437     * Slice 94 — process one WHEN NOT MATCHED THEN INSERT (cols) VALUES (exprs)
5438     * action. Emits one {@link LineageEdge} per (insert col, source ref)
5439     * pair, plus arity validation between the explicit column list and
5440     * VALUES list. If the column list is omitted, we cannot derive
5441     * target column names — the slice rejects defensively.
5442     */
5443    private static void buildMergeInsertAction(TMergeInsertClause ins,
5444                                               String targetQName,
5445                                               TTable targetTable,
5446                                               NameBindingProvider provider,
5447                                               Map<String, String> seenTargetCols,
5448                                               List<LineageEdge> lineage,
5449                                               Set<String> emittedEdgeKeys,
5450                                               Map<String, String> aliasToTableQName,
5451                                               Map<String, Integer> aliasToSubIdx,
5452                                               Set<String> targetAliases,
5453                                               TMergeSqlStatement merge) {
5454        TResultColumnList values = ins.getValuelist();
5455        gudusoft.gsqlparser.nodes.TObjectNameList colList = ins.getColumnList();
5456        if (values == null || values.size() == 0) {
5457            throw new SemanticIRBuildException(Diagnostic.error(
5458                    DiagnosticCode.MERGE_INSERT_DEFAULT_VALUES_NOT_SUPPORTED,
5459                    "MERGE WHEN NOT MATCHED INSERT has no VALUES list "
5460                            + "(DEFAULT VALUES / row-type forms not supported)",
5461                    merge));
5462        }
5463        // If an explicit column list is present, validate arity.
5464        if (colList != null && colList.size() > 0) {
5465            if (colList.size() != values.size()) {
5466                throw new SemanticIRBuildException(Diagnostic.error(
5467                        DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH,
5468                        "MERGE WHEN NOT MATCHED INSERT column list has "
5469                                + colList.size() + " column(s) but VALUES "
5470                                + "list has " + values.size(),
5471                        merge));
5472            }
5473        }
5474        for (int i = 0; i < values.size(); i++) {
5475            TResultColumn rc = values.getResultColumn(i);
5476            TExpression rhs = (rc == null) ? null : rc.getExpr();
5477            if (rhs == null) {
5478                throw new SemanticIRBuildException(Diagnostic.error(
5479                        DiagnosticCode.MERGE_INSERT_DEFAULT_VALUES_NOT_SUPPORTED,
5480                        "MERGE WHEN NOT MATCHED INSERT VALUES item #"
5481                                + (i + 1) + " has no expression",
5482                        merge));
5483            }
5484            if (containsAnySubqueryExpression(rhs)) {
5485                throw new SemanticIRBuildException(Diagnostic.error(
5486                        DiagnosticCode.MERGE_INSERT_VALUES_HAS_SUBQUERY_NOT_SUPPORTED,
5487                        "MERGE WHEN NOT MATCHED INSERT VALUES item #"
5488                                + (i + 1) + " contains a subquery; slice 94 "
5489                                + "admits scalar-only VALUES expressions",
5490                        merge));
5491            }
5492            rejectWindowFunctionInScope(rhs, "MERGE INSERT VALUES");
5493
5494            // Target column name. If the explicit column list is
5495            // omitted, slice 94 does not synthesize positional target
5496            // column names (the catalog is required to map by
5497            // position; the current builder does not consume catalog
5498            // ordering). We still emit lineage edges from a synth
5499            // "__merge_insert_pos_<i>__" target column so source refs
5500            // are observable; users with an explicit column list get
5501            // the verbatim spelling.
5502            String colSpelling;
5503            if (colList != null && colList.size() > 0) {
5504                TObjectName col = colList.getObjectName(i);
5505                // INSERT column list is conventionally bare (no
5506                // qualifier), but Oracle / SQL Server allow
5507                // target-qualified spellings; strip them and reject
5508                // foreign qualifiers (codex round-1 diff Q3 NO fix).
5509                colSpelling = (col == null) ? ("__merge_insert_pos_" + i + "__")
5510                        : validateAndStripSetLhsQualifier(col.toString(),
5511                                targetTable, targetQName, merge);
5512            } else {
5513                colSpelling = "__merge_insert_pos_" + i + "__";
5514            }
5515            String lowerKey = colSpelling.toLowerCase(Locale.ROOT);
5516            if (!seenTargetCols.containsKey(lowerKey)) {
5517                seenTargetCols.put(lowerKey, colSpelling);
5518            }
5519            List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider);
5520            for (ColumnRef src : rhsRefs) {
5521                emitMergeLineageEdge(targetQName, colSpelling, src,
5522                        lineage, emittedEdgeKeys, aliasToTableQName,
5523                        aliasToSubIdx, targetAliases);
5524            }
5525        }
5526    }
5527
5528    /**
5529     * Slice 94 — emit a single MERGE per-WHEN action lineage edge:
5530     * {@code TABLE_COLUMN(targetQName, colSpelling) ← <src ref>}.
5531     * Deduplicates on a lower-case key so the same (target column,
5532     * source ref) pair appearing in multiple WHEN clauses produces
5533     * one edge (codex round-2 Q4 confirmed YES on this dedup
5534     * strategy).
5535     */
5536    private static void emitMergeLineageEdge(String targetQName,
5537                                             String colSpelling,
5538                                             ColumnRef src,
5539                                             List<LineageEdge> lineage,
5540                                             Set<String> emittedEdgeKeys,
5541                                             Map<String, String> aliasToTableQName,
5542                                             Map<String, Integer> aliasToSubIdx,
5543                                             Set<String> targetAliases) {
5544        if (src == null || colSpelling == null || colSpelling.isEmpty()) {
5545            return;
5546        }
5547        String srcAlias = src.getRelationAlias();
5548        String srcCol = src.getColumnName();
5549        if (srcCol == null || srcCol.isEmpty()) {
5550            return;
5551        }
5552        if (srcAlias == null) srcAlias = "";
5553        String aliasKey = srcAlias.toLowerCase(Locale.ROOT);
5554        // Codex round-1 diff Q1 BLOCKING fix: skip only if the alias
5555        // is a target alias, NOT if the alias resolves to a same-named
5556        // table. Self-merge (USING with same name as target) must
5557        // distinguish target from USING by alias identity, not by
5558        // resolved table name.
5559        if (targetAliases.contains(aliasKey)) {
5560            return;
5561        }
5562        // Map alias → LineageRef (TABLE_COLUMN or STATEMENT_OUTPUT).
5563        LineageRef toRef;
5564        if (aliasToSubIdx.containsKey(aliasKey)) {
5565            toRef = LineageRef.statementOutput(
5566                    aliasToSubIdx.get(aliasKey), srcCol);
5567        } else if (aliasToTableQName.containsKey(aliasKey)) {
5568            toRef = LineageRef.tableColumn(
5569                    aliasToTableQName.get(aliasKey), srcCol);
5570        } else {
5571            // Unknown alias — skip to avoid emitting a bogus edge.
5572            // The ref still surfaces on joinColumnRefs[] (ON / WHEN-AND).
5573            return;
5574        }
5575        // Codex round-1 diff Q2 NO fix: dedup on resolved LineageRef
5576        // identity (not raw alias), so `s.name` (alias) and
5577        // `managers.name` (qualified name) coming from the SAME
5578        // resolved source produce ONE edge. The key embeds the toRef's
5579        // canonical form: STATEMENT_OUTPUT(idx,col) or
5580        // TABLE_COLUMN(qname,col) — both are lower-cased.
5581        String resolvedKey;
5582        if (toRef.getKind() == LineageRef.Kind.STATEMENT_OUTPUT) {
5583            resolvedKey = "STMT_OUT::" + toRef.getStatementIndex() + "::"
5584                    + (toRef.getOutputName() == null ? "" : toRef.getOutputName());
5585        } else {
5586            resolvedKey = "TBL_COL::"
5587                    + (toRef.getQualifiedName() == null ? "" : toRef.getQualifiedName())
5588                    + "::"
5589                    + (toRef.getColumnName() == null ? "" : toRef.getColumnName());
5590        }
5591        String key = (targetQName + "::" + colSpelling + "::"
5592                + resolvedKey).toLowerCase(Locale.ROOT);
5593        if (emittedEdgeKeys.add(key)) {
5594            lineage.add(new LineageEdge(
5595                    LineageRef.tableColumn(targetQName, colSpelling),
5596                    toRef));
5597        }
5598    }
5599
5600    /**
5601     * Slice 94 — validate the SET LHS / INSERT column-list spelling
5602     * and strip a leading target qualifier. Codex round-1 diff Q3 NO
5603     * fix: previously the helper returned a foreign-qualified spelling
5604     * unchanged, which silently produced a wrong target column (e.g.
5605     * {@code "s.name"} would land in the target columns list verbatim
5606     * instead of being rejected).
5607     *
5608     * <p>Admit rules:
5609     * <ul>
5610     *   <li>Unqualified bare name: return unchanged.</li>
5611     *   <li>Qualified by target alias or qualified name: strip.</li>
5612     *   <li>Qualified by anything else: reject as
5613     *       {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}
5614     *       (the same code used for slice-80 UPDATE-LHS shape rejects,
5615     *       message text discriminates by mentioning the foreign
5616     *       qualifier).</li>
5617     * </ul>
5618     */
5619    private static String validateAndStripSetLhsQualifier(String spelling,
5620                                                          TTable targetTable,
5621                                                          String targetQName,
5622                                                          TMergeSqlStatement merge) {
5623        if (spelling == null) return spelling;
5624        int dot = spelling.indexOf('.');
5625        if (dot <= 0) return spelling;
5626        String qualifier = spelling.substring(0, dot);
5627        String bare = spelling.substring(dot + 1);
5628        String targetAlias = effectiveAliasOf(targetTable);
5629        if (targetAlias != null
5630                && qualifier.equalsIgnoreCase(targetAlias)) {
5631            return bare;
5632        }
5633        if (qualifier.equalsIgnoreCase(targetQName)) {
5634            return bare;
5635        }
5636        throw new SemanticIRBuildException(Diagnostic.error(
5637                DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED,
5638                "MERGE SET LHS / INSERT column qualifier '" + qualifier
5639                        + "' does not match the target table; slice 94 "
5640                        + "admits target-qualified or unqualified target "
5641                        + "column references only",
5642                merge));
5643    }
5644
5645    /**
5646     * Slice 84 — process one FROM-side source table for joined
5647     * {@link #buildDelete}. Mirrors slice-82 {@link #buildUpdateRelation}.
5648     * Applies the slice-84 reject contract for nested-join wrappers,
5649     * then appends a TABLE-kind {@link RelationSource} unless the
5650     * table is the target (reference-identity filter — clean IR
5651     * semantics: relations[] models read-side sources only). For
5652     * subquery sources (already extracted in step 4.7), publishes a
5653     * SUBQUERY-kind {@link RelationSource} so the inScope-enhanced
5654     * provider can route {@code sub.col} references.
5655     *
5656     * <p>Null-driver guard: probed PG
5657     * {@code DELETE FROM e USING (t1 JOIN t2 ON …)} returns
5658     * {@code refJoin[0].getTable() == null}. Silent skip mirrors
5659     * slice-82's null guard for the analogous UPDATE case;
5660     * documented as a known limitation (parenthesized JOIN-in-USING
5661     * is opaque to {@code relations[]} though WHERE refs still bind
5662     * via Resolver2).
5663     */
5664    private static void buildDeleteRelation(TTable t, TTable targetTable,
5665                                            List<RelationSource> relations,
5666                                            TDeleteSqlStatement delete,
5667                                            Map<String, Integer> cteNameToStatementIndex) {
5668        if (t == null) {
5669            return; // defensive — parenthesized JOIN-in-USING surfaces null
5670        }
5671        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
5672            // Slice 84 — admit FROM-side subqueries. The inner SELECT
5673            // has already been extracted as its own StatementGraph by
5674            // extractDeleteFromSubqueries. Publish a SUBQUERY-kind
5675            // RelationSource so the inScope-enhanced provider routes
5676            // `sub.col` references correctly. Mirrors slice-83
5677            // buildUpdateRelation's subquery branch.
5678            String subAlias = effectiveAliasOf(t);
5679            if (subAlias != null && !subAlias.isEmpty()) {
5680                relations.add(new RelationSource(subAlias,
5681                        new RelationBinding(RelationKind.SUBQUERY, subAlias)));
5682            }
5683            return;
5684        }
5685        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
5686            // Defensive: TTable wrapping a TJoin. Not reached by any
5687            // observed parser path on supported dialects (slice-82
5688            // precedent — parenthesized JOIN-in-USING surfaces a null
5689            // driver, not a join-typed TTable). Distinct DiagnosticCode
5690            // per slice-80's message-text-discrimination contract.
5691            throw new SemanticIRBuildException(Diagnostic.error(
5692                    DiagnosticCode.DELETE_FROM_NESTED_JOIN_NOT_SUPPORTED,
5693                    "DELETE FROM source is a nested join wrapper; "
5694                            + "slice 84 admits simple table / subquery "
5695                            + "FROM sources only",
5696                    delete));
5697        }
5698        // Reference-identity filter: target's own TTable instance is
5699        // excluded from relations[]. Different TTable instances with
5700        // the same qualified name (e.g. MSSQL `DELETE FROM t FROM t
5701        // spqh JOIN sp` where target identity A and FROM-driver
5702        // identity B share name "t") both stay — the catalog-miss
5703        // WARN walker's pass-1-target-then-pass-2-relations ordering
5704        // (slice 83) deduplicates by qualified name.
5705        if (t == targetTable) {
5706            return;
5707        }
5708        TObjectName tName = t.getTableName();
5709        if (tName == null) {
5710            return; // defensive
5711        }
5712        // Slice 106 — FROM-side CTE detection. When the FROM-side table
5713        // is an objectname-typed reference whose bare name matches a
5714        // declared CTE in this DELETE's outer WITH clause, emit a
5715        // SUBQUERY-kind RelationSource pointing at the CTE statement
5716        // (mirrors slice-105 buildUpdateRelation). The slice-77 catalog-
5717        // miss WARN walker filters to RelationKind.TABLE so CTE-bound
5718        // relations are naturally skipped, even when the catalog also
5719        // declares the same name (slice-105 §G / §X precedent).
5720        //
5721        // Explicit objectname guard (codex round-1 NICE Q3): subquery /
5722        // join table types are handled by the early returns above; this
5723        // guard documents the contract and makes the branch resilient
5724        // if a future TTable type is added.
5725        if (cteNameToStatementIndex != null
5726                && !cteNameToStatementIndex.isEmpty()
5727                && t.getTableType()
5728                        == gudusoft.gsqlparser.ETableSource.objectname) {
5729            String bareName = tName.toString();
5730            if (bareName != null && !bareName.isEmpty()) {
5731                String bareNameLower = bareName.toLowerCase(Locale.ROOT);
5732                if (cteNameToStatementIndex.containsKey(bareNameLower)) {
5733                    String cteAlias = effectiveAliasOf(t);
5734                    if (cteAlias == null || cteAlias.isEmpty()) {
5735                        cteAlias = bareName;
5736                    }
5737                    relations.add(new RelationSource(cteAlias,
5738                            new RelationBinding(RelationKind.SUBQUERY, cteAlias)));
5739                    return;
5740                }
5741            }
5742        }
5743        relations.add(new RelationSource(effectiveAliasOf(t),
5744                new RelationBinding(RelationKind.TABLE, tName.toString())));
5745    }
5746
5747    /**
5748     * Slice 84 — process one {@link TJoinItem} for joined
5749     * {@link #buildDelete}. Mirrors slice-82 {@link #buildUpdateJoinItem}.
5750     * Applies the slice-84 reject contract for USING / NATURAL /
5751     * subquery-in-ON, processes the right-side table via
5752     * {@link #buildDeleteRelation}, and collects ON-clause column
5753     * refs into {@code joinRefs} via the shared
5754     * {@link #collectColumnRefs} helper.
5755     */
5756    private static void buildDeleteJoinItem(TJoinItem item, TTable targetTable,
5757                                            NameBindingProvider provider,
5758                                            List<RelationSource> relations,
5759                                            java.util.LinkedHashSet<ColumnRef> joinRefs,
5760                                            TDeleteSqlStatement delete,
5761                                            Map<String, Integer> cteNameToStatementIndex) {
5762        if (item == null) return;
5763        if (item.getUsingColumns() != null && item.getUsingColumns().size() > 0) {
5764            throw new SemanticIRBuildException(Diagnostic.error(
5765                    DiagnosticCode.DELETE_FROM_JOIN_USING_NOT_SUPPORTED,
5766                    "DELETE FROM join uses USING(...); slice 84 admits "
5767                            + "JOIN ON / CROSS JOIN / comma-FROM only",
5768                    item));
5769        }
5770        if (isNaturalJoinType(item.getJoinType())) {
5771            throw new SemanticIRBuildException(Diagnostic.error(
5772                    DiagnosticCode.DELETE_FROM_JOIN_NATURAL_NOT_SUPPORTED,
5773                    "DELETE FROM uses NATURAL JOIN; slice 84 admits "
5774                            + "JOIN ON / CROSS JOIN / comma-FROM only",
5775                    item));
5776        }
5777        // Right-side table: apply the same source-shape rejects +
5778        // identity filter as the driver table. Slice 106 — threads
5779        // cteNameToStatementIndex so right-side CTE refs (MSSQL
5780        // `FROM target t JOIN cte ON …`) get SUBQUERY-kind emission.
5781        buildDeleteRelation(item.getTable(), targetTable, relations, delete,
5782                cteNameToStatementIndex);
5783        // ON-clause refs: subquery rejects with slice-84 code;
5784        // window function reuses CLAUSE_WINDOW_FUNCTION_LEAK via the
5785        // shared helper. CROSS JOIN has no ON; skip the walk entirely.
5786        TExpression onCond = item.getOnCondition();
5787        if (onCond == null) return;
5788        if (containsAnySubqueryExpression(onCond)) {
5789            throw new SemanticIRBuildException(Diagnostic.error(
5790                    DiagnosticCode.DELETE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED,
5791                    "DELETE FROM JOIN ON condition contains a subquery; "
5792                            + "slice 84 admits scalar predicates only",
5793                    item));
5794        }
5795        rejectWindowFunctionInScope(onCond, "DELETE FROM JOIN ON");
5796        joinRefs.addAll(collectColumnRefs(onCond, provider));
5797    }
5798
5799    /**
5800     * Slice 84 — extract every FROM-side subquery in
5801     * {@code delete.getReferenceJoins()} as its own
5802     * {@link StatementGraph} appended to {@code stmts} before the
5803     * DELETE itself. Walks both the driver TTable of each TJoin AND
5804     * each JoinItem's right table. Returns an alias → stmts-index
5805     * map so the consuming DELETE can (a) build its in-scope column
5806     * map via {@link #buildDeleteInScopeMap}, and (b) bind
5807     * {@code sub.col} references in WHERE / ON via the
5808     * inScope-enhanced provider.
5809     *
5810     * <p>Mirrors slice-83 {@link #extractUpdateFromSubqueries} but
5811     * walks {@code delete.getReferenceJoins()} instead of
5812     * {@code update.getJoins()}. Reuses the SELECT-side
5813     * {@link #processDirectSubqueryTable} verbatim, forwarding the
5814     * outer-WITH {@code cteNameToStatementIndex} +
5815     * {@code ctePublishedColumns} (slice 106) so a nested SELECT in
5816     * an extracted FROM-subquery body can resolve outer-CTE refs.
5817     * Pre-slice-106 the maps were always empty because slice 81
5818     * rejected top-level WITH on DELETE
5819     * ({@link DiagnosticCode#DELETE_CTE_NOT_SUPPORTED}).
5820     *
5821     * <p>No mutation-guard wrapper: buildDelete owns fresh local
5822     * lists and exceptions propagate cleanly to the caller.
5823     */
5824    private static Map<String, Integer> extractDeleteFromSubqueries(
5825            TDeleteSqlStatement delete,
5826            NameBindingProvider provider,
5827            List<StatementGraph> stmts,
5828            List<LineageEdge> lineage,
5829            Map<String, Integer> cteNameToStatementIndex,
5830            Map<String, List<String>> ctePublishedColumns) {
5831        Map<String, Integer> aliasToIndex = new HashMap<>();
5832        TJoinList refJoins = delete.getReferenceJoins();
5833        if (refJoins == null) return aliasToIndex;
5834        // Slice 106 — forward the outer-WITH CTE maps so a nested SELECT
5835        // inside an extracted FROM-subquery body can resolve outer-WITH
5836        // CTE references. Resolver2 wires CTEScope; the maps are
5837        // forwarded for parity with the SELECT / MERGE / UPDATE call
5838        // sites and so the §N test for
5839        // `USING (SELECT … FROM cte) sub` produces the expected
5840        // cross-stmt lineage edge to the CTE body.
5841        Map<String, Integer> cteMap = cteNameToStatementIndex == null
5842                ? Collections.<String, Integer>emptyMap()
5843                : cteNameToStatementIndex;
5844        Map<String, List<String>> ctePublished = ctePublishedColumns == null
5845                ? Collections.<String, List<String>>emptyMap()
5846                : ctePublishedColumns;
5847        for (int ji = 0; ji < refJoins.size(); ji++) {
5848            TJoin join = refJoins.getJoin(ji);
5849            // Driver table — may be a subquery (PG / SF / BQ / RS
5850            // `DELETE FROM t USING (SELECT …) sub` shape).
5851            processDirectSubqueryTable(join.getTable(), provider,
5852                    stmts, lineage, cteMap, ctePublished, aliasToIndex);
5853            TJoinItemList items = join.getJoinItems();
5854            if (items == null) continue;
5855            for (int i = 0; i < items.size(); i++) {
5856                TJoinItem item = items.getJoinItem(i);
5857                if (item == null) continue;
5858                // Right-side table of a JoinItem — may be a subquery
5859                // (MSSQL / PG `DELETE FROM t FROM x JOIN (SELECT …)
5860                // sub ON …` shape).
5861                processDirectSubqueryTable(item.getTable(), provider,
5862                        stmts, lineage, cteMap, ctePublished, aliasToIndex);
5863            }
5864        }
5865        return aliasToIndex;
5866    }
5867
5868    /**
5869     * Slice 84 — build an effective-alias-keyed in-scope map publishing
5870     * each extracted DELETE FROM-subquery's output column names.
5871     * Mirrors slice-83 {@link #buildUpdateInScopeMap} but walks
5872     * {@code delete.getReferenceJoins()}.
5873     *
5874     * <p>Base-table FROM-side relations do not need an entry: their
5875     * column resolution stays on the Resolver2 catalog path
5876     * (probed correct for PG / MSSQL DELETE — see slice-84 plan
5877     * §Codex Q4 + Q11).
5878     */
5879    private static Map<String, List<String>> buildDeleteInScopeMap(
5880            TDeleteSqlStatement delete,
5881            Map<String, Integer> subqueryAliasToIndex,
5882            List<StatementGraph> stmts,
5883            Map<String, Integer> cteNameToStatementIndex,
5884            Map<String, List<String>> ctePublishedColumns) {
5885        Map<String, List<String>> result = new HashMap<>();
5886        boolean haveSubq = subqueryAliasToIndex != null
5887                && !subqueryAliasToIndex.isEmpty();
5888        boolean haveCte = cteNameToStatementIndex != null
5889                && !cteNameToStatementIndex.isEmpty();
5890        if (!haveSubq && !haveCte) {
5891            return result;
5892        }
5893        TJoinList refJoins = delete.getReferenceJoins();
5894        if (refJoins == null) return result;
5895        for (int ji = 0; ji < refJoins.size(); ji++) {
5896            TJoin join = refJoins.getJoin(ji);
5897            addDeleteRelationToInScopeMap(join.getTable(),
5898                    subqueryAliasToIndex, stmts, result,
5899                    cteNameToStatementIndex, ctePublishedColumns);
5900            TJoinItemList items = join.getJoinItems();
5901            if (items == null) continue;
5902            for (int i = 0; i < items.size(); i++) {
5903                TJoinItem item = items.getJoinItem(i);
5904                if (item == null) continue;
5905                addDeleteRelationToInScopeMap(item.getTable(),
5906                        subqueryAliasToIndex, stmts, result,
5907                        cteNameToStatementIndex, ctePublishedColumns);
5908            }
5909        }
5910        return result;
5911    }
5912
5913    private static void addDeleteRelationToInScopeMap(TTable t,
5914            Map<String, Integer> subqueryAliasToIndex,
5915            List<StatementGraph> stmts,
5916            Map<String, List<String>> result,
5917            Map<String, Integer> cteNameToStatementIndex,
5918            Map<String, List<String>> ctePublishedColumns) {
5919        if (t == null) return;
5920        // Slice 106 — CTE-as-FROM-relation in-scope publication. When
5921        // the FROM-side table is an objectname-typed reference whose
5922        // bare name matches a declared outer CTE, publish the CTE's
5923        // own column names against the FROM-side effective alias so
5924        // WHERE / ON / RETURNING refs against the CTE alias bind
5925        // correctly. Mirrors slice-105 addUpdateRelationToInScopeMap.
5926        if (cteNameToStatementIndex != null
5927                && !cteNameToStatementIndex.isEmpty()
5928                && ctePublishedColumns != null
5929                && t.getTableType()
5930                        == gudusoft.gsqlparser.ETableSource.objectname) {
5931            TObjectName tName = t.getTableName();
5932            if (tName != null) {
5933                String bare = tName.toString();
5934                if (bare != null && !bare.isEmpty()) {
5935                    String bareLower = bare.toLowerCase(Locale.ROOT);
5936                    if (cteNameToStatementIndex.containsKey(bareLower)) {
5937                        String aliasKey = effectiveAliasLowerCaseOrNull(t);
5938                        if (aliasKey == null) aliasKey = bareLower;
5939                        List<String> cols = ctePublishedColumns.get(bareLower);
5940                        if (cols != null) {
5941                            result.put(aliasKey, cols);
5942                        }
5943                        return;
5944                    }
5945                }
5946            }
5947        }
5948        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) {
5949            return;
5950        }
5951        if (subqueryAliasToIndex == null) {
5952            return;
5953        }
5954        String key = effectiveAliasLowerCaseOrNull(t);
5955        if (key == null) return;
5956        Integer idx = subqueryAliasToIndex.get(key);
5957        if (idx == null) return;
5958        result.put(key, outputColumnNames(stmts.get(idx)));
5959    }
5960
5961    /**
5962     * Slice 92 — returns {@code true} when the MySQL DELETE statement is a
5963     * self-reference single-target form ({@code DELETE T1 FROM T1 [WHERE …]})
5964     * that is semantically equivalent to {@code DELETE FROM T1 [WHERE …]}.
5965     *
5966     * <p>The check requires ALL of the following (codex plan-review Q1+Q5
5967     * BLOCKING fix — checking only {@code joins[0]} is insufficient because
5968     * {@code DELETE T1 FROM T2} also has {@code joins.size==1} and
5969     * {@code joins[0].table=="T1"==targetQName}, yet the FROM clause points
5970     * to a different table):
5971     * <ol>
5972     *   <li>{@code joins.size == 1} — exactly one MySQL target list entry.</li>
5973     *   <li>{@code getReferenceJoins().size() == 1} — exactly one FROM clause
5974     *       table.</li>
5975     *   <li>{@code joins[0]} has no JoinItems — must be a plain table, not a
5976     *       JOIN chain.</li>
5977     *   <li>{@code refJoins[0]} has no JoinItems — same constraint.</li>
5978     *   <li>{@code joins[0].table.name.toLowerCase() == targetQName.toLowerCase()}.
5979     *   </li>
5980     *   <li>{@code refJoins[0].table.name.toLowerCase() == targetQName.toLowerCase()}.
5981     *   </li>
5982     * </ol>
5983     */
5984    private static boolean isMysqlSelfReferenceDelete(
5985            TDeleteSqlStatement delete, String targetQName) {
5986        if (delete.joins == null || delete.joins.size() != 1) return false;
5987        TJoinList ref = delete.getReferenceJoins();
5988        if (ref == null || ref.size() != 1) return false;
5989        TJoin join0 = delete.joins.getJoin(0);
5990        if (join0.getJoinItems() != null && join0.getJoinItems().size() > 0) {
5991            return false;
5992        }
5993        TJoin ref0 = ref.getJoin(0);
5994        if (ref0.getJoinItems() != null && ref0.getJoinItems().size() > 0) {
5995            return false;
5996        }
5997        TTable joinTable = join0.getTable();
5998        if (joinTable == null || joinTable.getTableName() == null) return false;
5999        TTable refTable = ref0.getTable();
6000        if (refTable == null || refTable.getTableName() == null) return false;
6001        String lowerTarget = targetQName.toLowerCase(java.util.Locale.ROOT);
6002        String joinName = joinTable.getTableName().toString()
6003                .toLowerCase(java.util.Locale.ROOT);
6004        String refName = refTable.getTableName().toString()
6005                .toLowerCase(java.util.Locale.ROOT);
6006
6007        // Codex diff-review P1 fix: MySQL allows the alias in the DELETE
6008        // target list instead of the table name:
6009        //   DELETE t FROM T1 AS t WHERE t.id = 1
6010        // In this form joins[0].table.name = "t" (the alias used in the
6011        // delete-list) while targetQName = "T1" (from getTargetTable()
6012        // which the parser resolves to the real table). Accept the target
6013        // table's alias as a valid joins[0] match alongside the table name.
6014        String targetAlias = null;
6015        if (delete.getTargetTable() != null
6016                && delete.getTargetTable().getAliasClause() != null
6017                && delete.getTargetTable().getAliasClause().getAliasName() != null) {
6018            String a = delete.getTargetTable().getAliasClause()
6019                    .getAliasName().toString();
6020            if (a != null && !a.isEmpty()) {
6021                targetAlias = a.toLowerCase(java.util.Locale.ROOT);
6022            }
6023        }
6024        boolean joinMatchesTarget = joinName.equals(lowerTarget)
6025                || (targetAlias != null && joinName.equals(targetAlias));
6026        // refJoins[0].table must always be the real table name (= targetQName).
6027        return joinMatchesTarget && refName.equals(lowerTarget);
6028    }
6029
6030    /**
6031     * Per-result-column metadata about an extracted scalar-subquery
6032     * projection (slice 11). {@link #statementIndex} points to the
6033     * inner body statement; {@link #innerOutputName} is the inner
6034     * SELECT's single projected output name (used to wire the
6035     * STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edge).
6036     */
6037    private static final class ScalarInfo {
6038        final int statementIndex;
6039        final String innerOutputName;
6040        ScalarInfo(int statementIndex, String innerOutputName) {
6041            this.statementIndex = statementIndex;
6042            this.innerOutputName = innerOutputName;
6043        }
6044    }
6045
6046    /**
6047     * Walk the consuming SELECT's FROM list. For every {@link TTable} of
6048     * type {@link gudusoft.gsqlparser.ETableSource#subquery}, recursively
6049     * build the inner statement, append it to {@code stmts}, emit its own
6050     * lineage edges, and record alias→statementIndex. The returned map is
6051     * scoped to this single consuming statement so duplicate aliases
6052     * across different scopes do not collide.
6053     *
6054     * <p>Slice 17: extraction now walks BOTH sides of every JOIN
6055     * ({@code TJoin.getTable()} for the left, {@code joinItems[i].getTable()}
6056     * for each right) and recurses into nested FROM-subquery bodies. Each
6057     * recursive level pre-extracts its own children before calling
6058     * {@code buildSelectStatement}, preserving the
6059     * {@code BodyIndexes}-required ordering (innermost body before
6060     * its consumer). FROM-subquery bodies still recurse with
6061     * {@code allowScalarProjectionSubqueries=false} (slice-15 invariant
6062     * pinned by {@code Slice15Test.scalarProjectionInsideFromSubqueryBodyStillRejected}).
6063     *
6064     * <p>Slice 18 lifts CTE bodies (the non-set-op CTE-body branch in
6065     * {@link #build} now invokes this extractor with
6066     * {@code allowFromSubqueries=true}). Still rejected: subqueries with
6067     * no alias, FROM-subqueries inside a scalar body / set-op branch /
6068     * set-op CTE body (each enforced by the caller's
6069     * {@code allowFromSubqueries=false}), and predicate subqueries inside
6070     * the FROM-subquery body's WHERE / JOIN ON / GROUP BY (slice-17
6071     * helper {@link #rejectSubqueriesInFromSubqueryBodyClauses}).
6072     */
6073    private static Map<String, Integer> extractFromSubqueriesAsStatements(
6074            TSelectSqlStatement consumer,
6075            NameBindingProvider consumerProvider,
6076            List<StatementGraph> stmts,
6077            List<LineageEdge> lineage,
6078            Map<String, Integer> cteNameToStatementIndex,
6079            Map<String, List<String>> ctePublishedColumns) {
6080        Map<String, Integer> aliasToIndex = new HashMap<>();
6081        if (consumer.joins == null) return aliasToIndex;
6082        // Slice 17 mutation-free preflight: walk the entire direct
6083        // FROM/JOIN list once and reject before any mutation of
6084        // stmts/lineage. Catches comma-FROM, anonymous subqueries,
6085        // unsupported join shapes, and ALL same-level alias collisions
6086        // (base AND subquery, since rejectDuplicateAliases inside
6087        // buildRelations only catches them later, after this level's
6088        // subquery body has already landed in stmts).
6089        preflightDirectFromList(consumer);
6090
6091        // Slice 17: walk both sides of every join and process each
6092        // direct subquery via the same helper so left/right can't drift.
6093        for (TJoin join : consumer.joins) {
6094            processDirectSubqueryTable(join.getTable(),
6095                    consumerProvider, stmts, lineage,
6096                    cteNameToStatementIndex, ctePublishedColumns, aliasToIndex);
6097            TJoinItemList items = join.getJoinItems();
6098            if (items == null) continue;
6099            for (int i = 0; i < items.size(); i++) {
6100                TJoinItem item = items.getJoinItem(i);
6101                if (item == null) continue;
6102                processDirectSubqueryTable(item.getTable(),
6103                        consumerProvider, stmts, lineage,
6104                        cteNameToStatementIndex, ctePublishedColumns, aliasToIndex);
6105            }
6106        }
6107        return aliasToIndex;
6108    }
6109
6110    /**
6111     * Slice 17 mutation-free preflight for the consumer's direct
6112     * FROM/JOIN list. Validates structural invariants BEFORE any
6113     * subquery body is appended to {@code stmts} so a deferred
6114     * failure (e.g. on the second of two siblings) doesn't strand
6115     * earlier-sibling output in the program.
6116     *
6117     * <p>Slice 62 (codex plan-review round 1): the comma-FROM
6118     * reject was removed here. The preflight runs only for
6119     * {@code allowFromSubqueries=true} paths (outer SELECT, CTE
6120     * body, FROM-subquery body recursion) — exactly the paths
6121     * that admit comma-FROM under slice 62. Synthetic body
6122     * contexts (scalar / set-op-branch / set-op-CTE / predicate)
6123     * do not run this preflight; they reach the gated reject in
6124     * {@link #buildRelations} (and predicate bodies hit the
6125     * earlier slice-62 reject inside
6126     * {@link #preflightPredicateSubqueryShape}).
6127     */
6128    private static void preflightDirectFromList(TSelectSqlStatement consumer) {
6129        if (consumer.joins == null) return;
6130        Set<String> seenSubqueryAliases = new HashSet<>();
6131        Set<String> seenAllAliases = new HashSet<>();
6132        for (TJoin join : consumer.joins) {
6133            preflightOneTable(join.getTable(), seenSubqueryAliases, seenAllAliases);
6134            TJoinItemList items = join.getJoinItems();
6135            if (items == null) continue;
6136            for (int i = 0; i < items.size(); i++) {
6137                TJoinItem item = items.getJoinItem(i);
6138                if (item == null) continue;
6139                rejectUnsupportedJoinShape(item);
6140                preflightOneTable(item.getTable(), seenSubqueryAliases, seenAllAliases);
6141            }
6142        }
6143    }
6144
6145    /**
6146     * Slice 17: validate one direct FROM/JOIN-list TTable in the
6147     * mutation-free preflight. Effective alias is the SQL-written alias
6148     * if present, else the slice-74 synthetic alias for unaliased
6149     * FROM-subqueries (position-keyed), else the table name (matches
6150     * {@link #buildRelation}).
6151     *
6152     * <p>Slice 74: removed the {@code FROM_SUBQUERY_ALIAS_REQUIRED} reject
6153     * for anonymous subqueries; the slot is now filled by
6154     * {@link FromSubqueryNaming#synthAliasFor}. Two unaliased subqueries
6155     * at the same source location are theoretically impossible (the
6156     * parser would have to emit the same start token for both), but if
6157     * it ever happens the {@code DUPLICATE_FROM_SUBQUERY_ALIAS} branch
6158     * below catches it the same way as a literal user-written duplicate.
6159     *
6160     * <p>Still rejects: duplicate subquery aliases (whether user-written
6161     * or synthetic by collision), and any cross-kind alias collision
6162     * (base alias colliding with a subquery alias).
6163     */
6164    private static void preflightOneTable(TTable t,
6165                                          Set<String> seenSubqueryAliases,
6166                                          Set<String> seenAllAliases) {
6167        if (t == null) return;
6168        boolean isSub = t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery;
6169        String effective = effectiveAliasOf(t);
6170        if (effective == null || effective.isEmpty()) return;
6171        String lower = effective.toLowerCase(Locale.ROOT);
6172        if (isSub && !seenSubqueryAliases.add(lower)) {
6173            throw new SemanticIRBuildException(
6174                    Diagnostic.error(DiagnosticCode.DUPLICATE_FROM_SUBQUERY_ALIAS,
6175                    "duplicate FROM-clause subquery alias '" + effective + "'", (TParseTreeNode) null));
6176        }
6177        if (!seenAllAliases.add(lower)) {
6178            throw new SemanticIRBuildException(
6179                    Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS,
6180                    "duplicate relation alias '" + effective
6181                            + "' is not supported (would make ColumnRef ambiguous)", (TParseTreeNode) null));
6182        }
6183    }
6184
6185    /**
6186     * Slice 17: extract one direct subquery TTable as its own
6187     * StatementGraph. Recurses into the inner SELECT first
6188     * (innermost body lands in {@code stmts} BEFORE its consumer, as
6189     * {@code BodyIndexes} requires). Skips non-subquery tables (base
6190     * relations are bound later by {@code buildRelations}).
6191     */
6192    private static void processDirectSubqueryTable(
6193            TTable t,
6194            NameBindingProvider consumerProvider,
6195            List<StatementGraph> stmts,
6196            List<LineageEdge> lineage,
6197            Map<String, Integer> cteNameToStatementIndex,
6198            Map<String, List<String>> ctePublishedColumns,
6199            Map<String, Integer> aliasToIndex) {
6200        if (t == null) return;
6201        // Slice 136: a PIVOT / UNPIVOT source can itself be a FROM-subquery
6202        // (`FROM (SELECT ...) [alias] PIVOT(...)`). Unwrap the pivoted_table to
6203        // its underlying source relation so the inner SELECT is extracted and
6204        // registered HERE — that lands the source-subquery alias in the
6205        // pre-pass's aliasToIndex (== build()'s outerSubqueryAliasToIndex)
6206        // BEFORE emitLineageForStatement runs, so the pivot→subquery cross-stmt
6207        // edges resolve. A base-table pivot source falls through the
6208        // non-subquery early-return below unchanged.
6209        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
6210                && t.getPivotedTable() != null
6211                && !t.getPivotedTable().getRelations().isEmpty()) {
6212            t = t.getPivotedTable().getRelations().get(0);
6213            if (t == null) return;
6214        }
6215        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) return;
6216        // Alias presence/uniqueness already validated by the preflight.
6217        // Slice 74: anonymous (unaliased) subqueries get a synth name
6218        // from FromSubqueryNaming via effectiveAliasOf so the alias used
6219        // for the aliasToIndex map and inner-stmt name is non-null.
6220        String alias = effectiveAliasOf(t);
6221        String aliasLower = alias.toLowerCase(Locale.ROOT);
6222        TSelectSqlStatement inner = t.getSubquery();
6223        if (inner == null) {
6224            throw new SemanticIRBuildException(
6225                    Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_NO_INNER_SELECT,
6226                    "FROM-clause subquery '" + alias + "' has no inner SELECT", (TParseTreeNode) null));
6227        }
6228        // Slice 17 leak guard: predicate subqueries inside the
6229        // FROM-subquery body's WHERE / JOIN ON / GROUP BY would
6230        // otherwise slip past `allowScalarProjectionSubqueries=false`
6231        // (which only guards buildOutputColumns) and leak inner refs
6232        // into the body's filter/join/group ref lists.
6233        rejectSubqueriesInFromSubqueryBodyClauses(inner, alias);
6234        // Recurse into the inner's own FROM-subqueries first so each
6235        // deeper body lands in stmts BEFORE the body that consumes it.
6236        // The recursive call uses `consumerProvider` because the inner
6237        // sees the same CTE-name set as the outer (CTEs are visible
6238        // through FROM-subquery bodies — pinned by
6239        // Slice5Test.cteVisibleInsideFromSubquery).
6240        // Slice 60: thread ctePublishedColumns down unchanged. The
6241        // inner's siblings get registered into innerSubAliasToIndex
6242        // here; below we build the per-level innerInScope BEFORE
6243        // calling buildSelectStatement.
6244        Map<String, Integer> innerSubAliasToIndex =
6245                extractFromSubqueriesAsStatements(inner, consumerProvider,
6246                        stmts, lineage, cteNameToStatementIndex,
6247                        ctePublishedColumns);
6248        // Slice 60 (codex diff-review): build the inner FROM-subquery
6249        // body's effective-alias-keyed in-scope map by walking the
6250        // inner SELECT's FROM list. Sibling isolation is preserved
6251        // because `innerSubAliasToIndex` contains ONLY this body's
6252        // own children — ancestor siblings are never visited by the
6253        // walk because they're not in the inner's FROM list.
6254        Map<String, List<String>> innerInScope = buildEffectiveAliasInScopeMap(
6255                inner, consumerProvider, ctePublishedColumns,
6256                innerSubAliasToIndex, stmts);
6257        NameBindingProvider innerProviderWithStar = consumerProvider
6258                .withInScopeRelationColumns(innerInScope);
6259        // Slice 120 — switch from the 7-arg buildSelectStatement to the
6260        // 14-arg buildSelectStatementImpl so the FROM-subquery body's
6261        // WHERE clause can extract uncorrelated predicate subqueries
6262        // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
6263        // ANY-ALL-SOME) as their own statements (mirrors the slice-114
6264        // CTE-body lift). JOIN-ON predicate subqueries stay rejected (the
6265        // two flags are independent per the slice-113 split) — the
6266        // slice-17 leak guard rejectSubqueriesInFromSubqueryBodyClauses
6267        // above still fires for the body's JOIN-ON / GROUP-BY clauses.
6268        // allowFromSubqueries=true so its buildRelations accepts
6269        // already-extracted subquery aliases; allowScalarProjectionSubqueries
6270        // =false (slice-15 invariant). The snapshot/rollback wrapper
6271        // mirrors the slice-114 CTE-body call site: if the build appends
6272        // predicate bodies and then a later reject fires, stmts/lineage
6273        // truncate back to the pre-call boundary so a partial extraction
6274        // does not leak into the program. processDirectSubqueryTable is
6275        // shared by the SELECT / UPDATE (slice 83) / DELETE (slice 84)
6276        // FROM-subquery extractors, so this single site lifts all three.
6277        int fromBodyStmtsSnapshot = stmts.size();
6278        int fromBodyLineageSnapshot = lineage.size();
6279        StatementGraph innerStmt;
6280        try {
6281            if (isPivotSelect(inner)) {
6282                // Slice 138: a nested PIVOT / UNPIVOT in a FROM-subquery body
6283                // (`FROM (SELECT ... FROM base PIVOT(...)) t`). The outer-SELECT
6284                // pivot router in buildSelectStatementImpl is gated to
6285                // `name == null`, so a pivot body extracted here (name == alias)
6286                // would otherwise fall to the normal path and reject with
6287                // TABLE_BINDING_UNRESOLVED ("null(piviot_table)"). Route it to
6288                // buildPivotSelect with the subquery alias as the statement name
6289                // so the extracted statement becomes a proper pivot StatementGraph;
6290                // the outer query then references it as a SUBQUERY-kind relation
6291                // and the slice-136 emitLineageForStatement call below wires the
6292                // pivot output → source edges (base-table TABLE_COLUMN, or — when
6293                // the pivot's own source is a subquery — STATEMENT_OUTPUT via the
6294                // innerSubAliasToIndex already populated by the recursion above).
6295                // PIVOT extra-clause / chained / set-op-branch nesting stay
6296                // deferred via buildPivotSelect's existing rejects.
6297                innerStmt = buildPivotSelect(inner, innerProviderWithStar, alias);
6298            } else {
6299                innerStmt = buildSelectStatementImpl(inner, innerProviderWithStar, alias,
6300                    /*hasOuterCteListAlreadyProcessed=*/ false,
6301                    /*allowFromSubqueries=*/ true,
6302                    /*allowScalarProjectionSubqueries=*/ false,
6303                    /*allowWindowProjection=*/ true,
6304                    /*allowJoinOnPredicateSubqueries=*/ false,
6305                    /*stmtsForExtraction=*/ stmts,
6306                    /*lineageForExtraction=*/ lineage,
6307                    /*cteMapForExtraction=*/ cteNameToStatementIndex,
6308                    /*isPredicateBody=*/ false,
6309                    /*whereClauseContext=*/ PredicateClauseContext.FROM_SUBQUERY_BODY_WHERE,
6310                    /*allowWherePredicateSubqueries=*/ true);
6311            }
6312        } catch (RuntimeException ex) {
6313            while (stmts.size() > fromBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
6314            while (lineage.size() > fromBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
6315            throw ex;
6316        }
6317        int idx = stmts.size();
6318        stmts.add(innerStmt);
6319        aliasToIndex.put(aliasLower, idx);
6320        // Emit lineage with the inner's own subquery alias map (so
6321        // STATEMENT_OUTPUT → STATEMENT_OUTPUT edges target the inner's
6322        // children, not the outer's). Scalar map stays empty because
6323        // FROM-subquery bodies still reject scalar projections
6324        // (slice-15 invariant).
6325        emitLineageForStatement(innerStmt, idx, lineage,
6326                cteNameToStatementIndex,
6327                innerSubAliasToIndex,
6328                Collections.<Integer, ScalarInfo>emptyMap());
6329    }
6330
6331    /**
6332     * Slice 17 leak guard: reject subqueries inside a FROM-subquery
6333     * body's JOIN ON / GROUP BY clauses. Mirrors
6334     * {@link #rejectSubqueriesInScalarBodyClauses} (slice 11) — the
6335     * {@code allowScalarProjectionSubqueries=false} flag only guards
6336     * {@code buildOutputColumns}, so without this helper a SQL like
6337     * {@code SELECT id FROM (SELECT id FROM e JOIN d ON e.x = d.x
6338     * AND EXISTS (...)) sub} would leak the EXISTS subquery's refs into
6339     * the body's join column refs via {@code collectColumnRefs}.
6340     * HAVING / ORDER BY subqueries are caught by the slice-9 / 10
6341     * deep-scan rejecters inside {@code buildSelectStatementImpl}.
6342     *
6343     * <p>Slice 120 — the WHERE branch was removed: uncorrelated WHERE-side
6344     * predicate subqueries in a FROM-subquery body are now extracted as
6345     * their own statements by {@code buildSelectStatementImpl} via
6346     * {@link PredicateClauseContext#FROM_SUBQUERY_BODY_WHERE} (see
6347     * {@link #processDirectSubqueryTable}). {@code FROM_SUBQUERY_INNER_SUBQUERY_IN_WHERE}
6348     * stays declared-but-unreached for public-API stability (slice
6349     * 71/72/82/86/95/101/.../114 retain-for-documentation precedent).
6350     */
6351    private static void rejectSubqueriesInFromSubqueryBodyClauses(
6352            TSelectSqlStatement inner, String fromAlias) {
6353        if (inner.joins != null) {
6354            for (TJoin join : inner.joins) {
6355                TJoinItemList items = join.getJoinItems();
6356                if (items == null) continue;
6357                for (int i = 0; i < items.size(); i++) {
6358                    TJoinItem item = items.getJoinItem(i);
6359                    TExpression onCond = item == null ? null : item.getOnCondition();
6360                    if (onCond != null && containsAnySubqueryExpression(onCond)) {
6361                        throw new SemanticIRBuildException(
6362                                Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_INNER_SUBQUERY_IN_JOIN_ON,
6363                                "FROM-clause subquery '" + fromAlias
6364                                        + "' has a subquery in a JOIN ON clause; not supported yet "
6365                                        + "(would leak inner refs)", (TParseTreeNode) null));
6366                    }
6367                }
6368            }
6369        }
6370        TGroupBy groupBy = inner.getGroupByClause();
6371        if (groupBy != null) {
6372            TGroupByItemList items = groupBy.getItems();
6373            if (items != null && containsAnySubquery(items)) {
6374                throw new SemanticIRBuildException(
6375                        Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_INNER_SUBQUERY_IN_GROUP_BY,
6376                        "FROM-clause subquery '" + fromAlias
6377                                + "' has a subquery in a GROUP BY clause; not supported yet "
6378                                + "(would leak inner refs)", (TParseTreeNode) null));
6379            }
6380        }
6381    }
6382
6383    /**
6384     * Walk the consuming SELECT's result-column list. For every
6385     * top-level {@link EExpressionType#subquery_t} projection, build
6386     * the inner SELECT as its own {@link StatementGraph} (mirroring
6387     * slice 5 FROM-subquery extraction), append it to {@code stmts},
6388     * emit its own lineage edges, and record
6389     * {@code resultColumnOrdinal → ScalarInfo} so the consumer's
6390     * {@code emitLineageForStatement} can wire the
6391     * STATEMENT_OUTPUT → STATEMENT_OUTPUT edge.
6392     *
6393     * <p>Slice 11 disallows: scalar subqueries with no outer alias,
6394     * inner SELECTs that project more than one column, inner columns
6395     * with no alias and no direct column name, scalar subqueries
6396     * whose inner WHERE/JOIN/GROUP BY contains a subquery (predicate
6397     * leak guard), correlated scalar subqueries (inner refs that
6398     * resolve to outer aliases), and nested scalar subqueries.
6399     *
6400     * <p>Slice 14 lifted correlated TABLE-bound; slice 15 lifted CTE-
6401     * and SUBQUERY-bound. Slice 20 lifts <i>nested</i> scalar
6402     * projections inside a scalar body when the
6403     * {@code allowRecursiveScalarSubqueryExtraction} flag is true (passed
6404     * by the outer-build and CTE-body call sites). Set-op-branch call
6405     * sites pass false to keep the slice-12 / slice-16 boundary that
6406     * branch scalar bodies must not host another scalar projection.
6407     *
6408     * <p>Slice 20 wraps the body in a snapshot/rollback so a deeper
6409     * level's failure does not leak appended scalar-body statements at
6410     * shallower levels into {@code stmts}/{@code lineage}. The wrapper
6411     * mirrors slice-16's {@code buildSetOpProgram} and slice-17/18's
6412     * extract wrappers (§14.18 process lesson #21).
6413     */
6414    private static Map<Integer, ScalarInfo> extractScalarSubqueriesAsStatements(
6415            TSelectSqlStatement consumer,
6416            NameBindingProvider consumerProvider,
6417            List<StatementGraph> stmts,
6418            List<LineageEdge> lineage,
6419            Map<String, Integer> cteNameToStatementIndex,
6420            EnclosingScope enclosingScope,
6421            boolean allowRecursiveScalarSubqueryExtraction) {
6422        // Slice 20: SET-OP-WIDE-style transactional rollback. A failure
6423        // anywhere inside the loop (or inside a recursive call) truncates
6424        // both lists back to the pre-extraction size. The wrapper closes
6425        // the class of "mutation-free check fires after partial mutation"
6426        // (codex round-3..5 finding on slice 16); the recursive scalar
6427        // extraction surfaced it here.
6428        int stmtsSnapshot = stmts.size();
6429        int lineageSnapshot = lineage.size();
6430        try {
6431            return extractScalarSubqueriesAsStatementsInternal(consumer,
6432                    consumerProvider, stmts, lineage,
6433                    cteNameToStatementIndex, enclosingScope,
6434                    allowRecursiveScalarSubqueryExtraction);
6435        } catch (RuntimeException ex) {
6436            while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1);
6437            while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1);
6438            throw ex;
6439        }
6440    }
6441
6442    /**
6443     * Internal body of {@link #extractScalarSubqueriesAsStatements}.
6444     * Wrapped with snapshot/rollback by the public entry point; do not
6445     * call directly from non-wrapper sites.
6446     */
6447    private static Map<Integer, ScalarInfo> extractScalarSubqueriesAsStatementsInternal(
6448            TSelectSqlStatement consumer,
6449            NameBindingProvider consumerProvider,
6450            List<StatementGraph> stmts,
6451            List<LineageEdge> lineage,
6452            Map<String, Integer> cteNameToStatementIndex,
6453            EnclosingScope enclosingScope,
6454            boolean allowRecursiveScalarSubqueryExtraction) {
6455        Map<Integer, ScalarInfo> ordinalToInfo = new HashMap<>();
6456        TResultColumnList rcl = consumer.getResultColumnList();
6457        if (rcl == null || rcl.size() == 0) return ordinalToInfo;
6458
6459        // Reject duplicate output aliases when a scalar projection is
6460        // present (codex impl-review round-1 SHOULD 1). Lineage refs are
6461        // keyed by (statementIndex, outputName); two outputs sharing
6462        // the same name would collapse their lineage chains and
6463        // silently merge the scalar dependency with another column's
6464        // dependency. The slice-11 boundary is the cleanest place to
6465        // enforce this since the issue is most acute when a scalar
6466        // body's STATEMENT_OUTPUT → STATEMENT_OUTPUT edge is in play.
6467        boolean hasScalar = false;
6468        for (int i = 0; i < rcl.size(); i++) {
6469            TResultColumn rc = rcl.getResultColumn(i);
6470            if (rc != null && rc.getExpr() != null
6471                    && rc.getExpr().getExpressionType() == EExpressionType.subquery_t) {
6472                hasScalar = true;
6473                break;
6474            }
6475        }
6476        if (hasScalar) {
6477            Set<String> seenOutputNames = new HashSet<>();
6478            for (int i = 0; i < rcl.size(); i++) {
6479                TResultColumn rc = rcl.getResultColumn(i);
6480                if (rc == null) continue;
6481                String alias = rc.getColumnAlias();
6482                String colName = rc.getColumnNameOnly();
6483                String name = (alias != null && !alias.isEmpty())
6484                        ? alias
6485                        : colName;
6486                if (name == null || name.isEmpty()) continue;
6487                String lower = name.toLowerCase(Locale.ROOT);
6488                if (!seenOutputNames.add(lower)) {
6489                    throw new SemanticIRBuildException(
6490                            Diagnostic.error(DiagnosticCode.DUPLICATE_OUTPUT_NAME,
6491                            "duplicate output name '" + name
6492                                    + "' in a SELECT containing a scalar subquery projection; "
6493                                    + "lineage refs are keyed by output name and would collide", rc));
6494                }
6495            }
6496        }
6497
6498        for (int i = 0; i < rcl.size(); i++) {
6499            TResultColumn rc = rcl.getResultColumn(i);
6500            if (rc == null || rc.getExpr() == null) continue;
6501            if (rc.getExpr().getExpressionType() != EExpressionType.subquery_t) {
6502                continue;
6503            }
6504            String outerAlias = rc.getColumnAlias();
6505            if (outerAlias == null || outerAlias.isEmpty()) {
6506                throw new SemanticIRBuildException(
6507                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_ALIAS_REQUIRED,
6508                        "scalar subquery projection must have an alias", rc));
6509            }
6510            TSelectSqlStatement inner = rc.getExpr().getSubQuery();
6511            if (inner == null) {
6512                throw new SemanticIRBuildException(
6513                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_NO_INNER_SELECT,
6514                        "scalar subquery projection '" + outerAlias
6515                                + "' has no inner SELECT", rc));
6516            }
6517            // Pre-recursion validation (codex round-2 MUST 2): inspect
6518            // the inner SELECT's projected column count and naming
6519            // BEFORE recursive build so the rejection message is
6520            // scalar-specific instead of bubbling up from
6521            // effectiveOutputName.
6522            TResultColumnList innerRcl = inner.getResultColumnList();
6523            if (innerRcl == null || innerRcl.size() == 0) {
6524                throw new SemanticIRBuildException(
6525                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT,
6526                        "scalar subquery '" + outerAlias
6527                                + "' must project exactly one column, got 0", rc));
6528            }
6529            if (innerRcl.size() != 1) {
6530                throw new SemanticIRBuildException(
6531                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT,
6532                        "scalar subquery '" + outerAlias
6533                                + "' must project exactly one column, got "
6534                                + innerRcl.size(), rc));
6535            }
6536            TResultColumn innerCol = innerRcl.getResultColumn(0);
6537            String innerAlias = innerCol.getColumnAlias();
6538            String innerColName = innerCol.getColumnNameOnly();
6539            boolean innerHasName =
6540                    (innerAlias != null && !innerAlias.isEmpty())
6541                            || (innerColName != null && !innerColName.isEmpty());
6542            if (!innerHasName && !isConstantExpression(innerCol.getExpr())) {
6543                throw new SemanticIRBuildException(
6544                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED,
6545                        "scalar subquery '" + outerAlias
6546                                + "' inner projection has no alias and no column name; "
6547                                + "add an explicit alias inside the subquery", rc));
6548            }
6549            // Pre-recursion deep-scan (codex round-3 MUST 5, round-4
6550            // MUST 1): reject nested predicate subqueries in the
6551            // scalar body's JOIN ON / GROUP BY before collectColumnRefs
6552            // can descend. Slice 121: the WHERE branch is no longer
6553            // rejected here (allowWherePredicateSubqueries=true) — the
6554            // scalar body's WHERE-side uncorrelated predicate subqueries
6555            // are extracted as their own statements by the
6556            // buildSelectStatementImpl call below via
6557            // PredicateClauseContext.SCALAR_BODY_WHERE.
6558            rejectSubqueriesInScalarBodyClauses(inner, outerAlias,
6559                    /*allowWherePredicateSubqueries=*/ true);
6560
6561            // Slice 20: branch on allowRecursiveScalarSubqueryExtraction.
6562            // - true (outer / CTE-body call sites): build the inner's
6563            //   own enclosing scope chained to this caller's; recursively
6564            //   extract the inner's scalar projections; then build the
6565            //   inner with allowScalarProjectionSubqueries=true;
6566            //   compute scalarName AFTER the recursive extraction so
6567            //   the digit suffix matches the post-extraction stmts.size()
6568            //   (slice-16 codex round-1 MUST 2 lesson).
6569            // - false (set-op-branch call site): keep the slice-12 /
6570            //   slice-16 boundary — no recursive extraction; the inner
6571            //   scalar map stays empty; the inner builds with
6572            //   allowScalarProjectionSubqueries=false; promotion still
6573            //   uses the caller's enclosing scope so OUTER_REFERENCE-of-*
6574            //   correlation works at the branch level.
6575            EnclosingScope innerEnclosing;
6576            Map<Integer, ScalarInfo> innerScalarMap;
6577            String scalarName;
6578            if (allowRecursiveScalarSubqueryExtraction) {
6579                innerEnclosing = buildEnclosingScope(inner,
6580                        cteNameToStatementIndex,
6581                        Collections.<String, Integer>emptyMap(),
6582                        enclosingScope);
6583                innerScalarMap = extractScalarSubqueriesAsStatements(inner,
6584                        consumerProvider, stmts, lineage,
6585                        cteNameToStatementIndex, innerEnclosing,
6586                        /*allowRecursiveScalarSubqueryExtraction=*/ true);
6587                scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">";
6588            } else {
6589                innerEnclosing = enclosingScope;
6590                innerScalarMap = Collections.<Integer, ScalarInfo>emptyMap();
6591                scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">";
6592            }
6593            // Slice 121 — switch from the 7-arg buildSelectStatement to
6594            // the 14-arg buildSelectStatementImpl so the scalar body's
6595            // WHERE clause can extract uncorrelated predicate subqueries
6596            // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
6597            // ANY-ALL-SOME) as their own <predicate_subquery_<i>>
6598            // statements (mirrors the slice-113 set-op branch / slice-120
6599            // FROM-subquery body lift). allowJoinOnPredicateSubqueries=false
6600            // keeps JOIN-ON predicate subqueries rejected (the two flags
6601            // are independent per the slice-113 split; the slice-11 leak
6602            // guard above still fires for the body's JOIN-ON / GROUP-BY).
6603            // allowScalarProjectionSubqueries stays gated on
6604            // allowRecursiveScalarSubqueryExtraction (slice-15/16 invariant).
6605            // stmts/lineage/cteMap are threaded for extraction; the
6606            // enclosing extractScalarSubqueriesAsStatements wrapper is the
6607            // snapshot/rollback boundary (no per-site try/catch needed —
6608            // slice-113 enclosed-by-buildSetOpProgram precedent).
6609            // preBuildScalarSize == the digit baked into scalarName above
6610            // (nothing mutates stmts between the scalarName assignment and
6611            // here), so `idx != preBuildScalarSize` below is exactly "the
6612            // build appended predicate bodies" without re-parsing the name.
6613            int preBuildScalarSize = stmts.size();
6614            StatementGraph innerStmt = buildSelectStatementImpl(inner, consumerProvider,
6615                    scalarName,
6616                    /*hasOuterCteListAlreadyProcessed=*/ false,
6617                    /*allowFromSubqueries=*/ false,
6618                    /*allowScalarProjectionSubqueries=*/ allowRecursiveScalarSubqueryExtraction,
6619                    /*allowWindowProjection=*/ false,
6620                    /*allowJoinOnPredicateSubqueries=*/ false,
6621                    /*stmtsForExtraction=*/ stmts,
6622                    /*lineageForExtraction=*/ lineage,
6623                    /*cteMapForExtraction=*/ cteNameToStatementIndex,
6624                    /*isPredicateBody=*/ false,
6625                    /*whereClauseContext=*/ PredicateClauseContext.SCALAR_BODY_WHERE,
6626                    /*allowWherePredicateSubqueries=*/ true);
6627            // Slice 14: instead of rejecting correlated scalar
6628            // subqueries, promote outer-scope refs into synthesised
6629            // OUTER_REFERENCE relations on the inner statement.
6630            // Non-TABLE-bound outer refs (CTE / SUBQUERY) and
6631            // unknown aliases still throw. (This projection path uses the
6632            // innerEnclosing scope for promotion; the UPDATE SET-RHS scalar
6633            // path in extractScalarSubqueriesFromUpdateSetRhs instead uses
6634            // withTolerantOuterBinding because UPDATE has no enclosing-scope
6635            // promotion — slices 117/119. The two are equivalent for
6636            // uncorrelated bodies; do not cross-port one onto the other.)
6637            // promoteCorrelatedRefsToOuterReference returns a new
6638            // StatementGraph and does NOT append to stmts, so reading
6639            // idx after it is equivalent to reading it right after the
6640            // build (it only reflects predicate bodies the build appended).
6641            innerStmt = promoteCorrelatedRefsToOuterReference(
6642                    innerStmt, outerAlias, innerEnclosing);
6643            int idx = stmts.size();
6644            // Slice 121 — if the WHERE predicate-body extraction appended
6645            // statements during the build, the scalar body's final index
6646            // exceeds the value baked into scalarName (computed before the
6647            // build). Rebuild with the corrected <scalar_subquery_<idx>>
6648            // name so the slice-12/16 name↔index invariant survives
6649            // (slice-113 set-op-branch withRenamedTo precedent). LineageRefs
6650            // are idx-based, not name-based, so the rename is cosmetic.
6651            if (idx != preBuildScalarSize) {
6652                innerStmt = withRenamedTo(innerStmt, SCALAR_BODY_PREFIX + idx + ">");
6653            }
6654            stmts.add(innerStmt);
6655            String innerOutName = effectiveOutputName(innerCol);
6656            ordinalToInfo.put(i, new ScalarInfo(idx, innerOutName));
6657            // Slice 20: pass the chained subquery alias map so
6658            // OUTER_REFERENCE-of-SUBQUERY refs in deeply nested scalar
6659            // bodies resolve through ancestor FROM-subquery aliases.
6660            // Pass innerScalarMap (non-empty when recursive extraction
6661            // is allowed) so the inner's own STATEMENT_OUTPUT →
6662            // STATEMENT_OUTPUT edges land.
6663            emitLineageForStatement(innerStmt, idx, lineage,
6664                    cteNameToStatementIndex,
6665                    innerEnclosing.flattenSubqueryAliasToIndex(),
6666                    innerScalarMap);
6667        }
6668        return ordinalToInfo;
6669    }
6670
6671    /**
6672     * Reject nested predicate subqueries inside a scalar body's
6673     * WHERE / JOIN ON / GROUP BY clauses. Scalar bodies are slice-11
6674     * scope; the pre-existing builder leaks predicate-subquery refs
6675     * into {@code filterColumnRefs}/etc. elsewhere, but the scalar-body
6676     * recursion path is guarded so slice-11 outputs stay clean.
6677     */
6678    private static void rejectSubqueriesInScalarBodyClauses(
6679            TSelectSqlStatement inner, String outerAlias,
6680            boolean allowWherePredicateSubqueries) {
6681        // Slice 121 — the WHERE branch is gated. The projection scalar
6682        // path passes {@code allowWherePredicateSubqueries=true} so its
6683        // WHERE-side uncorrelated predicate subqueries are instead
6684        // extracted as their own {@code <predicate_subquery_<i>>}
6685        // statements by {@code buildSelectStatementImpl} via
6686        // {@link PredicateClauseContext#SCALAR_BODY_WHERE} (see
6687        // {@link #extractScalarSubqueriesAsStatementsInternal}). The
6688        // UPDATE SET-RHS scalar path
6689        // ({@link #extractScalarSubqueriesFromUpdateSetRhs}) keeps passing
6690        // {@code false} so its WHERE subqueries still reject with
6691        // {@code SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE} (the code stays
6692        // reached). JOIN-ON and GROUP-BY always reject (slice 11/23/26).
6693        TWhereClause where = inner.getWhereClause();
6694        if (!allowWherePredicateSubqueries
6695                && where != null && containsAnySubquery(where)) {
6696            throw new SemanticIRBuildException(
6697                    Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE,
6698                    "scalar subquery '" + outerAlias
6699                            + "' has a subquery in its WHERE clause; not supported yet "
6700                            + "(would leak inner refs)", (TParseTreeNode) null));
6701        }
6702        if (inner.joins != null) {
6703            for (TJoin join : inner.joins) {
6704                TJoinItemList items = join.getJoinItems();
6705                if (items == null) continue;
6706                for (int i = 0; i < items.size(); i++) {
6707                    TJoinItem item = items.getJoinItem(i);
6708                    TExpression onCond = item == null ? null : item.getOnCondition();
6709                    if (onCond != null && containsAnySubqueryExpression(onCond)) {
6710                        throw new SemanticIRBuildException(
6711                                Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_JOIN_ON,
6712                                "scalar subquery '" + outerAlias
6713                                        + "' has a subquery in a JOIN ON clause; not supported yet "
6714                                        + "(would leak inner refs)", (TParseTreeNode) null));
6715                    }
6716                }
6717            }
6718        }
6719        TGroupBy groupBy = inner.getGroupByClause();
6720        if (groupBy != null) {
6721            TGroupByItemList items = groupBy.getItems();
6722            if (items != null && containsAnySubquery(items)) {
6723                throw new SemanticIRBuildException(
6724                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_GROUP_BY,
6725                        "scalar subquery '" + outerAlias
6726                                + "' has a subquery in a GROUP BY clause; not supported yet "
6727                                + "(would leak inner refs)", (TParseTreeNode) null));
6728            }
6729        }
6730        // HAVING / ORDER BY subqueries are caught by the slice-9 / 10
6731        // deep-scan rejecters that fire during buildSelectStatement.
6732    }
6733
6734    /**
6735     * True iff any descendant of {@code root} is a {@link TExpression}
6736     * with {@code subquery_t} type or a non-null
6737     * {@link TExpression#getSubQuery()}. Mirrors the slice 9/10 pattern.
6738     */
6739    private static boolean containsAnySubquery(gudusoft.gsqlparser.nodes.TParseTreeNode root) {
6740        final boolean[] found = {false};
6741        root.acceptChildren(new TParseTreeVisitor() {
6742            @Override
6743            public void preVisit(TExpression e) {
6744                if (found[0]) return;
6745                if (e.getExpressionType() == EExpressionType.subquery_t
6746                        || e.getSubQuery() != null) {
6747                    found[0] = true;
6748                }
6749            }
6750        });
6751        return found[0];
6752    }
6753
6754    /** Same as {@link #containsAnySubquery} but also checks the root expression itself. */
6755    private static boolean containsAnySubqueryExpression(TExpression root) {
6756        if (root.getExpressionType() == EExpressionType.subquery_t
6757                || root.getSubQuery() != null) {
6758            return true;
6759        }
6760        return containsAnySubquery(root);
6761    }
6762
6763    /**
6764     * Slice 119 — collect every {@code subquery_t} TExpression node that
6765     * is a direct (top-level) subquery in the compound expression {@code
6766     * root}, without descending into any found subquery's own body.
6767     *
6768     * <p>Uses {@link TExpression#acceptChildren} with a depth counter to
6769     * handle all expression subtypes (arithmetic, CASE, function args)
6770     * without manual branch enumeration. The depth counter increments on
6771     * every {@code subquery_t} preVisit and decrements on every postVisit,
6772     * so nested subqueries inside a found body (e.g. an EXISTS inside a
6773     * scalar's WHERE) are tracked but NOT added to the result set.
6774     *
6775     * <p>Result list is in traversal (preVisit) order for deterministic
6776     * statement-graph numbering across identical SQL texts.
6777     */
6778    private static List<TExpression> collectNestedSubqueryExpressions(TExpression root) {
6779        if (root == null) return Collections.<TExpression>emptyList();
6780        // Defensive: root itself is a subquery_t — callers of the
6781        // mixed-expression path should have already taken the slice-115
6782        // top-level branch, but guard anyway.
6783        if (root.getExpressionType() == EExpressionType.subquery_t) {
6784            return Collections.singletonList(root);
6785        }
6786        final List<TExpression> ordered = new ArrayList<>();
6787        final int[] depth = {0};
6788        root.acceptChildren(new TParseTreeVisitor() {
6789            @Override
6790            public void preVisit(TExpression e) {
6791                if (e.getExpressionType() == EExpressionType.subquery_t) {
6792                    if (depth[0] == 0) ordered.add(e); // top-level only
6793                    depth[0]++;
6794                }
6795            }
6796            @Override
6797            public void postVisit(TExpression e) {
6798                if (e.getExpressionType() == EExpressionType.subquery_t) {
6799                    depth[0]--;
6800                }
6801            }
6802        });
6803        return ordered;
6804    }
6805
6806    /**
6807     * Slice-14 enclosing-scope map for correlation promotion. Holds the
6808     * full outer {@link RelationSource} per alias visible from the
6809     * enclosing scope so {@link #promoteCorrelatedRefsToOuterReference}
6810     * can read the outer's binding kind and qualifiedName.
6811     *
6812     * <p>The map is keyed by lower-cased alias for case-insensitive
6813     * lookup. The synthesised OUTER_REFERENCE RelationSource's alias is
6814     * NOT taken from this map's value — it is taken from the inner
6815     * ref's spelling (see §4.3 of the slice-14 plan), because
6816     * {@link gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider}
6817     * records the inner ref's alias verbatim and case-sensitive
6818     * alias-equality elsewhere relies on that spelling.
6819     *
6820     * <p>Slice-15 adds {@link #subqueryAliasToIndex}: when a SUBQUERY-bound
6821     * outer alias is referenced from an inner correlated scalar, the
6822     * inner statement's lineage emission needs to look up the outer's
6823     * FROM-subquery body by alias to wire a STATEMENT_OUTPUT →
6824     * STATEMENT_OUTPUT edge. The map mirrors the outer's alias→index
6825     * map but is filtered to alias keys also present in
6826     * {@link #aliasLowerToOuter} as SUBQUERY entries so the two maps
6827     * cannot drift apart.
6828     *
6829     * <p>Slice-20 generalises slice-14/15's flat map into a chain of
6830     * ancestor scopes (the {@link #parent} field). Lookups walk innermost
6831     * → outermost via {@link #lookupAlias(String)}. The
6832     * {@link #flattenSubqueryAliasToIndex()} helper produces an innermost-
6833     * wins flattened map for {@code emitLineageForStatement} consumers
6834     * that resolve OUTER_REFERENCE-of-SUBQUERY through ancestor
6835     * FROM-subquery aliases. Worst-case asymptotic per build for a
6836     * scalar chain of depth D with K siblings per level is
6837     * {@code O(K · D²)}; in practice D ≤ 4-5 for human-written SQL so the
6838     * constant factor is negligible. If a real-world benchmark surfaces
6839     * the flatten as a hot path, the obvious fix is a memo keyed on
6840     * {@code EnclosingScope} identity — deferred until measured.
6841     */
6842    private static final class EnclosingScope {
6843        final Map<String, RelationSource> aliasLowerToOuter;
6844        final Map<String, Integer> subqueryAliasToIndex;
6845        /** Slice-20 chain: parent enclosing scope; null at the root. */
6846        final EnclosingScope parent;
6847
6848        EnclosingScope(Map<String, RelationSource> aliasLowerToOuter,
6849                       Map<String, Integer> subqueryAliasToIndex,
6850                       EnclosingScope parent) {
6851            this.aliasLowerToOuter = aliasLowerToOuter;
6852            this.subqueryAliasToIndex = subqueryAliasToIndex;
6853            this.parent = parent;
6854        }
6855
6856        static EnclosingScope empty() {
6857            return new EnclosingScope(
6858                    Collections.<String, RelationSource>emptyMap(),
6859                    Collections.<String, Integer>emptyMap(),
6860                    /*parent=*/ null);
6861        }
6862
6863        /**
6864         * Walk the chain innermost → outermost; first match wins
6865         * (shadowing). Defensive cycle guard via identity-keyed visited
6866         * set: the chain is a DAG by construction (parent links never
6867         * loop back), but the guard makes the invariant explicit.
6868         */
6869        RelationSource lookupAlias(String aliasLower) {
6870            EnclosingScope cur = this;
6871            Set<EnclosingScope> visited = Collections.newSetFromMap(
6872                    new IdentityHashMap<EnclosingScope, Boolean>());
6873            while (cur != null && visited.add(cur)) {
6874                RelationSource r = cur.aliasLowerToOuter.get(aliasLower);
6875                if (r != null) return r;
6876                cur = cur.parent;
6877            }
6878            return null;
6879        }
6880
6881        /**
6882         * Innermost-wins flatten of the SUBQUERY alias → body-index chain.
6883         * Ancestors contribute their own FROM-subquery alias maps; if both
6884         * a parent and a child define the same alias, the child wins
6885         * (innermost shadows outermost). Cycle guard mirrors
6886         * {@link #lookupAlias(String)}.
6887         */
6888        Map<String, Integer> flattenSubqueryAliasToIndex() {
6889            if (parent == null) return subqueryAliasToIndex;
6890            Deque<EnclosingScope> stack = new ArrayDeque<>();
6891            Set<EnclosingScope> visited = Collections.newSetFromMap(
6892                    new IdentityHashMap<EnclosingScope, Boolean>());
6893            EnclosingScope cur = this;
6894            while (cur != null && visited.add(cur)) {
6895                stack.push(cur);
6896                cur = cur.parent;
6897            }
6898            // Stack top = outermost. Pop outermost first, emit, then
6899            // innermost overwrites via put(). Result: innermost wins.
6900            Map<String, Integer> out = new LinkedHashMap<>();
6901            while (!stack.isEmpty()) {
6902                EnclosingScope s = stack.pop();
6903                out.putAll(s.subqueryAliasToIndex);
6904            }
6905            return out;
6906        }
6907    }
6908
6909    /**
6910     * Build an {@link EnclosingScope} for the consuming SELECT by walking
6911     * its {@link TSelectSqlStatement#tables} list (FROM relations) and
6912     * classifying each entry. Mirrors how
6913     * {@link Resolver2NameBindingProvider#bindRelation} would classify
6914     * the same TTable but produces a full RelationSource per alias so
6915     * the slice-14 promotion can read both kind and qualifiedName.
6916     *
6917     * <p>Slice 20: the {@code parent} parameter chains the new scope to an
6918     * enclosing one so a doubly-nested scalar body can resolve grandparent
6919     * aliases. Top-level callers pass {@code null}; recursive scalar
6920     * extraction passes the caller's enclosing scope.
6921     *
6922     * <p>Classification (precedence on collision: CTE name beats
6923     * base-table name):
6924     * <ul>
6925     *   <li>{@code TTable.getTableType() == subquery} AND alias matches
6926     *       a key in {@code subqueryAliasToIndex} → SUBQUERY-bound.</li>
6927     *   <li>{@code TTable.getName().toLowerCase()} matches a key in
6928     *       {@code cteNameToStatementIndex} → CTE-bound.</li>
6929     *   <li>Otherwise → TABLE-bound.</li>
6930     * </ul>
6931     */
6932    private static EnclosingScope buildEnclosingScope(TSelectSqlStatement consumer,
6933                                                      Map<String, Integer> cteNameToStatementIndex,
6934                                                      Map<String, Integer> subqueryAliasToIndex,
6935                                                      EnclosingScope parent) {
6936        if (consumer == null || consumer.tables == null || consumer.tables.size() == 0) {
6937            // Even an empty FROM contributes an empty scope so the chain's
6938            // shape reflects nesting depth uniformly.
6939            return new EnclosingScope(
6940                    Collections.<String, RelationSource>emptyMap(),
6941                    Collections.<String, Integer>emptyMap(),
6942                    parent);
6943        }
6944        Map<String, RelationSource> map = new LinkedHashMap<>();
6945        Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>();
6946        for (int i = 0; i < consumer.tables.size(); i++) {
6947            TTable t = consumer.tables.getTable(i);
6948            if (t == null) continue;
6949            // Slice 74: route anonymous FROM-subqueries through
6950            // effectiveAliasOf so the synth name (instead of the
6951            // literal "subquery" returned by t.getName()) flows into the
6952            // OUTER_REFERENCE scope chain.
6953            String aliasOrName = effectiveAliasOf(t);
6954            if (aliasOrName == null || aliasOrName.isEmpty()) continue;
6955            String lower = aliasOrName.toLowerCase(Locale.ROOT);
6956            if (map.containsKey(lower)) continue; // first-occurrence wins (defensive)
6957
6958            if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
6959                if (subqueryAliasToIndex != null && subqueryAliasToIndex.containsKey(lower)) {
6960                    map.put(lower, new RelationSource(aliasOrName,
6961                            new RelationBinding(RelationKind.SUBQUERY, aliasOrName)));
6962                    // Slice 15: keep a parallel filtered map of alias→index
6963                    // so OUTER_REFERENCE-of-SUBQUERY emit-side dispatch can
6964                    // resolve the outer body's statement index.
6965                    filteredSubqueryAliasToIndex.put(lower, subqueryAliasToIndex.get(lower));
6966                }
6967                continue;
6968            }
6969            if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) {
6970                continue; // function / rowList / etc. — not modelled
6971            }
6972            String name = t.getName();
6973            if (name == null || name.isEmpty()) continue;
6974            String nameLower = name.toLowerCase(Locale.ROOT);
6975            if (cteNameToStatementIndex != null
6976                    && cteNameToStatementIndex.containsKey(nameLower)) {
6977                map.put(lower, new RelationSource(aliasOrName,
6978                        new RelationBinding(RelationKind.CTE, name)));
6979            } else {
6980                map.put(lower, new RelationSource(aliasOrName,
6981                        new RelationBinding(RelationKind.TABLE, name)));
6982            }
6983        }
6984        return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent);
6985    }
6986
6987    /**
6988     * Slice 117 — sibling to {@link #buildEnclosingScope} for UPDATE
6989     * SET-RHS scalar-subquery bodies. The UPDATE outer scope is the
6990     * target table (TABLE-bound) plus any FROM-side relations walked
6991     * via {@code update.getJoins()} (TABLE / CTE / SUBQUERY-classified).
6992     *
6993     * <p>The target is added FIRST so on alias collisions with a
6994     * FROM-side relation (e.g. {@code UPDATE t ... FROM other t}) the
6995     * target wins. The {@code first-occurrence wins} rule mirrors
6996     * {@link #buildEnclosingScope}.
6997     *
6998     * <p>Used only by
6999     * {@link #extractScalarSubqueriesFromUpdateSetRhsInternal}.
7000     */
7001    private static EnclosingScope buildUpdateEnclosingScope(
7002            TUpdateSqlStatement update,
7003            Map<String, Integer> cteNameToStatementIndex,
7004            Map<String, Integer> subqueryAliasToIndex,
7005            EnclosingScope parent) {
7006        Map<String, RelationSource> map = new LinkedHashMap<>();
7007        Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>();
7008        if (update == null) {
7009            return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent);
7010        }
7011        // 1) Target — always TABLE-bound. effectiveAliasOf falls back to
7012        //    the table's own name when no alias is present.
7013        TTable target = update.getTargetTable();
7014        if (target != null
7015                && target.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
7016            String targetAlias = effectiveAliasOf(target);
7017            String targetName = target.getName();
7018            if (targetAlias != null && !targetAlias.isEmpty()
7019                    && targetName != null && !targetName.isEmpty()) {
7020                String aliasLower = targetAlias.toLowerCase(Locale.ROOT);
7021                map.put(aliasLower, new RelationSource(targetAlias,
7022                        new RelationBinding(RelationKind.TABLE, targetName)));
7023            }
7024        }
7025        // 2) FROM-side joins.
7026        TJoinList joins = update.getJoins();
7027        if (joins != null) {
7028            for (TJoin join : joins) {
7029                addUpdateRelationToEnclosingScope(join.getTable(), map,
7030                        filteredSubqueryAliasToIndex,
7031                        cteNameToStatementIndex, subqueryAliasToIndex);
7032                TJoinItemList items = join.getJoinItems();
7033                if (items == null) continue;
7034                for (int i = 0; i < items.size(); i++) {
7035                    TJoinItem item = items.getJoinItem(i);
7036                    if (item == null) continue;
7037                    addUpdateRelationToEnclosingScope(item.getTable(), map,
7038                            filteredSubqueryAliasToIndex,
7039                            cteNameToStatementIndex, subqueryAliasToIndex);
7040                }
7041            }
7042        }
7043        return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent);
7044    }
7045
7046    /**
7047     * Slice 117 — classify one FROM-side TTable for
7048     * {@link #buildUpdateEnclosingScope}. SUBQUERY-typed tables go to
7049     * {@link RelationKind#SUBQUERY} if their alias was registered in
7050     * {@code subqueryAliasToIndex} (slice-83 extraction map); objectname
7051     * tables go to {@link RelationKind#CTE} if their bare name is a
7052     * declared CTE, otherwise {@link RelationKind#TABLE}. First-
7053     * occurrence wins. Function / rowList sources are silently skipped
7054     * (not modelled).
7055     */
7056    private static void addUpdateRelationToEnclosingScope(TTable t,
7057            Map<String, RelationSource> map,
7058            Map<String, Integer> filteredSubqueryAliasToIndex,
7059            Map<String, Integer> cteNameToStatementIndex,
7060            Map<String, Integer> subqueryAliasToIndex) {
7061        if (t == null) return;
7062        String aliasOrName = effectiveAliasOf(t);
7063        if (aliasOrName == null || aliasOrName.isEmpty()) return;
7064        String lower = aliasOrName.toLowerCase(Locale.ROOT);
7065        if (map.containsKey(lower)) return; // first-occurrence wins
7066        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
7067            if (subqueryAliasToIndex != null
7068                    && subqueryAliasToIndex.containsKey(lower)) {
7069                map.put(lower, new RelationSource(aliasOrName,
7070                        new RelationBinding(RelationKind.SUBQUERY, aliasOrName)));
7071                filteredSubqueryAliasToIndex.put(lower,
7072                        subqueryAliasToIndex.get(lower));
7073            }
7074            return;
7075        }
7076        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) {
7077            return; // function / rowList / etc. — not modelled
7078        }
7079        String name = t.getName();
7080        if (name == null || name.isEmpty()) return;
7081        String nameLower = name.toLowerCase(Locale.ROOT);
7082        if (cteNameToStatementIndex != null
7083                && cteNameToStatementIndex.containsKey(nameLower)) {
7084            map.put(lower, new RelationSource(aliasOrName,
7085                    new RelationBinding(RelationKind.CTE, name)));
7086        } else {
7087            map.put(lower, new RelationSource(aliasOrName,
7088                    new RelationBinding(RelationKind.TABLE, name)));
7089        }
7090    }
7091
7092    /**
7093     * Slice 118 — sibling to {@link #buildUpdateEnclosingScope} for MERGE
7094     * per-WHEN action WHERE correlated predicate subqueries. Builds the
7095     * enclosing scope's relation map covering MERGE's target table, USING
7096     * source, and any outer CTEs declared on the MERGE itself. Used only
7097     * by {@link #collectMergeActionWhere}; produced once per MERGE in
7098     * {@link #buildMerge} and threaded through the predicate-subquery
7099     * extractor.
7100     *
7101     * <p>Classification mirrors {@code buildMerge} step 6:
7102     * <ul>
7103     *   <li>{@code merge.getTargetTable()} — TABLE-bound (qualifiedName =
7104     *       target's table name). First-occurrence wins.</li>
7105     *   <li>{@code merge.getUsingTable()}:
7106     *       <ul>
7107     *         <li>SUBQUERY-typed → SUBQUERY-bound with index pulled from
7108     *             {@code aliasToSubIdx}.</li>
7109     *         <li>objectname-typed AND name in
7110     *             {@code cteNameToStatementIndex} → CTE-bound (slice-101
7111     *             USING-as-CTE).</li>
7112     *         <li>Else objectname → TABLE-bound.</li>
7113     *       </ul></li>
7114     * </ul>
7115     */
7116    private static EnclosingScope buildMergeEnclosingScope(
7117            TMergeSqlStatement merge,
7118            Map<String, Integer> cteNameToStatementIndex,
7119            Map<String, Integer> aliasToSubIdx) {
7120        Map<String, RelationSource> map = new LinkedHashMap<>();
7121        Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>();
7122        if (merge == null) {
7123            return new EnclosingScope(map, filteredSubqueryAliasToIndex,
7124                    /*parent=*/ null);
7125        }
7126        // 1) Target — always TABLE-bound. effectiveAliasOf falls back to
7127        //    the table's own name when no alias is present.
7128        TTable target = merge.getTargetTable();
7129        if (target != null
7130                && target.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
7131            String targetAlias = effectiveAliasOf(target);
7132            String targetName = target.getName();
7133            if (targetAlias != null && !targetAlias.isEmpty()
7134                    && targetName != null && !targetName.isEmpty()) {
7135                String aliasLower = targetAlias.toLowerCase(Locale.ROOT);
7136                map.put(aliasLower, new RelationSource(targetAlias,
7137                        new RelationBinding(RelationKind.TABLE, targetName)));
7138            }
7139        }
7140        // 2) USING source.
7141        TTable using = merge.getUsingTable();
7142        if (using != null) {
7143            String usingAlias = effectiveAliasOf(using);
7144            // Fall back to the USING source's bare name when no alias is
7145            // present — matches buildMerge's `usingAlias` initialisation.
7146            if (usingAlias == null || usingAlias.isEmpty()) {
7147                usingAlias = (using.getName() == null
7148                        || using.getName().toString().isEmpty())
7149                        ? "__merge_using__"
7150                        : using.getName().toString();
7151            }
7152            String usingAliasLower = usingAlias.toLowerCase(Locale.ROOT);
7153            // First-occurrence wins (defensive — target's alias could
7154            // theoretically shadow if user named USING source identically;
7155            // matches buildUpdateEnclosingScope behaviour).
7156            if (!map.containsKey(usingAliasLower)) {
7157                if (using.getTableType()
7158                        == gudusoft.gsqlparser.ETableSource.subquery) {
7159                    if (aliasToSubIdx != null
7160                            && aliasToSubIdx.containsKey(usingAliasLower)) {
7161                        map.put(usingAliasLower, new RelationSource(usingAlias,
7162                                new RelationBinding(RelationKind.SUBQUERY,
7163                                        usingAlias)));
7164                        filteredSubqueryAliasToIndex.put(usingAliasLower,
7165                                aliasToSubIdx.get(usingAliasLower));
7166                    }
7167                } else if (using.getTableType()
7168                        == gudusoft.gsqlparser.ETableSource.objectname) {
7169                    String usingName = using.getName();
7170                    if (usingName != null && !usingName.isEmpty()) {
7171                        String usingNameLower =
7172                                usingName.toLowerCase(Locale.ROOT);
7173                        Integer cteIdx = (cteNameToStatementIndex == null)
7174                                ? null
7175                                : cteNameToStatementIndex.get(usingNameLower);
7176                        if (cteIdx != null) {
7177                            // Slice 101 USING-as-CTE branch — model as
7178                            // SUBQUERY-bound so the promoter classifies
7179                            // outerKind=SUBQUERY (mirrors the slice-117
7180                            // SUBQUERY classifier for UPDATE FROM-CTE
7181                            // sources, also via aliasToSubIdx).
7182                            map.put(usingAliasLower, new RelationSource(usingAlias,
7183                                    new RelationBinding(RelationKind.SUBQUERY,
7184                                            usingAlias)));
7185                            // The MERGE caller pre-populates aliasToSubIdx
7186                            // with the CTE's statement index for both
7187                            // usingAlias AND bare CTE name. Use whichever
7188                            // exists (aliasToSubIdx is keyed on lower-cased
7189                            // aliases — matches buildMerge step 6).
7190                            if (aliasToSubIdx != null
7191                                    && aliasToSubIdx.containsKey(usingAliasLower)) {
7192                                filteredSubqueryAliasToIndex.put(usingAliasLower,
7193                                        aliasToSubIdx.get(usingAliasLower));
7194                            }
7195                        } else {
7196                            map.put(usingAliasLower, new RelationSource(usingAlias,
7197                                    new RelationBinding(RelationKind.TABLE, usingName)));
7198                        }
7199                    }
7200                }
7201                // function / rowList sources are silently skipped (not
7202                // modelled — same convention as buildUpdateEnclosingScope).
7203            }
7204        }
7205        return new EnclosingScope(map, filteredSubqueryAliasToIndex,
7206                /*parent=*/ null);
7207    }
7208
7209    /**
7210     * Slice 117 — precompute the lowercased set of inner local FROM
7211     * aliases for a SELECT statement, used by the tolerant-outer-
7212     * binding fallback in
7213     * {@link gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider#bindColumn}.
7214     * Walks {@code select.tables} (which includes both the FROM driver
7215     * and any JOIN sides) and collects each table's effective alias.
7216     *
7217     * <p>Computed BEFORE the inner build's {@code buildRelations} JOIN-
7218     * ON collector runs so the tolerant provider is already scope-aware
7219     * when the collector first calls {@code bindColumn} (codex round-5
7220     * ordering fix).
7221     */
7222    private static Set<String> precomputeInnerLocalAliases(
7223            TSelectSqlStatement select) {
7224        Set<String> aliases = new HashSet<>();
7225        if (select == null || select.tables == null) return aliases;
7226        for (int i = 0; i < select.tables.size(); i++) {
7227            TTable t = select.tables.getTable(i);
7228            if (t == null) continue;
7229            String alias = effectiveAliasOf(t);
7230            if (alias != null && !alias.isEmpty()) {
7231                aliases.add(alias.toLowerCase(Locale.ROOT));
7232            }
7233        }
7234        return aliases;
7235    }
7236
7237    /**
7238     * Slice-14 correlation promotion (slice-15 extended). Walk every
7239     * column ref in the already-built inner statement; any ref whose
7240     * alias is NOT in the inner's local relations is "correlated."
7241     * Look it up in the enclosing scope:
7242     *
7243     * <ul>
7244     *   <li>Found AND TABLE / CTE / SUBQUERY-bound → synthesise an
7245     *       OUTER_REFERENCE RelationSource with {@code outerKind}
7246     *       set to the resolved outer kind, and add to
7247     *       {@code inner.relations}.</li>
7248     *   <li>Found AND UNION / UNKNOWN-bound → throw defensively
7249     *       (no current builder path produces these from a FROM
7250     *       relation).</li>
7251     *   <li>Not found anywhere → throw (Resolver2 should not give us
7252     *       such a ref; defensive).</li>
7253     * </ul>
7254     *
7255     * <p>The synthesised RelationSource's alias is the inner ref's
7256     * spelling (case-sensitive equality is preserved). The
7257     * qualifiedName comes from the outer's existing binding:
7258     * - TABLE: outer's table name.
7259     * - CTE: outer's CTE name (NOT alias — see
7260     *   {@code correlatedToCteBoundOuterWithCteAlias}).
7261     * - SUBQUERY: outer's alias (matching slice-14 SUBQUERY
7262     *   convention, where {@code buildEnclosingScope} sets
7263     *   {@code qualifiedName=aliasOrName}).
7264     * No lower-casing happens here — that happens at projector
7265     * emit-time per slice-1 convention. Multiple inner refs with
7266     * case-variant spellings inherit the same pre-existing slice-1
7267     * limitation.
7268     */
7269    private static StatementGraph promoteCorrelatedRefsToOuterReference(
7270            StatementGraph innerStmt, String outerAlias,
7271            EnclosingScope enclosingScope) {
7272        Set<String> innerAliasesLower = new HashSet<>();
7273        for (RelationSource r : innerStmt.getRelations()) {
7274            innerAliasesLower.add(r.getAlias().toLowerCase(Locale.ROOT));
7275        }
7276        // Per alias-lower: outer's RelationSource (for binding) and the
7277        // inner ref's exact alias spelling (for the synthesised alias).
7278        LinkedHashMap<String, RelationSource> outerByLower = new LinkedHashMap<>();
7279        LinkedHashMap<String, String> firstRefSpellingByLower = new LinkedHashMap<>();
7280
7281        for (ColumnRef ref : collectAllInnerRefs(innerStmt)) {
7282            String aliasLower = ref.getRelationAlias().toLowerCase(Locale.ROOT);
7283            if (innerAliasesLower.contains(aliasLower)) continue; // local — not correlated
7284            if (outerByLower.containsKey(aliasLower)) continue;   // already promoted
7285
7286            // Slice 20: chain-walking lookup. Walks the EnclosingScope
7287            // chain innermost → outermost; first match wins (shadowing
7288            // semantics). slice 14/15 used a single-level get(); slice
7289            // 20 generalises so inner-inner scalars can resolve
7290            // grandparent (and deeper) aliases.
7291            RelationSource outerRel = enclosingScope.lookupAlias(aliasLower);
7292            if (outerRel == null) {
7293                throw new SemanticIRBuildException(
7294                        Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS,
7295                        "scalar subquery '" + outerAlias
7296                                + "' references unknown alias '" + ref.getRelationAlias()
7297                                + "' (column '" + ref.getRelationAlias() + "."
7298                                + ref.getColumnName()
7299                                + "'); not in inner relations or any enclosing scope", (TParseTreeNode) null));
7300            }
7301            RelationKind outerKind = outerRel.getBinding().getKind();
7302            if (outerKind != RelationKind.TABLE
7303                    && outerKind != RelationKind.CTE
7304                    && outerKind != RelationKind.SUBQUERY) {
7305                throw new SemanticIRBuildException(
7306                        Diagnostic.error(DiagnosticCode.CORRELATED_SCALAR_SUBQUERY_UNKNOWN_OUTER_BINDING,
7307                        "correlated scalar subquery '" + outerAlias
7308                                + "' references outer alias '" + ref.getRelationAlias()
7309                                + "' bound to a " + outerKind
7310                                + "; only TABLE / CTE / SUBQUERY-bound outer correlations supported", (TParseTreeNode) null));
7311            }
7312            outerByLower.put(aliasLower, outerRel);
7313            firstRefSpellingByLower.put(aliasLower, ref.getRelationAlias());
7314        }
7315
7316        if (outerByLower.isEmpty()) return innerStmt;
7317
7318        List<RelationSource> augmented = new ArrayList<>(innerStmt.getRelations());
7319        for (String key : outerByLower.keySet()) {
7320            RelationSource outerRel = outerByLower.get(key);
7321            augmented.add(new RelationSource(
7322                    firstRefSpellingByLower.get(key),
7323                    new RelationBinding(RelationKind.OUTER_REFERENCE,
7324                            outerRel.getBinding().getQualifiedName(),
7325                            outerRel.getBinding().getKind())));
7326        }
7327        return rebuildStatementGraphWithRelations(innerStmt, augmented);
7328    }
7329
7330    /**
7331     * Collect every {@link ColumnRef} reachable from {@code innerStmt}'s
7332     * seven clause-bearing fields. Used by
7333     * {@link #promoteCorrelatedRefsToOuterReference} (slice-11 scalar
7334     * bodies) and the JOIN-ON EXISTS predicate-body correlation walker
7335     * (slice 23+); mirrors the old rejecter's clause coverage exactly
7336     * (output sources, filter, join, groupBy, having, orderBy, and
7337     * slice-73 distinctOn).
7338     */
7339    private static List<ColumnRef> collectAllInnerRefs(StatementGraph innerStmt) {
7340        List<ColumnRef> all = new ArrayList<>();
7341        for (OutputColumn out : innerStmt.getOutputColumns()) {
7342            all.addAll(out.getSources());
7343        }
7344        all.addAll(innerStmt.getFilterColumnRefs());
7345        all.addAll(innerStmt.getJoinColumnRefs());
7346        all.addAll(innerStmt.getGroupByColumnRefs());
7347        all.addAll(innerStmt.getHavingColumnRefs());
7348        all.addAll(innerStmt.getOrderByColumnRefs());
7349        all.addAll(innerStmt.getDistinctOnColumnRefs());
7350        return all;
7351    }
7352
7353    /**
7354     * Copy a {@link StatementGraph} replacing only its relations list.
7355     * StatementGraph is otherwise immutable.
7356     */
7357    private static StatementGraph rebuildStatementGraphWithRelations(
7358            StatementGraph stmt, List<RelationSource> relations) {
7359        return new StatementGraph(
7360                stmt.getName(),
7361                stmt.getKind(),
7362                relations,
7363                stmt.getOutputColumns(),
7364                stmt.getFilterColumnRefs(),
7365                stmt.getJoinColumnRefs(),
7366                stmt.getGroupByColumnRefs(),
7367                stmt.getHavingColumnRefs(),
7368                stmt.getOrderByColumnRefs(),
7369                stmt.getDistinctOnColumnRefs(),
7370                stmt.isDistinct(),
7371                stmt.getSetOperator(),
7372                stmt.getRowLimit())
7373                // Slice 180 (R5): preserve the block span the legacy ctor
7374                // would otherwise drop (this copy only swaps relations).
7375                .withSourceSpan(stmt.getSourceSpan());
7376    }
7377
7378    /**
7379     * Slice 16 preflight: reject any FROM-clause subquery directly on a
7380     * set-op branch's FROM/JOIN list, BEFORE
7381     * {@link #extractScalarSubqueriesAsStatements} can append scalar-body
7382     * statements to {@code stmts}/{@code lineage}. Without this preflight,
7383     * a branch with both a FROM-subquery and a scalar projection would
7384     * extract the scalar (mutating shared state) and then fail later in
7385     * {@code buildSelectStatement}, producing a confusing scalar-correlation
7386     * error message instead of the slice-12 FROM-subquery boundary.
7387     *
7388     * <p>Inspects only the branch's DIRECT FROM/JOIN entries — does NOT
7389     * recurse into result-column expressions or scalar-body inner SELECTs
7390     * (those are handled by the recursive scalar-body build's own
7391     * {@code allowFromSubqueries=false} guard). The error message contains
7392     * both {@code set-op branch} and {@code FROM-clause subquery} keywords
7393     * so the slice-12 boundary is surfaced explicitly.
7394     */
7395    private static void rejectFromSubqueriesInSetOpBranch(TSelectSqlStatement br) {
7396        if (br.joins == null) return;
7397        for (TJoin join : br.joins) {
7398            TTable left = join.getTable();
7399            if (left != null
7400                    && left.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
7401                throw new SemanticIRBuildException(
7402                        Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM,
7403                        "FROM-clause subquery directly in a set-op branch FROM is not "
7404                                + "supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null));
7405            }
7406            // Slice 140: a PIVOT/UNPIVOT branch whose SOURCE is a FROM-subquery
7407            // (`SELECT ... FROM (SELECT ...) PIVOT(...) UNION ALL ...`) is
7408            // deferred. The slice-138 `processDirectSubqueryTable` pre-extraction
7409            // is NOT called from the set-op branch path (set-op branches set
7410            // `allowFromSubqueries=false`), so routing such a shape to
7411            // `buildPivotSelect` would land in `buildRelation` on the inner
7412            // subquery TTable with empty `subqueryAliasToIndex` and emit a
7413            // less-informative second-step `TABLE_BINDING_UNRESOLVED` against
7414            // the inner subquery. Reuse the existing
7415            // `FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM` code with a discriminated
7416            // message; no new DiagnosticCode.
7417            if (left != null
7418                    && left.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
7419                    && left.getPivotedTable() != null
7420                    && !left.getPivotedTable().getRelations().isEmpty()
7421                    && left.getPivotedTable().getRelations().get(0) != null
7422                    && left.getPivotedTable().getRelations().get(0).getTableType()
7423                            == gudusoft.gsqlparser.ETableSource.subquery) {
7424                throw new SemanticIRBuildException(
7425                        Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM,
7426                        "FROM-clause subquery as a PIVOT source in a set-op branch is "
7427                                + "not supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null));
7428            }
7429            TJoinItemList items = join.getJoinItems();
7430            if (items == null) continue;
7431            for (int i = 0; i < items.size(); i++) {
7432                TTable r = items.getJoinItem(i).getTable();
7433                if (r != null
7434                        && r.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
7435                    throw new SemanticIRBuildException(
7436                            Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_ON_JOIN_SIDE_IN_SET_OP_BRANCH,
7437                            "FROM-clause subquery on a JOIN side in a set-op branch is "
7438                                    + "not supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null));
7439                }
7440            }
7441        }
7442    }
7443
7444    // ====================================================================
7445    // Slice 12: Set operations (UNION / INTERSECT / MINUS / EXCEPT).
7446    //
7447    // Algorithm: each branch of the set-op tree is built as its own
7448    // synthetically-named StatementGraph; the outer set-op statement has
7449    // empty relations and per-output `STATEMENT_OUTPUT → STATEMENT_OUTPUT`
7450    // lineage edges to each branch's corresponding output. The flatten
7451    // walks the left-leaning AST iteratively (CLAUDE.md mandates no
7452    // recursion on leftStmt/rightStmt; would StackOverflow on 2000+ UNIONs).
7453    //
7454    // Internal-node modifiers (ORDER BY / row-limits) are rejected on every
7455    // set-op node visited (root + internal), not just the root, because
7456    // parenthesized inner combined nodes can carry those modifiers (per
7457    // /tmp/SetOpInnerModifierProbe: `(A UNION B ORDER BY id) UNION C`
7458    // attaches ORDER BY to the inner node in Oracle / PostgreSQL / MSSQL;
7459    // PostgreSQL maps inner FETCH FIRST → LIMIT).
7460    // ====================================================================
7461
7462    /**
7463     * Build a set-op program: flatten branches, build each as its own
7464     * StatementGraph, construct the outer set-op statement, emit lineage.
7465     * Returns the outer set-op statement's index in {@code stmts}.
7466     *
7467     * @param setOp the {@link TSelectSqlStatement} carrying
7468     *     {@code setOperatorType != none}.
7469     * @param setOpName non-null when the set-op is a CTE body (the outer
7470     *     statement is named with the CTE name); null when the set-op is
7471     *     the program's top-level outer.
7472     * @param hasOuterCteListAlreadyProcessed true when the caller already
7473     *     processed the set-op root's CTE list (top-level dispatch);
7474     *     false when this is a recursive context (set-op CTE body) where
7475     *     a non-empty CTE list on the set-op root is rejected as a
7476     *     nested WITH.
7477     */
7478    private static int buildSetOpProgram(TSelectSqlStatement setOp,
7479                                         NameBindingProvider provider,
7480                                         List<StatementGraph> stmts,
7481                                         List<LineageEdge> lineage,
7482                                         Map<String, Integer> cteNameToStatementIndex,
7483                                         String setOpName,
7484                                         boolean hasOuterCteListAlreadyProcessed) {
7485        // Nested-WITH guard (rd-5 MUST 1): when called from a CTE body
7486        // context, the set-op root must not carry its own CTE list.
7487        if (!hasOuterCteListAlreadyProcessed
7488                && setOp.getCteList() != null
7489                && setOp.getCteList().size() > 0) {
7490            throw new SemanticIRBuildException(
7491                    Diagnostic.error(DiagnosticCode.NESTED_WITH_NOT_SUPPORTED,
7492                    "nested WITH/CTE inside a CTE body or subquery is not supported yet "
7493                            + "(set-op CTE body has its own CTE list)", (TParseTreeNode) null));
7494        }
7495        // Slice 21: ORDER BY is now collected from the outer set-op
7496        // (see buildSetOpOuterOrderByColumnRefs).
7497        // Slice 72: outer row-limit lifted via buildSetOpRowLimit.
7498        // The internal-node reject (parenthesized inner set-ops with
7499        // row-limit) fires inside flattenSetOpTreeIteratively. Compute
7500        // BEFORE the snapshot block so a defensive throw (Hive/Vertica/
7501        // ANSI-DB2 guards, MSSQL null-valued TOrderBy slots) propagates
7502        // without leaving stmts/lineage partially populated.
7503        RowLimit setOpRowLimit = buildSetOpRowLimit(setOp);
7504
7505        // Slice 16: SET-OP-WIDE TRANSACTIONAL ROLLBACK (codex rounds 3-5
7506        // adversarial findings). Snapshot `stmts.size()` and
7507        // `lineage.size()` BEFORE any branch mutation. On any
7508        // SemanticIRBuildException thrown by the per-branch loop or
7509        // post-build validation, truncate both lists back to the snapshot
7510        // and rethrow. This addresses the full class of "mutation-free
7511        // branch validation that fires after earlier-branch mutations":
7512        // FROM-subquery preflight (round 3), column-count check (round 4),
7513        // multi-scalar-projection per-branch validation (round 5), branch
7514        // duplicate-output-names check (round 5), and any future check
7515        // that may join the same class. The pre-loop preflight below
7516        // remains for fast-fail with better error messages on common
7517        // shapes, but the rollback is the safety net.
7518        int stmtsSnapshot = stmts.size();
7519        int lineageSnapshot = lineage.size();
7520        try {
7521            return buildSetOpProgramInternal(setOp, provider, stmts, lineage,
7522                    cteNameToStatementIndex, setOpName,
7523                    hasOuterCteListAlreadyProcessed, setOpRowLimit);
7524        } catch (RuntimeException e) {
7525            while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1);
7526            while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1);
7527            throw e;
7528        }
7529    }
7530
7531    /**
7532     * Internal body of {@link #buildSetOpProgram}. Wrapped with
7533     * snapshot/rollback by the public entry point; do not call directly.
7534     */
7535    private static int buildSetOpProgramInternal(TSelectSqlStatement setOp,
7536                                                  NameBindingProvider provider,
7537                                                  List<StatementGraph> stmts,
7538                                                  List<LineageEdge> lineage,
7539                                                  Map<String, Integer> cteNameToStatementIndex,
7540                                                  String setOpName,
7541                                                  boolean hasOuterCteListAlreadyProcessed,
7542                                                  RowLimit setOpRowLimit) {
7543
7544        SetOperator setOpKind = resolveSetOperator(setOp);
7545        List<TSelectSqlStatement> branches = flattenSetOpTreeIteratively(setOp, setOpKind);
7546        if (branches.size() < 2) {
7547            throw new SemanticIRBuildException(
7548                    Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COUNT_TOO_FEW,
7549                    "set-op flatten produced " + branches.size()
7550                            + " branches; expected at least 2", (TParseTreeNode) null));
7551        }
7552
7553        // Slice 16: PRE-LOOP PREFLIGHT (codex round-3 + round-4 adversarial
7554        // findings — medium). Run all mutation-free branch validation across
7555        // EVERY branch BEFORE the main build loop runs. Without this, a
7556        // scalar-bearing earlier branch can append scalar-body statements to
7557        // `stmts`/`lineage` BEFORE a later branch's rejection fires, leaving
7558        // partial state on the rejection path. The slice-16 safety claim is
7559        // "no half-built scalar bodies leak when a branch is rejected"; that
7560        // claim only holds when ALL mutation-free branch checks are
7561        // set-op-wide.
7562        //
7563        // Checks bundled here (each is AST-only and side-effect-free):
7564        //   - Defensive nested-set-op leaf check (slice 12).
7565        //   - Direct branch FROM-subquery rejection (slice 16 round 3).
7566        //   - Result-column-count compatibility across branches (slice 12,
7567        //     moved here in slice 16 round 4 — uses AST
7568        //     getResultColumnList().size() since BUILT outputColumns.size()
7569        //     is unavailable pre-build; the post-loop check on BUILT
7570        //     outputs stays as a defensive backup if AST-vs-built ever
7571        //     diverges for a future shape).
7572        int expectedAstCols = -1;
7573        for (int i = 0; i < branches.size(); i++) {
7574            TSelectSqlStatement br = branches.get(i);
7575            if (br.getSetOperatorType() != null
7576                    && br.getSetOperatorType() != ESetOperatorType.none) {
7577                throw new SemanticIRBuildException(
7578                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_IS_SET_OP,
7579                        "set-op branch is itself a set operation; nested set "
7580                                + "operations in branches are not supported yet", (TParseTreeNode) null));
7581            }
7582            rejectFromSubqueriesInSetOpBranch(br);
7583            int astCols = br.getResultColumnList() == null
7584                    ? 0
7585                    : br.getResultColumnList().size();
7586            if (i == 0) {
7587                expectedAstCols = astCols;
7588            } else if (astCols != expectedAstCols) {
7589                throw new SemanticIRBuildException(
7590                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COLUMN_COUNT_MISMATCH,
7591                        "set-op branch column-count mismatch: branch[0] has "
7592                                + expectedAstCols + " columns, branch[" + i + "] has "
7593                                + astCols, (TParseTreeNode) null));
7594            }
7595        }
7596
7597        int[] branchIdxs = new int[branches.size()];
7598        for (int i = 0; i < branches.size(); i++) {
7599            TSelectSqlStatement br = branches.get(i);
7600
7601            // Slice 16: per-branch enclosing scope. allowFromSubqueries
7602            // stays false at the branch level so subqueryAliasToIndex
7603            // is empty (no OUTER_REFERENCE-of-SUBQUERY in branches).
7604            // CTE-aware so a branch's scalar can correlate to a CTE
7605            // visible in scope (top-level set-ops have all outer CTEs
7606            // indexed; CTE-body set-ops have only prior visible CTEs
7607            // indexed — the current CTE is registered AFTER this method
7608            // returns, by design, mirroring the non-set-op CTE body path).
7609            EnclosingScope branchEnclosing = buildEnclosingScope(br,
7610                    cteNameToStatementIndex,
7611                    Collections.<String, Integer>emptyMap(),
7612                    /*parent=*/ null);
7613            // Slice 20: pass `false` so the slice-12 / slice-16 boundary
7614            // holds — branch scalar bodies must NOT host another scalar
7615            // projection. The branch's TOP-LEVEL scalar projection is
7616            // still allowed (slice 16); deeper recursion is not.
7617            Map<Integer, ScalarInfo> branchScalarMap =
7618                    extractScalarSubqueriesAsStatements(br, provider,
7619                            stmts, lineage, cteNameToStatementIndex, branchEnclosing,
7620                            /*allowRecursiveScalarSubqueryExtraction=*/ false);
7621
7622            // Slice 16: compute branchName AFTER scalar extraction so the
7623            // digit suffix in `<set_op_branch_<idx>>` matches the branch's
7624            // final position in `stmts`. Scalar bodies appended by
7625            // extractScalarSubqueriesAsStatements come BEFORE the branch
7626            // in `stmts`, so pre-extraction `stmts.size()` would be wrong
7627            // by (number of scalar bodies in this branch) — breaking the
7628            // slice-12 invariant that branch synthetic names round-trip
7629            // to their statement index.
7630            //
7631            // Slice 113 — predicate bodies extracted from the branch's
7632            // WHERE clause via {@link PredicateClauseContext#SET_OP_BRANCH_WHERE}
7633            // also land in {@code stmts} BEFORE the branch, INSIDE the
7634            // {@code buildSelectStatementImpl} call below. So the
7635            // pre-build {@code stmts.size()} can still understate the
7636            // branch's final position. The slice-12/16 invariant is
7637            // preserved by computing a tentative name pre-build (best
7638            // guess used by inner consumers that need a non-null name),
7639            // then rebuilding the StatementGraph with the corrected
7640            // name AFTER the build via {@link #withRenamedTo} if any
7641            // predicate body was extracted.
7642            int preBuildStmtsSize = stmts.size();
7643            String tentativeBranchName = SET_OP_BRANCH_PREFIX + preBuildStmtsSize + ">";
7644            StatementGraph branchStmt;
7645            if (isPivotSelect(br)) {
7646                // Slice 140: a nested PIVOT / UNPIVOT in a set-op branch
7647                // (`SELECT ... FROM base PIVOT(...) UNION ALL ...`). The
7648                // slice-129 pivot router in {@link #buildSelectStatementImpl}
7649                // is gated to `name == null && !isPredicateBody`; a set-op
7650                // branch carries the synthetic `<set_op_branch_N>` name, so
7651                // the gate misses and the branch would otherwise fall to the
7652                // normal path which can't bind a `pivoted_table` (rejects
7653                // with the misleading TABLE_BINDING_UNRESOLVED
7654                // "null(piviot_table)"). Route it to `buildPivotSelect` with
7655                // the synthetic branch name so the branch becomes a proper
7656                // pivot StatementGraph. PIVOT extra-clause / chained /
7657                // malformed-UNPIVOT / bare-`*`-without-catalog rejects stay
7658                // deferred via `buildPivotSelect`'s own guards. The
7659                // FROM-subquery-source case is pre-flight-rejected by the
7660                // extended {@link #rejectFromSubqueriesInSetOpBranch} above,
7661                // so `buildPivotSelect.buildRelation` never lands on an
7662                // unresolvable subquery TTable here. No
7663                // `withRenamedTo` rebuild block is needed below because
7664                // `buildPivotSelect` does not extract predicate bodies during
7665                // its build (its `rejectPivotExtraClauses` fires on WHERE /
7666                // GROUP BY / HAVING / ORDER BY / QUALIFY / DISTINCT /
7667                // row-limit / set-op).
7668                //
7669                // Direct analogue of slice 138 (FROM-subquery body via
7670                // `processDirectSubqueryTable`) and slice 139 (CTE body
7671                // via the four CTE walkers). This is the FIFTH and final
7672                // SemanticIRBuilder routing site that previously missed
7673                // the pivot router.
7674                branchStmt = buildPivotSelect(br, provider, tentativeBranchName);
7675            } else {
7676                branchStmt = buildSelectStatementImpl(br, provider, tentativeBranchName,
7677                        /*hasOuterCteListAlreadyProcessed=*/ false,
7678                        /*allowFromSubqueries=*/ false,
7679                        /*allowScalarProjectionSubqueries=*/ true,   // ← slice-16 lift
7680                        /*allowWindowProjection=*/ true,
7681                        // Slice 113 keeps JOIN-ON predicate subqueries rejected
7682                        // in set-op branches (slice 23 / 26 contract pinned by
7683                        // existsInSetOpBranchJoinOnStillRejected /
7684                        // lhsSubqueryInSetOpBranchRejected) — the lift is
7685                        // WHERE-only. The two flags are now independent.
7686                        /*allowJoinOnPredicateSubqueries=*/ false,
7687                        /*stmtsForExtraction=*/ stmts,               // ← slice-113
7688                        /*lineageForExtraction=*/ lineage,           // ← slice-113
7689                        /*cteMapForExtraction=*/ cteNameToStatementIndex, // ← slice-113
7690                        /*isPredicateBody=*/ false,
7691                        /*whereClauseContext=*/ PredicateClauseContext.SET_OP_BRANCH_WHERE,
7692                        /*allowWherePredicateSubqueries=*/ true);    // ← slice-113 lift
7693            }
7694            int idx = stmts.size();
7695            if (idx != preBuildStmtsSize) {
7696                // Slice 113 — predicate bodies were appended during
7697                // the branch build. Rebuild the StatementGraph with
7698                // the corrected name so the slice-12/16 invariant
7699                // (digit suffix == final position) survives. The
7700                // rebuild copies all 15 fields; no LineageRef is
7701                // affected because they are idx-based, not name-based
7702                // (codex round-1 Q4 resolution).
7703                String finalBranchName = SET_OP_BRANCH_PREFIX + idx + ">";
7704                branchStmt = withRenamedTo(branchStmt, finalBranchName);
7705            }
7706            rejectDuplicateOutputNames(branchStmt, branchStmt.getName());
7707            branchIdxs[i] = idx;
7708            stmts.add(branchStmt);
7709            // Branch's own per-output, filter, and join lineage. Pass
7710            // the branch's scalar map so STATEMENT_OUTPUT →
7711            // STATEMENT_OUTPUT edges to scalar bodies are emitted.
7712            // subqueryAliasToIndex stays empty (allowFromSubqueries=false
7713            // for branches, so no FROM-subquery aliases exist at this scope).
7714            emitLineageForStatement(branchStmt, idx, lineage,
7715                    cteNameToStatementIndex,
7716                    Collections.<String, Integer>emptyMap(),
7717                    branchScalarMap);
7718        }
7719
7720        // Validate column-count alignment via BUILT statements.
7721        int expectedCols = stmts.get(branchIdxs[0]).getOutputColumns().size();
7722        for (int i = 1; i < branches.size(); i++) {
7723            int n = stmts.get(branchIdxs[i]).getOutputColumns().size();
7724            if (n != expectedCols) {
7725                throw new SemanticIRBuildException(
7726                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COLUMN_COUNT_MISMATCH,
7727                        "set-op branch column-count mismatch: branch[0] has "
7728                                + expectedCols + " columns, branch[" + i + "] has " + n, (TParseTreeNode) null));
7729            }
7730        }
7731
7732        // Build outer outputs from branch[0]'s built outputs.
7733        StatementGraph branch0 = stmts.get(branchIdxs[0]);
7734        List<OutputColumn> outerOutputs = new ArrayList<>(expectedCols);
7735        Set<String> seenOuter = new HashSet<>();
7736        for (int i = 0; i < expectedCols; i++) {
7737            OutputColumn b0 = branch0.getOutputColumns().get(i);
7738            String name = b0.getName();
7739            if (name == null || name.isEmpty()) {
7740                throw new SemanticIRBuildException(
7741                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_OUTPUT_NAME_UNUSABLE,
7742                        "set-op output position " + i + " has no usable name in branch[0]; "
7743                                + "add an alias to the SELECT-list expression", (TParseTreeNode) null));
7744            }
7745            if (!seenOuter.add(name.toLowerCase(Locale.ROOT))) {
7746                throw new SemanticIRBuildException(
7747                        Diagnostic.error(DiagnosticCode.SET_OP_DUPLICATE_OUTER_OUTPUT_NAME,
7748                        "set-op outer output name '" + name + "' is duplicated "
7749                                + "(branch[0] has duplicate output names)", null));
7750            }
7751            outerOutputs.add(new OutputColumn(name,
7752                    /*derived=*/ true,
7753                    /*aggregate=*/ false,
7754                    /*sources=*/ Collections.<ColumnRef>emptyList(),
7755                    /*windowSpec=*/ null));
7756        }
7757
7758        // Slice 21: collect outer ORDER BY refs from branches' base sources.
7759        // A throw here unwinds via the slice-16 snapshot/rollback wrapper
7760        // in buildSetOpProgram(), so partially-built branches/scalar-bodies
7761        // do not leak into stmts/lineage on rejection.
7762        List<ColumnRef> outerOrderByRefs = buildSetOpOuterOrderByColumnRefs(
7763                setOp, outerOutputs, stmts, branchIdxs);
7764
7765        StatementGraph outer = new StatementGraph(setOpName, "SELECT",
7766                /*relations=*/ Collections.<RelationSource>emptyList(),
7767                outerOutputs,
7768                /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(),
7769                /*joinColumnRefs=*/  Collections.<ColumnRef>emptyList(),
7770                /*groupByColumnRefs=*/Collections.<ColumnRef>emptyList(),
7771                /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(),
7772                /*orderByColumnRefs=*/outerOrderByRefs,
7773                /*distinctOnColumnRefs=*/Collections.<ColumnRef>emptyList(),
7774                /*distinct=*/ false,
7775                /*setOperator=*/ setOpKind,
7776                /*rowLimit=*/ setOpRowLimit);
7777        // Slice 180 (R5): the set-op outer block's span covers the whole
7778        // set-op statement.
7779        outer = outer.withSourceSpan(SourceSpan.of(setOp));
7780        int outerIdx = stmts.size();
7781        stmts.add(outer);
7782
7783        // Lineage: outer.outputs[i] → each branch.outputs[i] (in branch order).
7784        for (int i = 0; i < expectedCols; i++) {
7785            OutputColumn out = outer.getOutputColumns().get(i);
7786            for (int b = 0; b < branches.size(); b++) {
7787                StatementGraph branchStmt = stmts.get(branchIdxs[b]);
7788                String branchOutName = branchStmt.getOutputColumns().get(i).getName();
7789                lineage.add(new LineageEdge(
7790                        LineageRef.statementOutput(outerIdx, out.getName()),
7791                        LineageRef.statementOutput(branchIdxs[b], branchOutName)));
7792            }
7793        }
7794        return outerIdx;
7795    }
7796
7797    /**
7798     * Reject row-limit clauses on an INTERNAL (non-root) set-op node.
7799     * Slice 9 / 12 rationale: with ORDER BY they decide which rows
7800     * survive, so the canonical-model exclusion of ORDER BY is only
7801     * sound when no row-limit is present.
7802     *
7803     * <p>Slice 21 split this from {@code rejectSetOpInternalOrderBy}
7804     * because the OUTER set-op node lifts its ORDER BY (collected via
7805     * {@link #buildSetOpOuterOrderByColumnRefs}). Slice 72 narrows the
7806     * row-limit guard the same way: the OUTER set-op node now lifts
7807     * row-limit metadata (collected via {@link #buildSetOpRowLimit}),
7808     * while parenthesized inner combined operations carrying a
7809     * row-limit (e.g. {@code (A UNION B LIMIT 3) UNION C}) remain
7810     * rejected because the intermediate limit is destroyed by the
7811     * outer set operation.
7812     */
7813    private static void rejectSetOpRowLimit(TSelectSqlStatement node) {
7814        if (node.getLimitClause() != null) {
7815            throw new SemanticIRBuildException(
7816                    Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED,
7817                    "row-limit clause LIMIT on a non-root set-op node is not supported yet", (TParseTreeNode) null));
7818        }
7819        if (node.getTopClause() != null) {
7820            throw new SemanticIRBuildException(
7821                    Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED,
7822                    "row-limit clause TOP on a non-root set-op node is not supported yet", (TParseTreeNode) null));
7823        }
7824        if (node.getFetchFirstClause() != null) {
7825            throw new SemanticIRBuildException(
7826                    Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED,
7827                    "row-limit clause FETCH FIRST on a non-root set-op node is not supported yet", (TParseTreeNode) null));
7828        }
7829        if (node.getOffsetClause() != null) {
7830            throw new SemanticIRBuildException(
7831                    Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED,
7832                    "row-limit clause OFFSET on a non-root set-op node is not supported yet", (TParseTreeNode) null));
7833        }
7834    }
7835
7836    /**
7837     * Reject ORDER BY on an INTERNAL (non-root) set-op node. Slice 21
7838     * lifted ORDER BY on the OUTER (root) set-op, but parenthesized
7839     * inner combined nodes like
7840     * {@code (A UNION B ORDER BY id) UNION C} remain rejected: the
7841     * intermediate sort is destroyed by the outer set operation
7842     * (UNION does not preserve order), so the inner ORDER BY has no
7843     * observable effect. Lifting requires modelling intermediate sort
7844     * semantics — a future slice.
7845     */
7846    private static void rejectSetOpInternalOrderBy(TSelectSqlStatement node) {
7847        if (node.getOrderbyClause() != null) {
7848            throw new SemanticIRBuildException(
7849                    Diagnostic.error(DiagnosticCode.SET_OP_NON_ROOT_ORDER_BY_NOT_SUPPORTED,
7850                    "ORDER BY on a non-root set-op node is not supported yet "
7851                            + "(intermediate sort would be discarded by the outer set operation)", (TParseTreeNode) null));
7852        }
7853    }
7854
7855    /**
7856     * Collect physical column refs for the outer set-op's ORDER BY
7857     * clause. Slice 21 lifts the slice-12 rejection on set-op outer
7858     * ORDER BY using the slice-9 single-SELECT pattern, generalised:
7859     *
7860     * <ul>
7861     *   <li>Each sort-key item passes the same shape rejections as
7862     *       {@link #buildOrderByColumnRefs} (ordinals, constants,
7863     *       scalar / predicate subqueries, window functions, ORDER
7864     *       SIBLINGS BY, RESET WHEN, in-clause OFFSET/FETCH).
7865     *   <li>Each {@link TObjectName} reference dispatches via a
7866     *       four-case fail-closed taxonomy: {@code column_alias} →
7867     *       lookup via {@code toString()}; unqualified {@code column}
7868     *       → lookup via {@code getColumnNameOnly()}; qualified
7869     *       {@code column} → reject (set-op outer scope is the
7870     *       unioned outputs, not branches' tables); other
7871     *       {@code dbObjectType} → reject as unsupported.
7872     *   <li>The lookup is positional against {@code outerOutputs} (=
7873     *       branch[0].outputs by slice-12 design) — NOT per-branch
7874     *       name search. Per-branch name search would mis-bind
7875     *       swapped-name branches and silently accept names present
7876     *       only in non-branch[0]. Slice-21 codex rounds 1-2 MUSTs.
7877     *   <li>Each branch contributes its
7878     *       {@code outputColumns[pos].sources} for the matched
7879     *       position. Branches with empty sources at the matched
7880     *       position (scalar / fully-derived projection) reject the
7881     *       sort key with a tuned message — silent omission would
7882     *       lose dependency information (slice-21 codex round 1
7883     *       MUST 5).
7884     *   <li>Each branch-local {@link ColumnRef} is normalised to its
7885     *       catalog name via {@link RelationSource#getBinding()}'s
7886     *       {@code qualifiedName}. The set-op outer's relations list
7887     *       is empty, so branch-local aliases are not resolvable in
7888     *       the owning statement; normalisation yields self-contained
7889     *       refs (slice-21 codex round 1 MUST 4 + round 2 MUST 1).
7890     *   <li>A per-item empty-refs guard rejects sort keys that
7891     *       contributed zero physical refs (e.g. {@code ORDER BY
7892     *       1+0}, {@code ORDER BY UPPER('x')}). Mirrors slice-9
7893     *       single-SELECT invariant. Operates on a per-item local
7894     *       set, so duplicate cross-item refs ({@code ORDER BY id,
7895     *       id}) survive global LinkedHashSet de-duplication
7896     *       (slice-21 codex round 4 MUST 1).
7897     * </ul>
7898     *
7899     * <p>Like slice-9 ORDER BY for single-SELECT, this list does NOT
7900     * contribute to the canonical model — it is presentation metadata
7901     * only. The dlineage XML probe ({@code /tmp/SetOpOrderByLimitProbe})
7902     * confirmed dlineage emits no parity edges for set-op outer
7903     * ORDER BY.
7904     */
7905    private static List<ColumnRef> buildSetOpOuterOrderByColumnRefs(
7906            TSelectSqlStatement setOp,
7907            List<OutputColumn> outerOutputs,
7908            List<StatementGraph> stmts,
7909            int[] branchIdxs) {
7910        TOrderBy orderBy = setOp.getOrderbyClause();
7911        if (orderBy == null) {
7912            return new ArrayList<>();
7913        }
7914        if (orderBy.isSiblings()) {
7915            throw new SemanticIRBuildException(
7916                    Diagnostic.error(DiagnosticCode.ORDER_SIBLINGS_BY_NOT_SUPPORTED,
7917                    "ORDER SIBLINGS BY is not supported yet "
7918                            + "(Oracle hierarchical ordering)", orderBy));
7919        }
7920        if (orderBy.getResetWhenCondition() != null) {
7921            throw new SemanticIRBuildException(
7922                    Diagnostic.error(DiagnosticCode.ORDER_BY_RESET_WHEN_NOT_SUPPORTED,
7923                    "ORDER BY ... RESET WHEN is not supported yet "
7924                            + "(Teradata window-style restart)", orderBy));
7925        }
7926        // Slice 72: TOrderBy in-clause OFFSET/FETCH slots are admitted
7927        // for MSSQL set-op outer via buildSetOpRowLimit's TOrderBy
7928        // fallback (the MSSQL parser routes set-op outer OFFSET/FETCH
7929        // EXCLUSIVELY onto TOrderBy, not duplicated onto the SELECT
7930        // node as in single-SELECT). Removing the previous defensive
7931        // throws here so the slice-72 admit shapes aren't false-
7932        // rejected. The unused codes
7933        // ORDER_BY_FETCH_FIRST_NOT_SUPPORTED and
7934        // ORDER_BY_OFFSET_NOT_SUPPORTED stay as documentation of a
7935        // known reject taxonomy.
7936        TOrderByItemList items = orderBy.getItems();
7937        if (items == null || items.size() == 0) {
7938            return new ArrayList<>();
7939        }
7940        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
7941        for (int i = 0; i < items.size(); i++) {
7942            TOrderByItem item = items.getOrderByItem(i);
7943            if (item == null) continue;
7944            TExpression sortKey = item.getSortKey();
7945            if (sortKey == null) continue;
7946            // Same shape rejections as slice-9 single-SELECT.
7947            rejectOrderByOrdinalOrConstant(sortKey);
7948            rejectOrderByScalarSubquery(sortKey);
7949            rejectOrderByWindowFunction(sortKey);
7950            // (NOT rejectOrderByAliasReference — alias refs are valid
7951            // at set-op outer scope; they ARE the branch-output names,
7952            // looked up positionally below.)
7953
7954            // Per-item local set so the empty-refs guard counts refs
7955            // FOUND for this sort key, not refs ADDED to the global
7956            // set after de-dup. Otherwise `ORDER BY id, id` would
7957            // false-reject the second item (slice-21 codex round 4 MUST 1).
7958            LinkedHashSet<ColumnRef> itemRefs = new LinkedHashSet<>();
7959            collectSetOpOuterRefsForSortKey(sortKey, outerOutputs,
7960                    stmts, branchIdxs, itemRefs);
7961            if (itemRefs.isEmpty()) {
7962                throw new SemanticIRBuildException(
7963                        Diagnostic.error(DiagnosticCode.SET_OP_OUTER_ORDER_BY_NO_PHYSICAL_COLUMN_REFS,
7964                        "ORDER BY sort key '" + sortKey
7965                                + "' has no physical column references at set-op "
7966                                + "outer (constant or non-column expressions are "
7967                                + "not supported yet)", sortKey));
7968            }
7969            refs.addAll(itemRefs);
7970        }
7971        return new ArrayList<>(refs);
7972    }
7973
7974    /**
7975     * Collect refs for one set-op outer ORDER BY sort key. Walks the
7976     * sort-key expression for {@link TObjectName} nodes and dispatches
7977     * each through {@link #processSetOpOrderByObjectName}. Includes a
7978     * top-level fast path for the common {@code ORDER BY x} case where
7979     * the entire sort key IS the {@link TObjectName}.
7980     *
7981     * <p>The visitor filters its dispatch to {@code column},
7982     * {@code column_alias}, and {@code unknown} dbObjectTypes — these
7983     * are the shapes that represent sort-key column references. Other
7984     * TObjectName nodes (function names, schema qualifications) are
7985     * part of the surrounding expression structure and skipped
7986     * silently. The {@code unknown} case is included so the four-case
7987     * fail-closed taxonomy in {@link #processSetOpOrderByObjectName}
7988     * rejects vendor-typed unknown qualified refs (e.g.
7989     * {@code foo.id + id}, slice-21 codex round 2 MUST 2).
7990     */
7991    private static void collectSetOpOuterRefsForSortKey(
7992            TExpression sortKey,
7993            final List<OutputColumn> outerOutputs,
7994            final List<StatementGraph> stmts,
7995            final int[] branchIdxs,
7996            final LinkedHashSet<ColumnRef> outRefs) {
7997        // Top-level fast path: the visitor's `acceptChildren` may not
7998        // visit the root TObjectName when the sort key is itself a
7999        // bare TObjectName. Mirrors slice-9 rejectOrderByAliasReference.
8000        if (sortKey.getExpressionType() == EExpressionType.simple_object_name_t) {
8001            TObjectName op = sortKey.getObjectOperand();
8002            if (op != null) {
8003                processSetOpOrderByObjectName(op, outerOutputs, stmts,
8004                        branchIdxs, outRefs);
8005                return;
8006            }
8007        }
8008        sortKey.acceptChildren(new TParseTreeVisitor() {
8009            @Override
8010            public void preVisit(TObjectName node) {
8011                EDbObjectType ot = node.getDbObjectType();
8012                // Skip non-column-like TObjectNames (function names,
8013                // schema/server qualifications). The four-case
8014                // fail-closed taxonomy still runs for column /
8015                // column_alias / unknown to handle the slice-21 codex
8016                // round 2 MUST 2 partial-accept case (e.g.
8017                // `foo.id + id` rejects via `foo.id`'s `unknown`
8018                // dbObjectType).
8019                if (ot != EDbObjectType.column
8020                        && ot != EDbObjectType.column_alias
8021                        && ot != EDbObjectType.unknown) {
8022                    return;
8023                }
8024                processSetOpOrderByObjectName(node, outerOutputs, stmts,
8025                        branchIdxs, outRefs);
8026            }
8027        });
8028    }
8029
8030    /**
8031     * Resolve one {@link TObjectName} sort-key reference at set-op
8032     * outer scope. Four-case fail-closed taxonomy (slice-21 codex
8033     * round 2 MUST 2): column_alias / unqualified column / qualified
8034     * column / other.
8035     */
8036    private static void processSetOpOrderByObjectName(
8037            TObjectName node,
8038            List<OutputColumn> outerOutputs,
8039            List<StatementGraph> stmts,
8040            int[] branchIdxs,
8041            LinkedHashSet<ColumnRef> outRefs) {
8042        EDbObjectType ot = node.getDbObjectType();
8043        String name;
8044        if (ot == EDbObjectType.column_alias) {
8045            // Aliases at set-op outer carry tableToken=alias-name (the
8046            // /tmp/SetOpQualifiedRefProbe finding); accept regardless.
8047            name = node.toString();
8048        } else if (ot == EDbObjectType.column) {
8049            if (node.getTableToken() != null) {
8050                throw new SemanticIRBuildException(
8051                        Diagnostic.error(DiagnosticCode.ORDER_BY_QUALIFIED_REFERENCE_NOT_SUPPORTED,
8052                        "qualified column reference '" + node
8053                                + "' in set-op outer ORDER BY not supported "
8054                                + "(scope is the unioned outputs, not branches' tables)", node));
8055            }
8056            name = node.getColumnNameOnly();
8057        } else {
8058            throw new SemanticIRBuildException(
8059                    Diagnostic.error(DiagnosticCode.ORDER_BY_OBJECT_REFERENCE_UNSUPPORTED,
8060                    "unsupported ORDER BY object reference '" + node
8061                            + "' (dbObjectType=" + ot + ") in set-op outer", node));
8062        }
8063        if (name == null || name.isEmpty() || "*".equals(name)) {
8064            throw new SemanticIRBuildException(
8065                    Diagnostic.error(DiagnosticCode.ORDER_BY_OBJECT_REFERENCE_NO_USABLE_NAME,
8066                    "ORDER BY object reference '" + node + "' has no usable name", node));
8067        }
8068        String key = name.toLowerCase(Locale.ROOT);
8069        int pos = -1;
8070        for (int i = 0; i < outerOutputs.size(); i++) {
8071            String outName = outerOutputs.get(i).getName();
8072            if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) {
8073                pos = i;
8074                break;
8075            }
8076        }
8077        if (pos < 0) {
8078            throw new SemanticIRBuildException(
8079                    Diagnostic.error(DiagnosticCode.ORDER_BY_NAME_NOT_MATCHED_IN_SET_OP_OUTPUT,
8080                    "ORDER BY '" + name + "' does not match any set-op output "
8081                            + "column (set-op outer column names come from branch[0])", node));
8082        }
8083        for (int b = 0; b < branchIdxs.length; b++) {
8084            StatementGraph br = stmts.get(branchIdxs[b]);
8085            OutputColumn oc = br.getOutputColumns().get(pos);
8086            if (oc.getSources().isEmpty()) {
8087                throw new SemanticIRBuildException(
8088                        Diagnostic.error(DiagnosticCode.SET_OP_ORDER_BY_BRANCH_OUTPUT_NO_SOURCES,
8089                        "ORDER BY '" + name + "' references branch[" + b
8090                                + "] output '" + oc.getName()
8091                                + "' which has no physical sources "
8092                                + "(derived/scalar projection); cannot "
8093                                + "capture this dependency yet", node));
8094            }
8095            for (ColumnRef cr : oc.getSources()) {
8096                outRefs.add(normaliseSetOpBranchRef(cr, br));
8097            }
8098        }
8099    }
8100
8101    /**
8102     * Normalise a branch-local {@link ColumnRef} to a self-contained
8103     * ref using the underlying {@link RelationBinding#getQualifiedName()}.
8104     *
8105     * <p>Slice-21 invariant (codex round 2 MUST 1): the set-op outer's
8106     * {@code relations} list is empty. Branch-local aliases (like
8107     * {@code e} for {@code FROM employees e}) are not resolvable in
8108     * the outer statement, so {@code orderByColumnRefs} normalises to
8109     * the catalog name. Fail-closed if no matching RelationSource —
8110     * this would indicate corrupt branch lineage state.
8111     */
8112    private static ColumnRef normaliseSetOpBranchRef(ColumnRef cr,
8113                                                     StatementGraph branch) {
8114        String alias = cr.getRelationAlias();
8115        String aliasKey = alias.toLowerCase(Locale.ROOT);
8116        for (RelationSource rs : branch.getRelations()) {
8117            if (rs.getAlias().toLowerCase(Locale.ROOT).equals(aliasKey)) {
8118                return new ColumnRef(rs.getBinding().getQualifiedName(),
8119                        cr.getColumnName());
8120            }
8121        }
8122        throw new SemanticIRBuildException(
8123                Diagnostic.error(DiagnosticCode.BRANCH_COLUMN_REF_UNKNOWN_RELATION,
8124                "internal: branch ColumnRef relationAlias '" + alias
8125                        + "' does not match any RelationSource in the "
8126                        + "branch's relations list", null));
8127    }
8128
8129    /**
8130     * Reject duplicate output names within a single statement.
8131     * Lineage refs are keyed by {@code (statementIndex, outputName)}; two
8132     * outputs sharing a name silently merge their lineage chains.
8133     */
8134    private static void rejectDuplicateOutputNames(StatementGraph stmt, String label) {
8135        Set<String> seen = new HashSet<>();
8136        for (OutputColumn c : stmt.getOutputColumns()) {
8137            String name = c.getName();
8138            if (name == null || name.isEmpty()) continue;
8139            if (!seen.add(name.toLowerCase(Locale.ROOT))) {
8140                throw new SemanticIRBuildException(
8141                        Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_DUPLICATE_OUTPUT_NAME,
8142                        "set-op branch '" + label + "' has duplicate output name '"
8143                                + name + "'; lineage refs are keyed by output name "
8144                                + "and would collide", null));
8145            }
8146        }
8147    }
8148
8149    /**
8150     * Map ({@link ESetOperatorType}, {@code isAll()}) to the IR
8151     * {@link SetOperator} enum. The exhaustive switch makes a future
8152     * {@code ESetOperatorType} value fail loudly at build time
8153     * (mirrors slice-8 {@code resolveDistinctFlag} pattern).
8154     */
8155    private static SetOperator resolveSetOperator(TSelectSqlStatement setOp) {
8156        ESetOperatorType type = setOp.getSetOperatorType();
8157        if (type == null) {
8158            throw new SemanticIRBuildException(
8159                    Diagnostic.error(DiagnosticCode.SET_OP_ROOT_TYPE_NULL,
8160                    "expected non-null set-op type on the set-op root", (TParseTreeNode) null));
8161        }
8162        boolean all = setOp.isAll();
8163        switch (type) {
8164            case union:     return all ? SetOperator.UNION_ALL     : SetOperator.UNION;
8165            case intersect: return all ? SetOperator.INTERSECT_ALL : SetOperator.INTERSECT;
8166            case minus:     return all ? SetOperator.MINUS_ALL     : SetOperator.MINUS;
8167            case except:    return all ? SetOperator.EXCEPT_ALL    : SetOperator.EXCEPT;
8168            case none:
8169                throw new SemanticIRBuildException(
8170                        Diagnostic.error(DiagnosticCode.SET_OP_ROOT_TYPE_NONE,
8171                        "expected non-none set operator type on the set-op root", (TParseTreeNode) null));
8172            default:
8173                throw new SemanticIRBuildException(
8174                        Diagnostic.error(DiagnosticCode.SET_OP_UNKNOWN_OPERATOR_TYPE,
8175                        "unknown set operator type: " + type, (TParseTreeNode) null));
8176        }
8177    }
8178
8179    /**
8180     * Iteratively flatten the left-leaning set-op tree into a list of
8181     * leaf SELECT statements (CLAUDE.md mandates no recursion on
8182     * {@code leftStmt}/{@code rightStmt}; would StackOverflow on 2000+
8183     * UNIONs).
8184     *
8185     * <p>On every internal set-op node visited:
8186     * <ol>
8187     *   <li>Reject row-limit modifiers ({@link #rejectSetOpRowLimit})
8188     *       on every node (root + internal). Slice 12 +
8189     *       {@code /tmp/SetOpInnerModifierProbe}: parenthesized inner
8190     *       combined nodes can carry row-limits in Oracle / PostgreSQL /
8191     *       MSSQL.</li>
8192     *   <li>Reject ORDER BY ({@link #rejectSetOpInternalOrderBy}) only
8193     *       on INTERNAL (non-root) nodes. Slice 21 lifted ORDER BY on
8194     *       the root via {@link #buildSetOpOuterOrderByColumnRefs}; an
8195     *       internal {@code (A UNION B ORDER BY id) UNION C} sort is
8196     *       still discarded by the outer set operation, so it has no
8197     *       observable effect and remains rejected.</li>
8198     *   <li>Reject mixed-operator and mixed-{@code ALL} chains by checking
8199     *       the resolved kind matches the root's kind.</li>
8200     *   <li>Hard-reject malformed AST (null left/right child).</li>
8201     * </ol>
8202     *
8203     * <p>Push order is right-then-left so leaves emerge in left-to-right
8204     * declaration order.
8205     */
8206    private static List<TSelectSqlStatement> flattenSetOpTreeIteratively(
8207            TSelectSqlStatement root, SetOperator expected) {
8208        List<TSelectSqlStatement> leaves = new ArrayList<>();
8209        Deque<TSelectSqlStatement> stack = new ArrayDeque<>();
8210        stack.push(root);
8211        while (!stack.isEmpty()) {
8212            TSelectSqlStatement cur = stack.pop();
8213            ESetOperatorType t = cur.getSetOperatorType();
8214            if (t != null && t != ESetOperatorType.none) {
8215                // Slice 21: ORDER BY guard fires only on INTERNAL nodes.
8216                // The root (`cur == root`) lifts ORDER BY; the collection
8217                // happens in buildSetOpOuterOrderByColumnRefs.
8218                // Slice 72: row-limit guard ALSO fires only on INTERNAL
8219                // nodes. The root lifts via buildSetOpRowLimit (called
8220                // by buildSetOpProgram before this method).
8221                if (cur != root) {
8222                    rejectSetOpRowLimit(cur);
8223                    rejectSetOpInternalOrderBy(cur);
8224                }
8225                SetOperator curKind = resolveSetOperator(cur);
8226                if (curKind != expected) {
8227                    throw new SemanticIRBuildException(
8228                            Diagnostic.error(DiagnosticCode.MIXED_SET_OPERATORS_NOT_SUPPORTED,
8229                            "mixed set operators in a single chain are not supported yet "
8230                                    + "(root=" + expected + ", inner=" + curKind + ")", (TParseTreeNode) null));
8231                }
8232                if (cur.getLeftStmt() == null || cur.getRightStmt() == null) {
8233                    throw new SemanticIRBuildException(
8234                            Diagnostic.error(DiagnosticCode.MALFORMED_SET_OP_AST,
8235                            "malformed set-op AST: null left/right child", (TParseTreeNode) null));
8236                }
8237                stack.push(cur.getRightStmt());
8238                stack.push(cur.getLeftStmt());
8239            } else {
8240                leaves.add(cur);
8241            }
8242        }
8243        return leaves;
8244    }
8245
8246    private static Set<String> collectCteNames(TCTEList cteList) {
8247        if (cteList == null || cteList.size() == 0) return Collections.emptySet();
8248        Set<String> names = new HashSet<>();
8249        for (int i = 0; i < cteList.size(); i++) {
8250            String name = cteList.getCTE(i).getTableName().toString();
8251            if (name != null && !name.isEmpty()) {
8252                names.add(name.toLowerCase(Locale.ROOT));
8253            }
8254        }
8255        return names;
8256    }
8257
8258    /**
8259     * Slice 107 — return the first CTE name shared between the outer-WITH
8260     * and inner-WITH CTE lists on an INSERT (case-insensitive, lowercase
8261     * via {@code toLowerCase(Locale.ROOT)} matching the slice-15/103
8262     * duplicate-name walker convention), or {@code null} if the name sets
8263     * are disjoint. Used by {@code buildInsert} to keep the
8264     * shared-name case rejecting (PG/Oracle/Snowflake nested-WITH
8265     * inner-shadows-outer semantics not yet supported) while admitting
8266     * the disjoint case via flat-merge.
8267     *
8268     * <p>Pathological edge case (codex round-2 diff-review Q3): if one of
8269     * the two lists also contains an INTRA-list duplicate AND that
8270     * duplicated name happens to also appear in the other list, this
8271     * helper short-circuits with
8272     * INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED and masks the
8273     * more precise same-scope DUPLICATE_CTE_NAME the slice-103 walker
8274     * would have emitted. Accepted limitation — the diagnostic still
8275     * tells the user the shape is unsupported, and both codes point at
8276     * the same offending name. A future slice can pre-walk each list
8277     * for intra-list duplicates before the boundary check if a
8278     * customer reports confusion.
8279     */
8280    private static String findFirstSharedCteName(TCTEList outer, TCTEList inner) {
8281        Set<String> outerNames = new HashSet<>();
8282        for (int i = 0; i < outer.size(); i++) {
8283            outerNames.add(outer.getCTE(i).getTableName().toString().toLowerCase(Locale.ROOT));
8284        }
8285        for (int i = 0; i < inner.size(); i++) {
8286            String name = inner.getCTE(i).getTableName().toString();
8287            if (outerNames.contains(name.toLowerCase(Locale.ROOT))) {
8288                return name;
8289            }
8290        }
8291        return null;
8292    }
8293
8294    /**
8295     * Reject the case where a CTE body references a sibling CTE declared
8296     * <i>after</i> it. SQL chain semantics only allow left-to-right
8297     * references, but the bind-by-name provider would happily classify a
8298     * forward-declared CTE name as a base {@code TABLE} (because it's not
8299     * yet in {@code visibleSoFar}). Catching it here turns the silent
8300     * mislabeling into a clear error.
8301     */
8302    private static void rejectForwardCteReferences(final TCTE cte,
8303                                                   final Set<String> allCteNames,
8304                                                   final Set<String> visibleSoFar) {
8305        TSelectSqlStatement body = cte.getSubquery();
8306        if (body == null) return;
8307        final String selfName = cte.getTableName().toString().toLowerCase(Locale.ROOT);
8308        final List<String> forwards = new ArrayList<>();
8309        body.acceptChildren(new TParseTreeVisitor() {
8310            @Override
8311            public void preVisit(TTable t) {
8312                String tname = bareName(t);
8313                if (tname == null) return;
8314                String lower = tname.toLowerCase(Locale.ROOT);
8315                if (allCteNames.contains(lower)
8316                        && !visibleSoFar.contains(lower)
8317                        && !lower.equals(selfName)) {
8318                    forwards.add(tname);
8319                }
8320            }
8321        });
8322        if (!forwards.isEmpty()) {
8323            throw new SemanticIRBuildException(
8324                    Diagnostic.error(DiagnosticCode.CTE_FORWARD_REFERENCE,
8325                    "CTE '" + cte.getTableName() + "' forward-references later CTE(s) "
8326                            + forwards + "; only left-to-right CTE chains are supported", cte));
8327        }
8328    }
8329
8330    private static String bareName(TTable t) {
8331        if (t == null) return null;
8332        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) return null;
8333        return t.getName();
8334    }
8335
8336    /**
8337     * Reject {@code WITH RECURSIVE}. Slice 4 supports chained
8338     * (forward-referencing) CTEs; recursion is left for a later slice that
8339     * can model the fixpoint semantics.
8340     */
8341    private static void rejectRecursiveCtes(TCTEList cteList) {
8342        if (cteList == null) return;
8343        for (int i = 0; i < cteList.size(); i++) {
8344            TCTE cte = cteList.getCTE(i);
8345            if (cte.isRecursive()) {
8346                throw new SemanticIRBuildException(
8347                        Diagnostic.error(DiagnosticCode.CTE_WITH_RECURSIVE_NOT_SUPPORTED,
8348                        "WITH RECURSIVE is not supported yet (CTE: " + cte.getTableName() + ")", cte));
8349            }
8350        }
8351    }
8352
8353    /**
8354     * Slice 101 — walk the WITH clause on a MERGE statement and append
8355     * each CTE body to {@code stmts} as a preceding statement. Mirrors
8356     * the SELECT-side build() at lines ~516-653.
8357     *
8358     * <p>Returns a {@code cteNameToStatementIndex} map keyed by
8359     * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated
8360     * with each CTE's output column names so the {@code buildMerge}
8361     * USING-as-CTE branch can install them via
8362     * {@link NameBindingProvider#withInScopeRelationColumns}.
8363     *
8364     * <p>Rejects (chronological):
8365     * <ol>
8366     *   <li>WITH RECURSIVE — reuses {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}.
8367     *       Currently no admitting vendor (PG parser PARSE_FAILED, probe
8368     *       2026-05-17); defensive reject for forward compatibility.</li>
8369     *   <li>CTE with explicit column list — rejects with new
8370     *       {@link DiagnosticCode#MERGE_CTE_EXPLICIT_COLUMN_LIST_NOT_SUPPORTED}.
8371     *       PG and MSSQL parsers admit this shape; slice 101 defers
8372     *       because the inner CTE body output names ≠ user-visible CTE
8373     *       column names.</li>
8374     *   <li>Duplicate CTE name — reuses {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li>
8375     *   <li>Forward CTE reference — reuses {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li>
8376     * </ol>
8377     *
8378     * <p>Set-op CTE bodies route through {@link #buildSetOpProgram};
8379     * non-set-op CTE bodies route through {@link #buildSelectStatement}.
8380     * Each CTE's published columns are added to {@code ctePublishedColumnsOut}
8381     * after its body is built so a CTE cannot self-reference (mirrors
8382     * SELECT-side slice 60).
8383     */
8384    private static Map<String, Integer> buildMergeCteList(
8385            TMergeSqlStatement merge,
8386            NameBindingProvider provider,
8387            List<StatementGraph> stmts,
8388            List<LineageEdge> lineage,
8389            Map<String, List<String>> ctePublishedColumnsOut) {
8390        TCTEList cteList = merge.getCteList();
8391        Map<String, Integer> cteNameToStatementIndex = new HashMap<>();
8392        if (cteList == null || cteList.size() == 0) {
8393            return cteNameToStatementIndex;
8394        }
8395        rejectRecursiveCtes(cteList);
8396        // Slice 102 — explicit-column-list shapes (PG/MSSQL `WITH cte(a, b) AS
8397        // (...) MERGE ...`) are admitted by rebuilding the body's
8398        // StatementGraph with the explicit-list names and rewriting outgoing
8399        // STATEMENT_OUTPUT lineage refs. The slice-101 upfront reject is
8400        // replaced by per-CTE rename application below. The slice-101 code
8401        // (MERGE_CTE_EXPLICIT_COLUMN_LIST_NOT_SUPPORTED) stays declared for
8402        // API stability.
8403        Set<String> allCteNames = collectCteNames(cteList);
8404        Set<String> visibleSoFar = new HashSet<>();
8405        for (int i = 0; i < cteList.size(); i++) {
8406            TCTE cte = cteList.getCTE(i);
8407            String cteName = cte.getTableName().toString();
8408            String cteNameLower = cteName.toLowerCase(Locale.ROOT);
8409            if (visibleSoFar.contains(cteNameLower)) {
8410                throw new SemanticIRBuildException(
8411                        Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME,
8412                        "duplicate CTE name '" + cteName
8413                                + "' in WITH clause; CTE names must be unique",
8414                        cte));
8415            }
8416            rejectForwardCteReferences(cte, allCteNames, visibleSoFar);
8417            NameBindingProvider bodyProvider =
8418                    provider.withCteContext(visibleSoFar);
8419            // Slice 102 — snapshot the lineage size BEFORE either branch so
8420            // the rename helper can rewrite outgoing STATEMENT_OUTPUT refs
8421            // in [lineageSize0, lineage.size()) without touching prior CTE
8422            // bodies' edges. Covers BOTH set-op and non-set-op branches
8423            // (codex round-1 plan-review BLOCKING).
8424            int lineageSize0 = lineage.size();
8425            int bodyIdx;
8426            TSelectSqlStatement cteBody = cte.getSubquery();
8427            if (cteBody != null
8428                    && cteBody.getSetOperatorType() != null
8429                    && cteBody.getSetOperatorType() != ESetOperatorType.none) {
8430                bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts,
8431                        lineage, cteNameToStatementIndex, cteName,
8432                        /*hasOuterCteListAlreadyProcessed=*/ false);
8433                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8434            } else {
8435                int cteStmtsSize0 = stmts.size();
8436                int cteLineageSize0 = lineage.size();
8437                Map<String, Integer> cteSubqueryAliasToIndex;
8438                try {
8439                    cteSubqueryAliasToIndex =
8440                            extractFromSubqueriesAsStatements(cteBody,
8441                                    bodyProvider, stmts, lineage,
8442                                    cteNameToStatementIndex,
8443                                    ctePublishedColumnsOut);
8444                } catch (RuntimeException ex) {
8445                    while (stmts.size() > cteStmtsSize0) {
8446                        stmts.remove(stmts.size() - 1);
8447                    }
8448                    while (lineage.size() > cteLineageSize0) {
8449                        lineage.remove(lineage.size() - 1);
8450                    }
8451                    throw ex;
8452                }
8453                EnclosingScope cteEnclosing = buildEnclosingScope(cteBody,
8454                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8455                        /*parent=*/ null);
8456                Map<Integer, ScalarInfo> cteScalarMap =
8457                        extractScalarSubqueriesAsStatements(cteBody,
8458                                bodyProvider, stmts, lineage,
8459                                cteNameToStatementIndex, cteEnclosing,
8460                                /*allowRecursiveScalarSubqueryExtraction=*/ true);
8461                Map<String, List<String>> cteBodyInScope =
8462                        buildEffectiveAliasInScopeMap(cteBody, bodyProvider,
8463                                ctePublishedColumnsOut,
8464                                cteSubqueryAliasToIndex, stmts);
8465                NameBindingProvider cteBodyProviderWithStar = bodyProvider
8466                        .withInScopeRelationColumns(cteBodyInScope);
8467                // Slice 114 — switch to buildSelectStatementImpl with
8468                // snapshot/rollback (see the matching SELECT-side
8469                // CTE site for full rationale).
8470                int cteBodyStmtsSnapshot = stmts.size();
8471                int cteBodyLineageSnapshot = lineage.size();
8472                StatementGraph body;
8473                try {
8474                    if (isPivotSelect(cteBody)) {
8475                        // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body
8476                        // (mirrors the SELECT-side buildSelectCteList branch).
8477                        // The slice-129 pivot router in buildSelectStatementImpl
8478                        // is gated to the OUTER SELECT (name == null), so a pivot
8479                        // built here (name == cteName) would otherwise fall to the
8480                        // normal path and reject with TABLE_BINDING_UNRESOLVED
8481                        // ("null(piviot_table)"). The pivot CTE becomes its own
8482                        // StatementGraph; the DML's CTE-as-relation path (fed from
8483                        // ctePublishedColumns ← body.getOutputColumns()) carries
8484                        // the cross-stmt lineage. All pivot deferrals preserved by
8485                        // buildPivotSelect's own guards.
8486                        body = buildPivotSelect(cteBody,
8487                                cteBodyProviderWithStar, cteName);
8488                    } else {
8489                        body = buildSelectStatementImpl(cteBody,
8490                            cteBodyProviderWithStar, cteName,
8491                            /*hasOuterCteListAlreadyProcessed=*/ false,
8492                            /*allowFromSubqueries=*/ true,
8493                            /*allowScalarProjectionSubqueries=*/ true,
8494                            /*allowWindowProjection=*/ true,
8495                            /*allowJoinOnPredicateSubqueries=*/ false,
8496                            /*stmtsForExtraction=*/ stmts,
8497                            /*lineageForExtraction=*/ lineage,
8498                            /*cteMapForExtraction=*/ cteNameToStatementIndex,
8499                            /*isPredicateBody=*/ false,
8500                            /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE,
8501                            /*allowWherePredicateSubqueries=*/ true);
8502                    }
8503                } catch (RuntimeException ex) {
8504                    while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
8505                    while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
8506                    throw ex;
8507                }
8508                bodyIdx = stmts.size();
8509                stmts.add(body);
8510                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8511                emitLineageForStatement(body, bodyIdx, lineage,
8512                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8513                        cteScalarMap);
8514            }
8515            // Slice 102 — apply explicit-column-list rename if present.
8516            // Rebuilds stmts[bodyIdx] with renamed OutputColumns and
8517            // rewrites STATEMENT_OUTPUT(bodyIdx, oldName) refs in
8518            // lineage[lineageSize0..) to use the renamed name. Returns the
8519            // published column list (renamed if explicit list applied,
8520            // else inner names from the body).
8521            List<String> publishedCols = applyExplicitCteColumnListRename(
8522                    cte, stmts, lineage, bodyIdx, lineageSize0, "MERGE");
8523            ctePublishedColumnsOut.put(cteNameLower, publishedCols);
8524            visibleSoFar.add(cteNameLower);
8525        }
8526        return cteNameToStatementIndex;
8527    }
8528
8529    /**
8530     * Slice 105 — walk the WITH clause on an UPDATE statement and append
8531     * each CTE body to {@code stmts} as a preceding statement. Mirrors
8532     * the slice-101 MERGE walker {@link #buildMergeCteList} verbatim
8533     * except for the source of the CTE list and the
8534     * {@link #applyExplicitCteColumnListRename} {@code dmlKind} argument.
8535     *
8536     * <p>Returns a {@code cteNameToStatementIndex} map keyed by
8537     * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated
8538     * with each CTE's output column names so {@link #buildUpdateRelation}
8539     * + {@link #buildUpdateInScopeMap} can route FROM-side references to
8540     * the matching CTE as SUBQUERY-kind relations with the CTE's columns
8541     * published into the in-scope map.
8542     *
8543     * <p>The slice-103 SELECT-side CTE walker contract is reused via the
8544     * {@link #applyExplicitCteColumnListRename} helper with
8545     * {@code dmlKind="SELECT"} so the SELECT-side
8546     * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} code
8547     * fires on arity mismatch (codex round-1 Q2 confirmed YES — UPDATE is
8548     * closer to ordinary SELECT than to MERGE for CTE rename semantics).
8549     *
8550     * <p>Rejects (chronological):
8551     * <ol>
8552     *   <li>{@code WITH RECURSIVE} — {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}.
8553     *       Currently no admitting vendor (Oracle PARSE_FAILED on outer-WITH-UPDATE).</li>
8554     *   <li>Duplicate CTE name — {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li>
8555     *   <li>Forward CTE reference — {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li>
8556     *   <li>Explicit-column-list arity mismatch — handled by
8557     *       {@link #applyExplicitCteColumnListRename} via
8558     *       {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.</li>
8559     * </ol>
8560     */
8561    private static Map<String, Integer> buildUpdateCteList(
8562            TUpdateSqlStatement update,
8563            NameBindingProvider provider,
8564            List<StatementGraph> stmts,
8565            List<LineageEdge> lineage,
8566            Map<String, List<String>> ctePublishedColumnsOut) {
8567        TCTEList cteList = update.getCteList();
8568        Map<String, Integer> cteNameToStatementIndex = new HashMap<>();
8569        if (cteList == null || cteList.size() == 0) {
8570            return cteNameToStatementIndex;
8571        }
8572        rejectRecursiveCtes(cteList);
8573        Set<String> allCteNames = collectCteNames(cteList);
8574        Set<String> visibleSoFar = new HashSet<>();
8575        for (int i = 0; i < cteList.size(); i++) {
8576            TCTE cte = cteList.getCTE(i);
8577            String cteName = cte.getTableName().toString();
8578            String cteNameLower = cteName.toLowerCase(Locale.ROOT);
8579            if (visibleSoFar.contains(cteNameLower)) {
8580                throw new SemanticIRBuildException(
8581                        Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME,
8582                        "duplicate CTE name '" + cteName
8583                                + "' in WITH clause; CTE names must be unique",
8584                        cte));
8585            }
8586            rejectForwardCteReferences(cte, allCteNames, visibleSoFar);
8587            NameBindingProvider bodyProvider =
8588                    provider.withCteContext(visibleSoFar);
8589            int lineageSize0 = lineage.size();
8590            int bodyIdx;
8591            TSelectSqlStatement cteBody = cte.getSubquery();
8592            if (cteBody != null
8593                    && cteBody.getSetOperatorType() != null
8594                    && cteBody.getSetOperatorType() != ESetOperatorType.none) {
8595                bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts,
8596                        lineage, cteNameToStatementIndex, cteName,
8597                        /*hasOuterCteListAlreadyProcessed=*/ false);
8598                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8599            } else {
8600                int cteStmtsSize0 = stmts.size();
8601                int cteLineageSize0 = lineage.size();
8602                Map<String, Integer> cteSubqueryAliasToIndex;
8603                try {
8604                    cteSubqueryAliasToIndex =
8605                            extractFromSubqueriesAsStatements(cteBody,
8606                                    bodyProvider, stmts, lineage,
8607                                    cteNameToStatementIndex,
8608                                    ctePublishedColumnsOut);
8609                } catch (RuntimeException ex) {
8610                    while (stmts.size() > cteStmtsSize0) {
8611                        stmts.remove(stmts.size() - 1);
8612                    }
8613                    while (lineage.size() > cteLineageSize0) {
8614                        lineage.remove(lineage.size() - 1);
8615                    }
8616                    throw ex;
8617                }
8618                EnclosingScope cteEnclosing = buildEnclosingScope(cteBody,
8619                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8620                        /*parent=*/ null);
8621                Map<Integer, ScalarInfo> cteScalarMap =
8622                        extractScalarSubqueriesAsStatements(cteBody,
8623                                bodyProvider, stmts, lineage,
8624                                cteNameToStatementIndex, cteEnclosing,
8625                                /*allowRecursiveScalarSubqueryExtraction=*/ true);
8626                Map<String, List<String>> cteBodyInScope =
8627                        buildEffectiveAliasInScopeMap(cteBody, bodyProvider,
8628                                ctePublishedColumnsOut,
8629                                cteSubqueryAliasToIndex, stmts);
8630                NameBindingProvider cteBodyProviderWithStar = bodyProvider
8631                        .withInScopeRelationColumns(cteBodyInScope);
8632                // Slice 114 — switch to buildSelectStatementImpl with
8633                // snapshot/rollback (see the matching SELECT-side
8634                // CTE site for full rationale).
8635                int cteBodyStmtsSnapshot = stmts.size();
8636                int cteBodyLineageSnapshot = lineage.size();
8637                StatementGraph body;
8638                try {
8639                    if (isPivotSelect(cteBody)) {
8640                        // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body
8641                        // (mirrors the SELECT-side buildSelectCteList branch).
8642                        // The slice-129 pivot router in buildSelectStatementImpl
8643                        // is gated to the OUTER SELECT (name == null), so a pivot
8644                        // built here (name == cteName) would otherwise fall to the
8645                        // normal path and reject with TABLE_BINDING_UNRESOLVED
8646                        // ("null(piviot_table)"). The pivot CTE becomes its own
8647                        // StatementGraph; the DML's CTE-as-relation path (fed from
8648                        // ctePublishedColumns ← body.getOutputColumns()) carries
8649                        // the cross-stmt lineage. All pivot deferrals preserved by
8650                        // buildPivotSelect's own guards.
8651                        body = buildPivotSelect(cteBody,
8652                                cteBodyProviderWithStar, cteName);
8653                    } else {
8654                        body = buildSelectStatementImpl(cteBody,
8655                            cteBodyProviderWithStar, cteName,
8656                            /*hasOuterCteListAlreadyProcessed=*/ false,
8657                            /*allowFromSubqueries=*/ true,
8658                            /*allowScalarProjectionSubqueries=*/ true,
8659                            /*allowWindowProjection=*/ true,
8660                            /*allowJoinOnPredicateSubqueries=*/ false,
8661                            /*stmtsForExtraction=*/ stmts,
8662                            /*lineageForExtraction=*/ lineage,
8663                            /*cteMapForExtraction=*/ cteNameToStatementIndex,
8664                            /*isPredicateBody=*/ false,
8665                            /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE,
8666                            /*allowWherePredicateSubqueries=*/ true);
8667                    }
8668                } catch (RuntimeException ex) {
8669                    while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
8670                    while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
8671                    throw ex;
8672                }
8673                bodyIdx = stmts.size();
8674                stmts.add(body);
8675                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8676                emitLineageForStatement(body, bodyIdx, lineage,
8677                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8678                        cteScalarMap);
8679            }
8680            // Slice 105 — explicit column-list rename uses dmlKind="SELECT"
8681            // so the SELECT-side CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH
8682            // code fires (codex Q2 confirmed YES — UPDATE is closer to
8683            // ordinary SELECT than MERGE for CTE rename semantics).
8684            List<String> publishedCols = applyExplicitCteColumnListRename(
8685                    cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT");
8686            ctePublishedColumnsOut.put(cteNameLower, publishedCols);
8687            visibleSoFar.add(cteNameLower);
8688        }
8689        return cteNameToStatementIndex;
8690    }
8691
8692    /**
8693     * Slice 106 — walk the WITH clause on a DELETE statement and append
8694     * each CTE body to {@code stmts} as a preceding statement. Mirrors
8695     * the slice-105 UPDATE walker {@link #buildUpdateCteList} verbatim
8696     * except for the source of the CTE list ({@code delete.getCteList()}).
8697     *
8698     * <p>Returns a {@code cteNameToStatementIndex} map keyed by
8699     * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated
8700     * with each CTE's output column names so {@link #buildDeleteRelation}
8701     * + {@link #buildDeleteInScopeMap} can route FROM-side references to
8702     * the matching CTE as SUBQUERY-kind relations with the CTE's columns
8703     * published into the in-scope map.
8704     *
8705     * <p>The slice-103 SELECT-side CTE walker contract is reused via the
8706     * {@link #applyExplicitCteColumnListRename} helper with
8707     * {@code dmlKind="SELECT"} so the SELECT-side
8708     * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} code
8709     * fires on arity mismatch (slice-105 precedent: UPDATE/DELETE are
8710     * closer to ordinary SELECT than to MERGE for CTE rename semantics).
8711     *
8712     * <p>Rejects (chronological):
8713     * <ol>
8714     *   <li>{@code WITH RECURSIVE} —
8715     *       {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}.
8716     *       PG / MySQL admit the parse shape but slice 106 rejects at the
8717     *       semantic layer (mirrors slice-105 boundary).</li>
8718     *   <li>Duplicate CTE name — {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li>
8719     *   <li>Forward CTE reference — {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li>
8720     *   <li>Explicit-column-list arity mismatch — handled by
8721     *       {@link #applyExplicitCteColumnListRename} via
8722     *       {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.</li>
8723     * </ol>
8724     */
8725    private static Map<String, Integer> buildDeleteCteList(
8726            TDeleteSqlStatement delete,
8727            NameBindingProvider provider,
8728            List<StatementGraph> stmts,
8729            List<LineageEdge> lineage,
8730            Map<String, List<String>> ctePublishedColumnsOut) {
8731        TCTEList cteList = delete.getCteList();
8732        Map<String, Integer> cteNameToStatementIndex = new HashMap<>();
8733        if (cteList == null || cteList.size() == 0) {
8734            return cteNameToStatementIndex;
8735        }
8736        rejectRecursiveCtes(cteList);
8737        Set<String> allCteNames = collectCteNames(cteList);
8738        Set<String> visibleSoFar = new HashSet<>();
8739        for (int i = 0; i < cteList.size(); i++) {
8740            TCTE cte = cteList.getCTE(i);
8741            String cteName = cte.getTableName().toString();
8742            String cteNameLower = cteName.toLowerCase(Locale.ROOT);
8743            if (visibleSoFar.contains(cteNameLower)) {
8744                throw new SemanticIRBuildException(
8745                        Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME,
8746                        "duplicate CTE name '" + cteName
8747                                + "' in WITH clause; CTE names must be unique",
8748                        cte));
8749            }
8750            rejectForwardCteReferences(cte, allCteNames, visibleSoFar);
8751            NameBindingProvider bodyProvider =
8752                    provider.withCteContext(visibleSoFar);
8753            int lineageSize0 = lineage.size();
8754            int bodyIdx;
8755            TSelectSqlStatement cteBody = cte.getSubquery();
8756            if (cteBody != null
8757                    && cteBody.getSetOperatorType() != null
8758                    && cteBody.getSetOperatorType() != ESetOperatorType.none) {
8759                bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts,
8760                        lineage, cteNameToStatementIndex, cteName,
8761                        /*hasOuterCteListAlreadyProcessed=*/ false);
8762                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8763            } else {
8764                int cteStmtsSize0 = stmts.size();
8765                int cteLineageSize0 = lineage.size();
8766                Map<String, Integer> cteSubqueryAliasToIndex;
8767                try {
8768                    cteSubqueryAliasToIndex =
8769                            extractFromSubqueriesAsStatements(cteBody,
8770                                    bodyProvider, stmts, lineage,
8771                                    cteNameToStatementIndex,
8772                                    ctePublishedColumnsOut);
8773                } catch (RuntimeException ex) {
8774                    while (stmts.size() > cteStmtsSize0) {
8775                        stmts.remove(stmts.size() - 1);
8776                    }
8777                    while (lineage.size() > cteLineageSize0) {
8778                        lineage.remove(lineage.size() - 1);
8779                    }
8780                    throw ex;
8781                }
8782                EnclosingScope cteEnclosing = buildEnclosingScope(cteBody,
8783                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8784                        /*parent=*/ null);
8785                Map<Integer, ScalarInfo> cteScalarMap =
8786                        extractScalarSubqueriesAsStatements(cteBody,
8787                                bodyProvider, stmts, lineage,
8788                                cteNameToStatementIndex, cteEnclosing,
8789                                /*allowRecursiveScalarSubqueryExtraction=*/ true);
8790                Map<String, List<String>> cteBodyInScope =
8791                        buildEffectiveAliasInScopeMap(cteBody, bodyProvider,
8792                                ctePublishedColumnsOut,
8793                                cteSubqueryAliasToIndex, stmts);
8794                NameBindingProvider cteBodyProviderWithStar = bodyProvider
8795                        .withInScopeRelationColumns(cteBodyInScope);
8796                // Slice 114 — switch to buildSelectStatementImpl with
8797                // snapshot/rollback (see the matching SELECT-side
8798                // CTE site for full rationale).
8799                int cteBodyStmtsSnapshot = stmts.size();
8800                int cteBodyLineageSnapshot = lineage.size();
8801                StatementGraph body;
8802                try {
8803                    if (isPivotSelect(cteBody)) {
8804                        // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body
8805                        // (mirrors the SELECT-side buildSelectCteList branch).
8806                        // The slice-129 pivot router in buildSelectStatementImpl
8807                        // is gated to the OUTER SELECT (name == null), so a pivot
8808                        // built here (name == cteName) would otherwise fall to the
8809                        // normal path and reject with TABLE_BINDING_UNRESOLVED
8810                        // ("null(piviot_table)"). The pivot CTE becomes its own
8811                        // StatementGraph; the DML's CTE-as-relation path (fed from
8812                        // ctePublishedColumns ← body.getOutputColumns()) carries
8813                        // the cross-stmt lineage. All pivot deferrals preserved by
8814                        // buildPivotSelect's own guards.
8815                        body = buildPivotSelect(cteBody,
8816                                cteBodyProviderWithStar, cteName);
8817                    } else {
8818                        body = buildSelectStatementImpl(cteBody,
8819                            cteBodyProviderWithStar, cteName,
8820                            /*hasOuterCteListAlreadyProcessed=*/ false,
8821                            /*allowFromSubqueries=*/ true,
8822                            /*allowScalarProjectionSubqueries=*/ true,
8823                            /*allowWindowProjection=*/ true,
8824                            /*allowJoinOnPredicateSubqueries=*/ false,
8825                            /*stmtsForExtraction=*/ stmts,
8826                            /*lineageForExtraction=*/ lineage,
8827                            /*cteMapForExtraction=*/ cteNameToStatementIndex,
8828                            /*isPredicateBody=*/ false,
8829                            /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE,
8830                            /*allowWherePredicateSubqueries=*/ true);
8831                    }
8832                } catch (RuntimeException ex) {
8833                    while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1);
8834                    while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1);
8835                    throw ex;
8836                }
8837                bodyIdx = stmts.size();
8838                stmts.add(body);
8839                cteNameToStatementIndex.put(cteNameLower, bodyIdx);
8840                emitLineageForStatement(body, bodyIdx, lineage,
8841                        cteNameToStatementIndex, cteSubqueryAliasToIndex,
8842                        cteScalarMap);
8843            }
8844            // Slice 106 — explicit column-list rename uses dmlKind="SELECT"
8845            // so the SELECT-side CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH
8846            // code fires (slice-105 precedent: UPDATE/DELETE are closer
8847            // to ordinary SELECT than MERGE for CTE rename semantics).
8848            List<String> publishedCols = applyExplicitCteColumnListRename(
8849                    cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT");
8850            ctePublishedColumnsOut.put(cteNameLower, publishedCols);
8851            visibleSoFar.add(cteNameLower);
8852        }
8853        return cteNameToStatementIndex;
8854    }
8855
8856    /**
8857     * Slice 105 — combine the slice-83 subqueryAliasToIndex with the
8858     * slice-105 CTE-as-FROM-relation alias→cteIdx entries so
8859     * {@link #emitUpdateSubquerySourceEdges} produces cross-stmt
8860     * lineage edges for SET RHS references resolving to a CTE column.
8861     *
8862     * <p>Without this merge the visible {@link OutputColumn#getSources}
8863     * stays correct (CTE refs surface as {@link ColumnRef}s) but
8864     * {@code lineage[]} silently loses the canonical
8865     * {@code STATEMENT_OUTPUT(update,col) → STATEMENT_OUTPUT(cte,col)}
8866     * edge (codex round-2 Q5 silent-correctness bug).
8867     *
8868     * <p>Walks {@code update.getJoins()} the same way
8869     * {@link #buildUpdateRelation} does to keep the alias resolution
8870     * identical: CTE-bound FROM-side relations are detected by their
8871     * bare name (case-insensitive) and registered under their effective
8872     * alias. Subquery aliases stay keyed lowercase to match the
8873     * slice-83 contract.
8874     */
8875    private static Map<String, Integer> buildUpdateCombinedAliasToSubIdx(
8876            TUpdateSqlStatement update,
8877            Map<String, Integer> subqueryAliasToIndex,
8878            Map<String, Integer> cteNameToStatementIndex) {
8879        Map<String, Integer> combined = new HashMap<>();
8880        if (subqueryAliasToIndex != null) {
8881            combined.putAll(subqueryAliasToIndex);
8882        }
8883        if (cteNameToStatementIndex == null
8884                || cteNameToStatementIndex.isEmpty()) {
8885            return combined;
8886        }
8887        TJoinList joins = update.getJoins();
8888        if (joins == null) return combined;
8889        for (TJoin join : joins) {
8890            addCteAliasToCombinedMap(join.getTable(),
8891                    cteNameToStatementIndex, combined);
8892            TJoinItemList items = join.getJoinItems();
8893            if (items == null) continue;
8894            for (int i = 0; i < items.size(); i++) {
8895                TJoinItem item = items.getJoinItem(i);
8896                if (item == null) continue;
8897                addCteAliasToCombinedMap(item.getTable(),
8898                        cteNameToStatementIndex, combined);
8899            }
8900        }
8901        return combined;
8902    }
8903
8904    private static void addCteAliasToCombinedMap(TTable t,
8905            Map<String, Integer> cteNameToStatementIndex,
8906            Map<String, Integer> combined) {
8907        if (t == null) return;
8908        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) {
8909            return;
8910        }
8911        TObjectName tName = t.getTableName();
8912        if (tName == null) return;
8913        String bare = tName.toString();
8914        if (bare == null || bare.isEmpty()) return;
8915        String bareLower = bare.toLowerCase(Locale.ROOT);
8916        Integer cteIdx = cteNameToStatementIndex.get(bareLower);
8917        if (cteIdx == null) return;
8918        String aliasKey = effectiveAliasLowerCaseOrNull(t);
8919        if (aliasKey == null) aliasKey = bareLower;
8920        combined.put(aliasKey, cteIdx);
8921    }
8922
8923    /**
8924     * Slice 123 — DELETE analogue of
8925     * {@link #buildUpdateCombinedAliasToSubIdx}: combine the slice-84
8926     * {@code subqueryAliasToIndex} (USING-(SELECT) FROM-subqueries) with the
8927     * slice-106 CTE-as-FROM-relation alias&rarr;cteIdx entries so the
8928     * slice-85 {@link #buildReturningColumns} walker can promote a
8929     * RETURNING/OUTPUT source ref that resolves to a SUBQUERY-kind FROM-side
8930     * relation to a cross-stmt {@code STATEMENT_OUTPUT → STATEMENT_OUTPUT}
8931     * edge.
8932     *
8933     * <p>Walks {@code delete.getReferenceJoins()} (NOT {@code getJoins()},
8934     * which catches MySQL self-ref / multi-target shapes that still reject)
8935     * the same way {@link #buildDeleteRelation} does so alias resolution is
8936     * identical. Subquery aliases stay keyed lowercase (slice-84 contract);
8937     * CTE-as-relation entries are keyed by their effective alias via the
8938     * shared {@link #addCteAliasToCombinedMap} helper.
8939     *
8940     * <p>The {@code putAll} then {@code put} order is collision-free: the two
8941     * entry sources are disjoint in valid SQL. {@code subqueryAliasToIndex}
8942     * keys are aliases of {@code (SELECT …) sub} FROM-subqueries (non-{@code
8943     * objectname} table sources), while {@code addCteAliasToCombinedMap} only
8944     * adds {@code objectname}-typed tables whose bare name matches a declared
8945     * CTE — a single alias cannot be both, and two FROM relations sharing one
8946     * alias is a SQL error the parser/resolver rejects. Mirrors the slice-105
8947     * {@link #buildUpdateCombinedAliasToSubIdx} contract.
8948     */
8949    private static Map<String, Integer> buildDeleteCombinedAliasToSubIdx(
8950            TDeleteSqlStatement delete,
8951            Map<String, Integer> subqueryAliasToIndex,
8952            Map<String, Integer> cteNameToStatementIndex) {
8953        Map<String, Integer> combined = new HashMap<>();
8954        if (subqueryAliasToIndex != null) {
8955            combined.putAll(subqueryAliasToIndex);
8956        }
8957        if (cteNameToStatementIndex == null
8958                || cteNameToStatementIndex.isEmpty()) {
8959            return combined;
8960        }
8961        TJoinList refJoins = delete.getReferenceJoins();
8962        if (refJoins == null) return combined;
8963        for (int ji = 0; ji < refJoins.size(); ji++) {
8964            TJoin join = refJoins.getJoin(ji);
8965            addCteAliasToCombinedMap(join.getTable(),
8966                    cteNameToStatementIndex, combined);
8967            TJoinItemList items = join.getJoinItems();
8968            if (items == null) continue;
8969            for (int i = 0; i < items.size(); i++) {
8970                TJoinItem item = items.getJoinItem(i);
8971                if (item == null) continue;
8972                addCteAliasToCombinedMap(item.getTable(),
8973                        cteNameToStatementIndex, combined);
8974            }
8975        }
8976        return combined;
8977    }
8978
8979    /**
8980     * Slice 102 / Slice 103 — when a WITH-clause CTE declares an explicit
8981     * column list ({@code WITH cte(a, b) AS (SELECT x, y FROM t)}), rebuild
8982     * {@code stmts[bodyIdx]} so its {@link OutputColumn} names match the
8983     * explicit list at each ordinal and rewrite outgoing
8984     * {@link LineageRef.Kind#STATEMENT_OUTPUT} refs in
8985     * {@code lineage[lineageSize0..lineage.size())} so the inner-projection
8986     * names are replaced by the explicit-list names.
8987     *
8988     * <p>Returns the published column list for the caller's
8989     * {@code ctePublishedColumns} map: the renamed list when an explicit list
8990     * is present; otherwise the body's inner names (matching pre-slice-102
8991     * behavior). Slice 103 reuses this helper from the outer SELECT CTE
8992     * walker via {@code dmlKind="SELECT"} (slice-100 cross-DML reuse
8993     * precedent).
8994     *
8995     * <p>Rejects:
8996     * <ul>
8997     *   <li>Arity mismatch — explicit-list size != body output count →
8998     *       {@link DiagnosticCode#MERGE_CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}
8999     *       when {@code dmlKind="MERGE"}, otherwise
9000     *       {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.
9001     *       Slice 103 cannot rename the MERGE-side code (it is pinned by
9002     *       {@code Slice102Test.valueOfPinsResolveBothCodes} and adopting
9003     *       it on the SELECT side would also miswire the message text);
9004     *       the SELECT-side gets its own parallel code (codex round-1
9005     *       plan-review BLOCKING).</li>
9006     *   <li>Duplicate explicit name ({@code WITH cte(a, a) AS ...}) →
9007     *       {@link DiagnosticCode#DUPLICATE_OUTPUT_NAME}. STATEMENT_OUTPUT
9008     *       refs are keyed by output name; duplicates would collide
9009     *       (codex round-2 plan-review advisory).</li>
9010     * </ul>
9011     *
9012     * <p>{@link OutputColumn} and {@link StatementGraph} are immutable; the
9013     * rebuild uses the slice-85 15-arg primary constructor copying every
9014     * field unchanged except {@code outputColumns}. {@link LineageEdge} and
9015     * {@link LineageRef} are immutable; the rewrite walker constructs new
9016     * instances and replaces them in the mutable {@code lineage} list via
9017     * {@link List#set}.
9018     */
9019    private static List<String> applyExplicitCteColumnListRename(
9020            TCTE cte,
9021            List<StatementGraph> stmts,
9022            List<LineageEdge> lineage,
9023            int bodyIdx,
9024            int lineageSize0,
9025            String dmlKind) {
9026        StatementGraph body = stmts.get(bodyIdx);
9027        if (cte.getColumnList() == null || cte.getColumnList().size() == 0) {
9028            return outputColumnNames(body);
9029        }
9030        // Materialize the explicit list of renamed names (in declaration order).
9031        boolean isMerge = "MERGE".equals(dmlKind);
9032        String dmlLabel = isMerge ? "MERGE CTE" : "CTE";
9033        String withClauseLabel = isMerge ? "MERGE WITH clause CTE" : "WITH clause CTE";
9034        List<String> renamed = new ArrayList<>(cte.getColumnList().size());
9035        Set<String> seenLower = new HashSet<>();
9036        for (int k = 0; k < cte.getColumnList().size(); k++) {
9037            TObjectName col = cte.getColumnList().getObjectName(k);
9038            String name = (col == null) ? null : col.getColumnNameOnly();
9039            if (name == null || name.isEmpty()) {
9040                // Defensive — parser normally fills these; if not, fall
9041                // back to a synthetic name so the constructor invariant
9042                // (non-empty name) holds, and the arity check still works.
9043                name = "col" + (k + 1);
9044            }
9045            String lower = name.toLowerCase(Locale.ROOT);
9046            if (!seenLower.add(lower)) {
9047                throw new SemanticIRBuildException(Diagnostic.error(
9048                        DiagnosticCode.DUPLICATE_OUTPUT_NAME,
9049                        "duplicate column name '" + name + "' in " + dmlLabel + " '"
9050                                + cte.getTableName()
9051                                + "' explicit column list; output names must "
9052                                + "be unique within a CTE published column list",
9053                        cte));
9054            }
9055            renamed.add(name);
9056        }
9057        List<OutputColumn> bodyOutputs = body.getOutputColumns();
9058        if (bodyOutputs.size() != renamed.size()) {
9059            DiagnosticCode arityCode = isMerge
9060                    ? DiagnosticCode.MERGE_CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH
9061                    : DiagnosticCode.CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH;
9062            throw new SemanticIRBuildException(Diagnostic.error(
9063                    arityCode,
9064                    withClauseLabel + " '" + cte.getTableName()
9065                            + "' declares " + renamed.size()
9066                            + " explicit column(s) but the body's SELECT "
9067                            + "publishes " + bodyOutputs.size() + " column(s); "
9068                            + "the explicit list must have exactly one entry "
9069                            + "per body output column",
9070                    cte));
9071        }
9072        // Capture the old → new name mapping by ordinal BEFORE building the
9073        // new OutputColumns, so the lineage rewrite can look up the
9074        // substitution by old (inner) name. Codex round-1 diff-review
9075        // (non-blocking → upgraded to defensive guard): if the body has
9076        // duplicate inner output names (e.g. `SELECT id, id`), name-keyed
9077        // rewrite collapses both old refs to the last mapping and
9078        // produces wrong lineage. The IR contract already states output
9079        // names must be unique (see DUPLICATE_OUTPUT_NAME javadoc and the
9080        // line-4378 scalar-subquery guard) but is not enforced
9081        // generically. Reject here so explicit-rename paths cannot
9082        // silently break lineage.
9083        Set<String> seenInnerLower = new HashSet<>();
9084        for (OutputColumn oc : bodyOutputs) {
9085            String n = oc.getName();
9086            if (n == null || n.isEmpty()) continue;
9087            String lower = n.toLowerCase(Locale.ROOT);
9088            if (!seenInnerLower.add(lower)) {
9089                throw new SemanticIRBuildException(Diagnostic.error(
9090                        DiagnosticCode.DUPLICATE_OUTPUT_NAME,
9091                        dmlLabel + " '" + cte.getTableName()
9092                                + "' body publishes duplicate inner column "
9093                                + "name '" + n + "'; the explicit column "
9094                                + "list rename requires unique inner names "
9095                                + "because lineage refs are keyed by output "
9096                                + "name and would collide",
9097                        cte));
9098            }
9099        }
9100        Map<String, String> oldToNewLower = new HashMap<>();
9101        List<OutputColumn> newOutputs = new ArrayList<>(bodyOutputs.size());
9102        for (int k = 0; k < bodyOutputs.size(); k++) {
9103            OutputColumn oc = bodyOutputs.get(k);
9104            String oldName = oc.getName();
9105            String newName = renamed.get(k);
9106            if (oldName != null && !oldName.isEmpty()) {
9107                oldToNewLower.put(oldName.toLowerCase(Locale.ROOT), newName);
9108            }
9109            newOutputs.add(new OutputColumn(newName, oc.isDerived(),
9110                    oc.isAggregate(), oc.getSources(), oc.getWindowSpec()));
9111        }
9112        // Rebuild the body's StatementGraph using the slice-85 15-arg primary
9113        // constructor — copies every field (including the slice-85
9114        // returningColumns slot per codex round-1 plan-review BLOCKING)
9115        // except outputColumns.
9116        StatementGraph renamedBody = new StatementGraph(
9117                body.getName(), body.getKind(), body.getRelations(),
9118                newOutputs, body.getReturningColumns(),
9119                body.getFilterColumnRefs(), body.getJoinColumnRefs(),
9120                body.getGroupByColumnRefs(), body.getHavingColumnRefs(),
9121                body.getOrderByColumnRefs(), body.getDistinctOnColumnRefs(),
9122                body.isDistinct(), body.getSetOperator(), body.getRowLimit(),
9123                body.getTarget())
9124                // Slice 180 (R5): the rebuild via the legacy ctor would strip
9125                // the block span the original carried; preserve it so the
9126                // renamed body keeps its scope span. (The legacy ctor also
9127                // resets joinAnalysisFacts; that pre-existing facts-strip is
9128                // left as-is here — out of R5 scope — to avoid changing the
9129                // emitted JSON for these renamed bodies.)
9130                .withSourceSpan(body.getSourceSpan());
9131        stmts.set(bodyIdx, renamedBody);
9132        // Rewrite outgoing STATEMENT_OUTPUT refs in the window. Both `from`
9133        // and `to` are checked because edges can place the body-output ref
9134        // on either side (producer-side: from=TABLE_COLUMN, to=STATEMENT_OUTPUT;
9135        // consumer-side from a deeper inner stmt: from=STATEMENT_OUTPUT,
9136        // to=STATEMENT_OUTPUT — neither shape today places bodyIdx on
9137        // `from` for THIS body, but the symmetric check is cheap and
9138        // future-proof). LineageRef and LineageEdge are immutable, so new
9139        // instances are constructed and `lineage.set` replaces in place.
9140        for (int idx = lineageSize0; idx < lineage.size(); idx++) {
9141            LineageEdge edge = lineage.get(idx);
9142            LineageRef from = edge.getFrom();
9143            LineageRef to = edge.getTo();
9144            LineageRef newFrom = maybeRewriteStatementOutputRef(
9145                    from, bodyIdx, oldToNewLower);
9146            LineageRef newTo = maybeRewriteStatementOutputRef(
9147                    to, bodyIdx, oldToNewLower);
9148            if (newFrom != from || newTo != to) {
9149                lineage.set(idx, new LineageEdge(newFrom, newTo));
9150            }
9151        }
9152        return Collections.unmodifiableList(renamed);
9153    }
9154
9155    /**
9156     * Slice 113 — copy a {@link StatementGraph} with a new {@code name}
9157     * field. Every other field is preserved verbatim. Used by the
9158     * set-op branch loop to assign the synthetic
9159     * {@code <set_op_branch_<idx>>} name AFTER the branch build, in case
9160     * the branch's WHERE-side predicate-subquery extraction
9161     * (slice 113 via {@link PredicateClauseContext#SET_OP_BRANCH_WHERE})
9162     * appended predicate-body statements to {@code stmts}, which would
9163     * otherwise leave the pre-computed digit suffix lagging behind the
9164     * branch's final position.
9165     *
9166     * <p>The rebuild is purely cosmetic on the {@link StatementGraph#getName()}
9167     * field. No {@link LineageRef} is affected because all lineage refs
9168     * are idx-based (see {@link LineageRef#statementOutput(int, String)}),
9169     * not name-based. {@code outputColumns}, {@code relations},
9170     * {@code filterColumnRefs}, {@code joinColumnRefs} and every other
9171     * field are reused unchanged.
9172     */
9173    private static StatementGraph withRenamedTo(StatementGraph s, String newName) {
9174        return new StatementGraph(newName, s.getKind(),
9175                s.getRelations(), s.getOutputColumns(), s.getReturningColumns(),
9176                s.getFilterColumnRefs(), s.getJoinColumnRefs(),
9177                s.getGroupByColumnRefs(), s.getHavingColumnRefs(),
9178                s.getOrderByColumnRefs(), s.getDistinctOnColumnRefs(),
9179                s.isDistinct(), s.getSetOperator(), s.getRowLimit(),
9180                s.getTarget())
9181                // Slice 180 (R5): preserve the block span the legacy ctor
9182                // would otherwise drop, so this rename keeps the scope span.
9183                .withSourceSpan(s.getSourceSpan());
9184    }
9185
9186    /**
9187     * Slice 102 — return a new STATEMENT_OUTPUT {@link LineageRef} with the
9188     * output name substituted when {@code ref} targets {@code bodyIdx} and
9189     * its current output name is a key in {@code oldToNewLower}. Otherwise
9190     * return {@code ref} unchanged (identity-comparable so the caller can
9191     * skip the {@code lineage.set} for no-op rewrites).
9192     */
9193    private static LineageRef maybeRewriteStatementOutputRef(
9194            LineageRef ref, int bodyIdx,
9195            Map<String, String> oldToNewLower) {
9196        if (ref == null) return null;
9197        if (ref.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) return ref;
9198        if (ref.getStatementIndex() != bodyIdx) return ref;
9199        String oldName = ref.getOutputName();
9200        if (oldName == null || oldName.isEmpty()) return ref;
9201        String newName = oldToNewLower.get(oldName.toLowerCase(Locale.ROOT));
9202        if (newName == null) return ref;
9203        return LineageRef.statementOutput(bodyIdx, newName);
9204    }
9205
9206    /**
9207     * Emit one lineage edge per (output, source) pair. Edges target a
9208     * {@link LineageRef.Kind#STATEMENT_OUTPUT} when the source's relation
9209     * is a CTE or a FROM-clause subquery, or a
9210     * {@link LineageRef.Kind#TABLE_COLUMN} when it's a base table.
9211     * Multi-source derived columns produce one edge per source.
9212     *
9213     * <p>{@code subqueryAliasToStatementIndex} is statement-local; the
9214     * caller supplies the alias map for this statement's own FROM list.
9215     * That avoids cross-scope alias collisions.
9216     */
9217    private static void emitLineageForStatement(StatementGraph stmt,
9218                                                int statementIndex,
9219                                                List<LineageEdge> lineage,
9220                                                Map<String, Integer> cteNameToStatementIndex,
9221                                                Map<String, Integer> subqueryAliasToStatementIndex,
9222                                                Map<Integer, ScalarInfo> ordinalToScalarInfo) {
9223        // Slice 87: lowercase alias keys so SQL identifiers written with
9224        // different casing in the FROM clause vs. SELECT qualifier resolve
9225        // correctly (e.g. `SELECT t.name FROM employees T`). Mirrors the
9226        // same fix in emitUpdateSubquerySourceEdges (slice 83). When two
9227        // relations collide after lowercasing (unusual, but not guaranteed
9228        // caught by the duplicate-alias preflight in all call paths per
9229        // codex Q1 advisory), last-write-wins — the same policy as slice 83.
9230        Map<String, RelationSource> aliasToRelation = new HashMap<>();
9231        for (RelationSource r : stmt.getRelations()) {
9232            String key = r.getAlias();
9233            if (key == null || key.isEmpty()) continue;
9234            aliasToRelation.put(key.toLowerCase(Locale.ROOT), r);
9235        }
9236        List<OutputColumn> outputs = stmt.getOutputColumns();
9237        for (int outOrdinal = 0; outOrdinal < outputs.size(); outOrdinal++) {
9238            OutputColumn out = outputs.get(outOrdinal);
9239            // Slice 11: scalar-subquery projections have empty sources
9240            // by construction; their lineage edge is a single
9241            // STATEMENT_OUTPUT → STATEMENT_OUTPUT pointing at the
9242            // extracted scalar body's only output. Emit it once and
9243            // skip the per-source loop (which would be a no-op anyway).
9244            ScalarInfo scalar = ordinalToScalarInfo.get(outOrdinal);
9245            if (scalar != null) {
9246                lineage.add(new LineageEdge(
9247                        LineageRef.statementOutput(statementIndex, out.getName()),
9248                        LineageRef.statementOutput(scalar.statementIndex,
9249                                scalar.innerOutputName)));
9250                continue;
9251            }
9252            for (ColumnRef src : out.getSources()) {
9253                String srcAlias = src.getRelationAlias();
9254                RelationSource rel = aliasToRelation.get(
9255                        srcAlias == null ? null : srcAlias.toLowerCase(Locale.ROOT));
9256                if (rel == null) {
9257                    throw new SemanticIRBuildException(
9258                            Diagnostic.error(DiagnosticCode.OUTPUT_REFERENCES_UNKNOWN_RELATION,
9259                            "output '" + out.getName() + "' references unknown relation '"
9260                                    + src.getRelationAlias() + "'", null));
9261                }
9262                LineageRef from = LineageRef.statementOutput(statementIndex, out.getName());
9263                LineageRef to;
9264                // Slice 15: resolved-kind dispatch. For OUTER_REFERENCE
9265                // bindings the underlying outerKind decides which
9266                // table-column or statement-output edge we emit.
9267                // Codex round-1 MUST 2 / round-2 MUST 1: exhaustive
9268                // dispatch instead of catch-all.
9269                RelationKind kind = rel.getBinding().getKind();
9270                RelationKind resolvedKind = (kind == RelationKind.OUTER_REFERENCE)
9271                        ? rel.getBinding().getOuterKind()
9272                        : kind;
9273                if (resolvedKind == RelationKind.CTE) {
9274                    Integer cteIndex = cteNameToStatementIndex.get(
9275                            rel.getBinding().getQualifiedName().toLowerCase(Locale.ROOT));
9276                    if (cteIndex == null) {
9277                        throw new SemanticIRBuildException(
9278                                Diagnostic.error(DiagnosticCode.CTE_BODY_MISSING,
9279                                "CTE '" + rel.getBinding().getQualifiedName() + "' has no body statement", null));
9280                    }
9281                    to = LineageRef.statementOutput(cteIndex, src.getColumnName());
9282                } else if (resolvedKind == RelationKind.SUBQUERY) {
9283                    Integer subIndex = subqueryAliasToStatementIndex.get(
9284                            rel.getAlias().toLowerCase(Locale.ROOT));
9285                    if (subIndex == null) {
9286                        throw new SemanticIRBuildException(
9287                                Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_BINDING_UNRESOLVED,
9288                                "FROM-clause subquery '" + rel.getAlias()
9289                                        + "' has no body statement registered", null));
9290                    }
9291                    to = LineageRef.statementOutput(subIndex, src.getColumnName());
9292                } else if (resolvedKind == RelationKind.TABLE
9293                        || resolvedKind == RelationKind.FUNCTION) {
9294                    // FUNCTION (table-valued function) is an opaque source:
9295                    // lineage terminates at the function's qualified name,
9296                    // same shape as a base table column.
9297                    to = LineageRef.tableColumn(
9298                            rel.getBinding().getQualifiedName(),
9299                            src.getColumnName());
9300                } else {
9301                    throw new SemanticIRBuildException(
9302                            Diagnostic.error(DiagnosticCode.OUTPUT_REFERENCES_UNSUPPORTED_BINDING_KIND,
9303                            "output '" + out.getName()
9304                                    + "' references relation '" + rel.getAlias()
9305                                    + "' with unsupported binding kind " + kind
9306                                    + (kind == RelationKind.OUTER_REFERENCE
9307                                        ? " (outerKind=" + rel.getBinding().getOuterKind() + ")"
9308                                        : ""), null));
9309                }
9310                lineage.add(new LineageEdge(from, to));
9311            }
9312        }
9313    }
9314
9315    /**
9316     * Build one SELECT statement (CTE body or outer). The {@code name}
9317     * argument is non-null for a CTE body, null otherwise. When
9318     * {@code hasOuterCteListAlreadyProcessed} is true, the SELECT's own
9319     * {@code getCteList()} is not rejected because the caller has already
9320     * extracted those CTEs into separate statements; in all other cases a
9321     * non-empty CTE list on this node is rejected (so nested WITH inside
9322     * a CTE body does not silently slip through).
9323     */
9324    private static StatementGraph buildSelectStatement(TSelectSqlStatement select,
9325                                                       NameBindingProvider provider,
9326                                                       String name,
9327                                                       boolean hasOuterCteListAlreadyProcessed,
9328                                                       boolean allowFromSubqueries,
9329                                                       boolean allowScalarProjectionSubqueries,
9330                                                       boolean allowWindowProjection) {
9331        // Slice 23: legacy 7-arg call site. Predicate-subquery extraction is
9332        // disabled (allowJoinOnPredicateSubqueries=false) and this is not a
9333        // predicate body itself (isPredicateBody=false). All non-outer call
9334        // sites use this overload — the slice-17 `rejectSubqueriesInJoinOn`
9335        // continues to fire at every non-outer JOIN-ON site.
9336        return buildSelectStatementImpl(select, provider, name,
9337                hasOuterCteListAlreadyProcessed,
9338                allowFromSubqueries,
9339                allowScalarProjectionSubqueries,
9340                allowWindowProjection,
9341                /*allowJoinOnPredicateSubqueries=*/ false,
9342                /*stmtsForExtraction=*/ null,
9343                /*lineageForExtraction=*/ null,
9344                /*cteMapForExtraction=*/ null,
9345                /*isPredicateBody=*/ false,
9346                /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE,
9347                /*allowWherePredicateSubqueries=*/ false);
9348    }
9349
9350    /**
9351     * Internal body shared between the legacy 7-arg overload and the
9352     * outer-SELECT entry point used by {@link #build}. Slice 23 added two new
9353     * concepts; slice 24 added one more.
9354     * <ul>
9355     *   <li>{@code allowJoinOnPredicateSubqueries} + {@code stmts}/{@code lineage}
9356     *       — when {@code allow...} is {@code true}, JOIN-ON uncorrelated
9357     *       EXISTS subqueries are extracted as their own
9358     *       {@code <predicate_subquery_<i>>} statements appended to
9359     *       {@code stmts} (slice-11/12 synthetic-name pattern). Outer-SELECT
9360     *       entry only.</li>
9361     *   <li>{@code isPredicateBody} — when {@code true}, this statement IS
9362     *       the inner SELECT of an extracted EXISTS body. The constant-only
9363     *       projection rejection in {@link #buildOutputColumns} is bypassed
9364     *       and a single synthetic OutputColumn is emitted in its place.</li>
9365     *   <li>{@code cteMapForExtraction} (slice 24) — outer's CTE
9366     *       name-to-statement-index map, plumbed in only when
9367     *       {@code allowJoinOnPredicateSubqueries=true}. Required so the
9368     *       slice-24 column-bearing inner projection can emit
9369     *       STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edges into outer-
9370     *       visible CTE bodies. Non-outer call sites pass {@code null}.</li>
9371     * </ul>
9372     */
9373    private static StatementGraph buildSelectStatementImpl(
9374            TSelectSqlStatement select,
9375            NameBindingProvider provider,
9376            String name,
9377            boolean hasOuterCteListAlreadyProcessed,
9378            boolean allowFromSubqueries,
9379            boolean allowScalarProjectionSubqueries,
9380            boolean allowWindowProjection,
9381            boolean allowJoinOnPredicateSubqueries,
9382            List<StatementGraph> stmtsForExtraction,
9383            List<LineageEdge> lineageForExtraction,
9384            Map<String, Integer> cteMapForExtraction,
9385            boolean isPredicateBody,
9386            PredicateClauseContext whereClauseContext,
9387            boolean allowWherePredicateSubqueries) {
9388        rejectUnsupportedShape(select, hasOuterCteListAlreadyProcessed);
9389        // Slice 129: PIVOT sub-slice (a). A SELECT whose single FROM source
9390        // is a PIVOT routes to the dedicated pivot builder, which binds the
9391        // underlying source relation and captures the consumed FOR /
9392        // aggregation-arg columns into StatementGraph.pivotColumnRefs. Scoped
9393        // to the outer SELECT context (name == null, not a predicate body);
9394        // nested / non-outer PIVOTs are deferred to a later sub-slice and
9395        // fall through to the normal path (which rejects with the existing
9396        // TABLE_BINDING_UNRESOLVED as before).
9397        if (name == null && !isPredicateBody && isPivotSelect(select)) {
9398            return buildPivotSelect(select, provider, name);
9399        }
9400        // Slice 143: a PIVOT/UNPIVOT driver that ALSO has a JOIN partner
9401        // (e.g. FROM sales PIVOT(...) AS p JOIN dim d ON ...) trips
9402        // isPivotSelect's "no trailing JOIN" guard and would otherwise fall to
9403        // the normal path and reject with the GENERIC TABLE_BINDING_UNRESOLVED.
9404        // Emit a PRECISE deferral reusing the existing
9405        // PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED code (no new lineage admitted),
9406        // mirroring the precise chained-PIVOT reject.
9407        //
9408        // Slice 145: this precise routing no longer carries the slice-143/144
9409        // `name == null && !isPredicateBody` guard, so it also fires in NESTED
9410        // contexts (CTE body / FROM-subquery body / set-op branch /
9411        // predicate-subquery body) where a pivot-with-join reaches
9412        // buildSelectStatementImpl under a non-null name (or isPredicateBody).
9413        // The slice-129 ADMISSION router above keeps its outer-only guard —
9414        // nested ADMITTED pivots (isPivotSelect) are routed to buildPivotSelect
9415        // at their own call sites and never reach here; only NON-admitted
9416        // pivot-with-join shapes do, which were previously rejected with the
9417        // generic TABLE_BINDING_UNRESOLVED. The helpers only match when a
9418        // pivoted_table is genuinely present, so a plain JOIN is untouched.
9419        if (isPivotWithJoinPartner(select)) {
9420            TTable pivotTable = select.joins.getJoin(0).getTable();
9421            // Slice 146: name the ACTUAL operator (PIVOT vs UNPIVOT) so an
9422            // UNPIVOT-then-JOIN does not mis-report "PIVOT".
9423            throw new SemanticIRBuildException(
9424                    Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
9425                    pivotOperatorKeyword(pivotTable) + " combined with a JOIN is not supported yet",
9426                    pivotTable));
9427        }
9428        // Slice 144: the symmetric positions — a PIVOT/UNPIVOT appearing as a
9429        // JOIN PARTNER rather than the FROM driver: the right-hand side of an
9430        // explicit JOIN (FROM dim JOIN sales PIVOT(...) ON ...) or a comma-join
9431        // sibling (FROM sales PIVOT(...), dim). Slice 143 closed the
9432        // pivot-as-driver case; these shapes previously fell to the normal path
9433        // and rejected with the GENERIC TABLE_BINDING_UNRESOLVED. Emit the same
9434        // PRECISE deferral reusing PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED (no new
9435        // lineage admitted). Slice 145 (see above) lifts the outer-only guard so
9436        // this also fires for a pivot join partner in any nesting context.
9437        TTable pivotPartner = findPivotJoinPartner(select);
9438        if (pivotPartner != null) {
9439            // Slice 146: name the ACTUAL operator (PIVOT vs UNPIVOT).
9440            throw new SemanticIRBuildException(
9441                    Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
9442                    pivotOperatorKeyword(pivotPartner) + " combined with a JOIN is not supported yet",
9443                    pivotPartner));
9444        }
9445        boolean distinct = resolveDistinctFlag(select);
9446        // Slice 65 — reset using scope at entry so a parent SELECT's
9447        // scope cannot leak into recursive nested builds. The using
9448        // scope for THIS SELECT is installed only AFTER buildRelations
9449        // completes (see below) so the predicate-subquery extraction
9450        // walk inside buildRelations does not inherit the outer scope
9451        // (codex slice-65 diff-review round-1 P2 #1: an inner
9452        // {@code EXISTS (SELECT SUM(x.v) FILTER (WHERE k > 0) FROM x)}
9453        // would have its bare `k` expand to outer's merged sources,
9454        // causing a valid uncorrelated body to be rejected as
9455        // correlated). The slice-64 → 65 JOIN-ON merged-key reject
9456        // also runs BEFORE buildRelations so ON-clause refs aren't
9457        // collected with a stale or future scope.
9458        provider = provider.withUsingScope(UsingScope.EMPTY);
9459        // Reset the join-graph structural anchor at entry (mirrors the
9460        // UsingScope reset above) so a parent SELECT's anchor cannot leak into
9461        // this recursive nested build. It is re-installed below for THIS
9462        // SELECT's own FROM clause once relations are bound.
9463        provider = provider.withJoinStructureAnchor(false);
9464        rejectUnqualifiedMergedKeyInJoinOn(select, provider);
9465        List<ColumnRef> joinRefs = new ArrayList<>();
9466        List<RelationSource> relations;
9467        if (isSetOpBranchSyntheticName(name)
9468                && hasNoFromSource(select)
9469                && allResultColumnsAreConstantExpressions(select)) {
9470            // Slice 61: allow FROM-less constant-only set-op branches
9471            // such as SELECT 1 UNION ALL SELECT 2. The general SELECT
9472            // boundary remains unchanged: non-branch SELECT 1 still
9473            // fails in buildRelations with "must have at least one
9474            // FROM source".
9475            relations = Collections.emptyList();
9476        } else {
9477            relations = buildRelations(select, provider, joinRefs,
9478                    allowFromSubqueries,
9479                    allowJoinOnPredicateSubqueries,
9480                    stmtsForExtraction, lineageForExtraction, cteMapForExtraction);
9481        }
9482        // Slice 65 — install this SELECT's own using scope AFTER
9483        // buildRelations / predicate-subquery extraction. From here
9484        // forward the clause collectors (output / filter / groupBy /
9485        // having / orderBy) see the merged-key scope for THIS SELECT.
9486        UsingScope ownScope = buildUsingScope(select, provider);
9487        if (!ownScope.isEmpty()) {
9488            provider = provider.withUsingScope(ownScope);
9489        }
9490        // Install the join-graph structural anchor for THIS SELECT: two or more
9491        // bound FROM endpoints mean the join graph is fully determined (an
9492        // unqualified column can only be NON_EXACT when ≥2 candidate relations
9493        // exist, and reaching here means every relation bound — buildRelations
9494        // throws TABLE_BINDING_UNRESOLVED otherwise). The anchor lets
9495        // rejectNonExactBindings degrade an unqualified, catalog-less column
9496        // miss over an explicit ON / CROSS / comma join to a warning instead of
9497        // discarding the whole statement graph — the same treatment the USING
9498        // merged-key anchor already gives.
9499        //
9500        // CATALOG-LESS GATE: the degrade exists only because, WITHOUT a catalog,
9501        // we cannot know which side an unqualified column belongs to. When every
9502        // FROM relation has a known column list (catalog or in-scope derived
9503        // columns), the catalog CAN adjudicate — an unqualified column matching
9504        // no side is a genuine error and must stay fatal (a multi-relation
9505        // resolver returns a null binding either way, so the reject result alone
9506        // cannot distinguish the two). So anchor only when at least one endpoint's
9507        // columns are unknown.
9508        if (relations.size() >= 2 && !fromRelationColumnsFullyKnown(select, provider)) {
9509            provider = provider.withJoinStructureAnchor(true);
9510        }
9511        List<OutputColumn> outputColumns = buildOutputColumns(select, provider,
9512                allowScalarProjectionSubqueries, allowWindowProjection,
9513                isPredicateBody, name);
9514        // Slice 112 — thread the SELECT path's outer extraction context
9515        // through buildFilterColumnRefs so top-level SELECT WHERE can
9516        // lift uncorrelated predicate-subquery wrappers via the
9517        // slice-23+ extraction pipeline (PredicateClauseContext.SELECT_WHERE).
9518        // Slice 113 — the same threading extends to set-op branch WHERE
9519        // via PredicateClauseContext.SET_OP_BRANCH_WHERE, distinguished
9520        // only by clauseLabel for diagnostic messages (codes are shared).
9521        //
9522        // {@code allowWherePredicateSubqueries} is INDEPENDENT of
9523        // {@code allowJoinOnPredicateSubqueries} (slice 113 split):
9524        // set-op branches admit WHERE-side predicate subqueries while
9525        // KEEPING JOIN-ON predicate subqueries rejected (slice 23 / 26
9526        // contract — pinned by Slice23Test#existsInSetOpBranchJoinOnStillRejected
9527        // and Slice26Test#lhsSubqueryInSetOpBranchRejected). Nested
9528        // SELECTs without extraction context
9529        // (allowWherePredicateSubqueries=false) keep the slice-80
9530        // blanket reject inside buildFilterColumnRefs.
9531        // R8 — collector for predicate-derived semi-joins (EXISTS / IN /
9532        // NOT EXISTS / NOT IN in WHERE). Populated by buildFilterColumnRefs'
9533        // extraction; converted to SEMI / ANTI_SEMI JoinEntities at
9534        // JoinGraph assembly below (which has `relations` to resolve the
9535        // outer left endpoint).
9536        List<SemiJoinFact> semiFacts = new ArrayList<>();
9537        // R8 — the outer FROM relation aliases (lower-cased) gate the
9538        // correlated-EXISTS degrade: only a correlation to a genuine outer
9539        // relation degrades; an unknown alias still rejects.
9540        Set<String> outerRelationAliases = new HashSet<>();
9541        for (RelationSource rs : relations) {
9542            if (rs.getAlias() != null && !rs.getAlias().isEmpty()) {
9543                outerRelationAliases.add(rs.getAlias().toLowerCase(Locale.ROOT));
9544            }
9545        }
9546        List<ColumnRef> filterRefs = buildFilterColumnRefs(select, provider,
9547                allowWherePredicateSubqueries,
9548                stmtsForExtraction, lineageForExtraction, cteMapForExtraction,
9549                whereClauseContext, semiFacts, outerRelationAliases);
9550        List<ColumnRef> groupByRefs = buildGroupByColumnRefs(select, provider);
9551        List<ColumnRef> havingRefs = buildHavingColumnRefs(select, provider);
9552        List<ColumnRef> orderByRefs = buildOrderByColumnRefs(select, provider, outputColumns);
9553        // Slice 73: DISTINCT ON refs collected here so they observe the
9554        // same {@code provider} (with UsingScope already installed) used
9555        // by buildGroupByColumnRefs / buildHavingColumnRefs /
9556        // buildOrderByColumnRefs. This keeps `DISTINCT ON (k)` over
9557        // `JOIN ... USING (k)` consistent with slice-65 merged-key
9558        // semantics and prevents parent-scope leakage into nested
9559        // builds.
9560        List<ColumnRef> distinctOnRefs = buildDistinctOnColumnRefs(select, provider);
9561        // Slice 125: QUALIFY filter refs. Needs outputColumns for the
9562        // projection-alias form (QUALIFY rn = 1); observes the same
9563        // provider (with UsingScope already installed) as the other
9564        // clause-ref builders so QUALIFY over JOIN ... USING (k) stays
9565        // consistent with slice-65 merged-key semantics.
9566        List<ColumnRef> qualifyRefs = buildQualifyColumnRefs(select, provider, outputColumns);
9567        // Slice 128: structured GROUP BY view. Built after
9568        // buildGroupByColumnRefs so its slice-61/slice-13 guards have
9569        // already fired; observes the same provider as the other clause-ref
9570        // builders. Empty unless the statement has a GROUP BY.
9571        List<GroupingElement> groupingElements = buildGroupingElements(select, provider);
9572        RowLimit rowLimit = buildRowLimit(select);
9573        StatementGraph sg = new StatementGraph(name, "SELECT", relations, outputColumns,
9574                filterRefs, joinRefs, groupByRefs, havingRefs, orderByRefs,
9575                distinctOnRefs,
9576                qualifyRefs,
9577                groupingElements,
9578                distinct,
9579                /*setOperator=*/ null,
9580                rowLimit);
9581        // Slice 179 (R5): carry this block's own source span (covers the
9582        // full select [... where ...]). All SELECT blocks — main, CTE bodies,
9583        // FROM-subquery bodies — funnel through this site, so each gets its
9584        // span from its own parse node. Read by attachQueryBlockScopes.
9585        sg = sg.withSourceSpan(SourceSpan.of(select));
9586        // Slice 167 (S6): attach the ordered structured JoinGraph (GAP 1).
9587        // Slice 169 (S8): attach the WHERE filter predicate tree (GAP 2/3).
9588        // Both additive — the flat joinColumnRefs / filterColumnRefs above
9589        // are untouched.
9590        JoinGraph joinGraph = buildJoinGraph(select, provider);
9591        // Source-dialect Sema runs after relation binding and before join
9592        // promotion. It is unconditional for every SELECT/query block, so an
9593        // Oracle (+) marker cannot disappear merely because no comma-join
9594        // boundary was promotable. Compatibility lowering continues below,
9595        // while the atomic build result retains every ERROR and recovery link.
9596        OracleLegacyOuterJoinValidationResult oraclePlusValidation =
9597                OracleLegacyOuterJoinValidator.validate(
9598                        select, relations, provider);
9599        for (Diagnostic diagnostic : oraclePlusValidation.getDiagnostics()) {
9600            recordBuildDiagnostic(diagnostic);
9601        }
9602        for (RecoveryMetadata.Entry recovery
9603                : oraclePlusValidation.getRecoveryEntries()) {
9604            recordBuildRecovery(recovery);
9605        }
9606        // R8 — append predicate-derived semi-join entities (EXISTS / IN /
9607        // NOT EXISTS / NOT IN) after the FROM-clause joins, continuing the
9608        // order numbering. The left endpoint is the outer relation owning
9609        // the correlated outer column (falling back to the first FROM
9610        // relation); the right endpoint is the lifted subquery block.
9611        joinGraph = appendSemiJoins(joinGraph, semiFacts, relations);
9612        // Oracle-style implicit-join predicate promotion: when a WHERE conjunct
9613        // links both sides of a comma-join (IMPLICIT_CROSS) entity, move it from
9614        // the filter list into that join's conditions and upgrade the join to
9615        // INNER. A two-sided predicate — equi, non-equi, or a BETWEEN/band range
9616        // across both relations — is a join condition, not a cartesian filter.
9617        // The entity keeps sourceSyntax=COMMA so its implicit origin stays
9618        // distinguishable from an explicit ON join. Single-sided predicates
9619        // remain filters; an IMPLICIT_CROSS with no linking predicate stays a
9620        // true cross product.
9621        TExpression implicitWhere = (select.getWhereClause() != null)
9622                ? select.getWhereClause().getCondition() : null;
9623        List<Predicate> filterPredicates;
9624        if (implicitWhere != null && hasImplicitCrossJoin(joinGraph)) {
9625            ImplicitJoinPromotion promo = promoteImplicitJoinConditions(
9626                    joinGraph, implicitWhere, provider,
9627                    oraclePlusValidation);
9628            joinGraph = promo.graph;
9629            filterPredicates = promo.filterPredicates;
9630        } else {
9631            filterPredicates = buildFilterPredicates(select, provider);
9632        }
9633        JoinAnalysisFacts facts = sg.getJoinAnalysisFacts();
9634        if (!joinGraph.isEmpty()) {
9635            facts = facts.withJoinGraph(joinGraph);
9636        }
9637        if (!filterPredicates.isEmpty()) {
9638            facts = facts.withFilterPredicates(filterPredicates);
9639        }
9640        if (!facts.isEmpty()) {
9641            sg = sg.withJoinAnalysisFacts(facts);
9642        }
9643        return sg;
9644    }
9645
9646    /**
9647     * Slice 169 (S8) — decompose the WHERE clause into a resolved filter
9648     * predicate list (GAP 2/3), reusing the slice-166 PredicateTreeBuilder.
9649     * Tolerant by construction: unknown shapes (including predicate
9650     * subqueries) fold into COMPLEX without leaking inner columns or
9651     * throwing. The flat {@code filterColumnRefs} are unaffected.
9652     */
9653    private static List<Predicate> buildFilterPredicates(TSelectSqlStatement select,
9654                                                         NameBindingProvider provider) {
9655        if (select == null || select.getWhereClause() == null
9656                || select.getWhereClause().getCondition() == null) {
9657            return Collections.emptyList();
9658        }
9659        return PredicateTreeBuilder.build(select.getWhereClause().getCondition(), provider);
9660    }
9661
9662    /** True iff {@code graph} contains a comma-join {@link SemanticJoinType#IMPLICIT_CROSS} entity. */
9663    private static boolean hasImplicitCrossJoin(JoinGraph graph) {
9664        for (JoinEntity e : graph.getJoins()) {
9665            if (e.getJoinType() == SemanticJoinType.IMPLICIT_CROSS) {
9666                return true;
9667            }
9668        }
9669        return false;
9670    }
9671
9672    /** Result of {@link #promoteImplicitJoinConditions}: the rewritten graph and residual WHERE filters. */
9673    private static final class ImplicitJoinPromotion {
9674        final JoinGraph graph;
9675        final List<Predicate> filterPredicates;
9676
9677        ImplicitJoinPromotion(JoinGraph graph, List<Predicate> filterPredicates) {
9678            this.graph = graph;
9679            this.filterPredicates = filterPredicates;
9680        }
9681    }
9682
9683    /**
9684     * Oracle implicit-join predicate promotion. For each top-level WHERE
9685     * conjunct that references aliases from BOTH sides of an
9686     * {@link SemanticJoinType#IMPLICIT_CROSS} comma-join entity, move its
9687     * {@link Predicate} into that entity's conditions and upgrade the entity's
9688     * join type. Aliases are read from the conjunct's AST (not the decomposed
9689     * predicate) so a {@code BETWEEN}'s range bounds — which collapse into a
9690     * single COMPLEX operand — are still seen as the right side.
9691     *
9692     * <p>The upgraded type depends on Oracle's {@code (+)} outer-join marker:
9693     * <ul>
9694     *   <li>no {@code (+)} → {@link SemanticJoinType#INNER};</li>
9695     *   <li>{@code (+)} on the column of the entity's RIGHT endpoint (the
9696     *       right side is null-producing) → {@link SemanticJoinType#LEFT};</li>
9697     *   <li>{@code (+)} on a column of the entity's LEFT endpoint (the left
9698     *       side is null-producing) → {@link SemanticJoinType#RIGHT}.</li>
9699     * </ul>
9700     * Direction is relative to the entity's endpoints, not operand position;
9701     * for GSP's left-deep comma model the LEFT endpoint is the accumulated
9702     * comma-join result and the RIGHT endpoint is the newly-added relation.
9703     * Multiple {@code (+)} conjuncts naming the same outer side all attach to
9704     * the one entity. A conjunct with {@code (+)} on both operands (illegal
9705     * Oracle) or conjuncts implying conflicting directions degrade the entity
9706     * back to {@link SemanticJoinType#IMPLICIT_CROSS} (its predicates stay
9707     * filters) rather than failing.
9708     *
9709     * <p>Each conjunct is assigned to the FIRST IMPLICIT_CROSS entity (in
9710     * FROM/graph order) whose right relation it references together with at
9711     * least one left-side relation, and all of whose referenced relations are
9712     * available at that boundary. A conjunct touching only one side, or no
9713     * comma-join boundary, stays a filter; an IMPLICIT_CROSS with no linking
9714     * conjunct stays a true cross product. The entity's {@code sourceSyntax}
9715     * stays {@link JoinSourceSyntax#COMMA}.
9716     */
9717    private static ImplicitJoinPromotion promoteImplicitJoinConditions(
9718            JoinGraph graph, TExpression whereCond, NameBindingProvider provider,
9719            OracleLegacyOuterJoinValidationResult oraclePlusValidation) {
9720        List<PredicateTreeBuilder.ConjunctPredicate> conjuncts =
9721                PredicateTreeBuilder.classifyConjuncts(whereCond, provider);
9722        List<JoinEntity> joins = graph.getJoins();
9723        JoinEndpointInstanceIndex endpointIndex =
9724                new JoinEndpointInstanceIndex(joins);
9725        int n = conjuncts.size();
9726        // Pass 1: assign each conjunct to the comma-join boundary it links,
9727        // and accumulate the Oracle (+) outer-join direction signal per entity.
9728        int[] assigned = new int[n];
9729        java.util.Arrays.fill(assigned, -1);
9730        boolean[] plusPresent = new boolean[n];
9731        java.util.Map<Integer, java.util.Set<SemanticJoinType>> dirByEntity =
9732                new java.util.HashMap<>();
9733        // Marked constant/function filters carry only the null-generated
9734        // relation. Resolve them to the earliest entity established by another
9735        // validated pair predicate in this same query block; in Oracle 12c+
9736        // one null-generated relation may have multiple preserved partners.
9737        java.util.Map<Integer, Integer> entityByNullGeneratedInstance =
9738                new java.util.HashMap<>();
9739        for (PredicateTreeBuilder.ConjunctPredicate cp : conjuncts) {
9740            PredicateJoinIntent intent =
9741                    oraclePlusValidation.intentForConjunct(cp.conjunct);
9742            if (intent == null
9743                    || intent.getValidity() != PredicateJoinIntent.Validity.VALID
9744                    || intent.isNullGeneratedFilter()
9745                    || intent.getNullGeneratedRelationInstanceId() == null) {
9746                continue;
9747            }
9748            int entityIndex = findImplicitJoinEntity(
9749                    joins, endpointIndex,
9750                    intent.getReferencedRelationInstanceIds());
9751            if (entityIndex < 0) continue;
9752            Integer nullId = intent.getNullGeneratedRelationInstanceId();
9753            Integer prior = entityByNullGeneratedInstance.get(nullId);
9754            if (prior == null || entityIndex < prior.intValue()) {
9755                entityByNullGeneratedInstance.put(
9756                        nullId, Integer.valueOf(entityIndex));
9757            }
9758        }
9759        // Entities to degrade: a (+) that is on both sides (illegal Oracle), or
9760        // a (+) we can detect but cannot classify to exactly one endpoint.
9761        java.util.Set<Integer> degradePlusEntities = new java.util.HashSet<>();
9762        for (int i = 0; i < n; i++) {
9763            PredicateTreeBuilder.ConjunctPredicate cp = conjuncts.get(i);
9764            PredicateJoinIntent validatedIntent =
9765                    oraclePlusValidation.intentForConjunct(cp.conjunct);
9766            if (validatedIntent != null
9767                    && validatedIntent.isNullGeneratedFilter()) {
9768                Integer target = entityByNullGeneratedInstance.get(
9769                        validatedIntent.getNullGeneratedRelationInstanceId());
9770                if (target != null && target.intValue() >= 0) {
9771                    assigned[i] = target.intValue();
9772                }
9773            } else {
9774                for (int ix = 0; ix < joins.size(); ix++) {
9775                    JoinEntity e = joins.get(ix);
9776                    if (e.getJoinType() != SemanticJoinType.IMPLICIT_CROSS) continue;
9777                    boolean links = validatedIntent != null
9778                            && validatedIntent.hasMarker()
9779                            ? conjunctInstanceIdsLinkImplicitJoin(
9780                                    endpointIndex, e,
9781                                    validatedIntent.getReferencedRelationInstanceIds())
9782                            : conjunctLinksImplicitJoin(e,
9783                                    collectConjunctRelationAliases(
9784                                            cp.conjunct, provider));
9785                    if (links) {
9786                        assigned[i] = ix;
9787                        break;
9788                    }
9789                }
9790            }
9791            int target = assigned[i];
9792            if (target < 0) continue;
9793            // Oracle (+) outer-join direction for this conjunct on its entity.
9794            // Deep-scan so a (+) anywhere in the conjunct is noticed even when
9795            // we cannot classify it (then we degrade rather than silently
9796            // treating it as an inner join).
9797            plusPresent[i] = validatedIntent != null
9798                    && validatedIntent.hasMarker();
9799            LegacyPlusAliasSignal compatibilitySignal = null;
9800            if (!plusPresent[i]) {
9801                compatibilitySignal = legacyPlusAliasSignal(
9802                        cp.conjunct, provider);
9803                plusPresent[i] = compatibilitySignal.markerPresent;
9804            }
9805            if (!plusPresent[i]) continue;
9806            if (validatedIntent != null) {
9807                if (validatedIntent.getValidity()
9808                        != PredicateJoinIntent.Validity.VALID) {
9809                    degradePlusEntities.add(target);
9810                    continue;
9811                }
9812                SemanticJoinType dir = oraclePlusJoinDirection(
9813                        endpointIndex, joins.get(target), validatedIntent);
9814                if (dir != null) {
9815                    dirByEntity.computeIfAbsent(target,
9816                            k -> new java.util.HashSet<SemanticJoinType>()).add(dir);
9817                } else {
9818                    degradePlusEntities.add(target);
9819                }
9820            } else if (compatibilitySignal.invalid) {
9821                degradePlusEntities.add(target);
9822            } else {
9823                SemanticJoinType dir = compatibilitySignal.outerAlias == null
9824                        ? null : oraclePlusJoinDirection(
9825                                joins.get(target), compatibilitySignal.outerAlias);
9826                if (dir != null) {
9827                    dirByEntity.computeIfAbsent(target,
9828                            k -> new java.util.HashSet<SemanticJoinType>()).add(dir);
9829                } else {
9830                    // (+) is present but unclassifiable (not a simple
9831                    // comparison, ambiguous operand, or outer column not an
9832                    // endpoint of this boundary) — degrade rather than guess.
9833                    degradePlusEntities.add(target);
9834                }
9835            }
9836        }
9837        // Pass 2: decide each linked entity's final join type. INNER when no
9838        // (+) is present; LEFT/RIGHT when all (+) conjuncts agree on direction;
9839        // DEGRADE (entity stays IMPLICIT_CROSS, its conjuncts fall back to
9840        // filters) when a (+) is invalid/unclassifiable or different conjuncts
9841        // imply conflicting directions.
9842        java.util.Map<Integer, SemanticJoinType> finalType = new java.util.HashMap<>();
9843        java.util.Set<Integer> degraded = new java.util.HashSet<>();
9844        for (int ix = 0; ix < joins.size(); ix++) {
9845            if (joins.get(ix).getJoinType() != SemanticJoinType.IMPLICIT_CROSS) continue;
9846            if (degradePlusEntities.contains(ix)) {
9847                degraded.add(ix);
9848                continue;
9849            }
9850            java.util.Set<SemanticJoinType> dirs = dirByEntity.get(ix);
9851            if (dirs == null || dirs.isEmpty()) {
9852                finalType.put(ix, SemanticJoinType.INNER);
9853            } else if (dirs.size() == 1) {
9854                finalType.put(ix, dirs.iterator().next());
9855            } else {
9856                degraded.add(ix);
9857            }
9858        }
9859        // Pass 3: in conjunct order, route each predicate to its entity's
9860        // condition list or to the residual filter list. For an OUTER (LEFT/
9861        // RIGHT) join only the (+)-marked conjuncts are join conditions: an
9862        // unmarked two-sided predicate is a post-join WHERE filter in Oracle
9863        // (it eliminates null-extended rows), so it must stay residual.
9864        java.util.Map<Integer, List<Predicate>> condsByEntity =
9865                new java.util.LinkedHashMap<>();
9866        List<Predicate> residualFilters = new ArrayList<>();
9867        for (int i = 0; i < n; i++) {
9868            int t = assigned[i];
9869            boolean asCondition = t >= 0 && !degraded.contains(t);
9870            if (asCondition) {
9871                SemanticJoinType ft = finalType.get(t);
9872                if (ft != SemanticJoinType.INNER && !plusPresent[i]) {
9873                    asCondition = false;
9874                }
9875            }
9876            if (asCondition) {
9877                condsByEntity.computeIfAbsent(t, k -> new ArrayList<Predicate>())
9878                        .add(conjuncts.get(i).predicate);
9879            } else {
9880                residualFilters.add(conjuncts.get(i).predicate);
9881            }
9882        }
9883        if (condsByEntity.isEmpty()) {
9884            return new ImplicitJoinPromotion(graph, residualFilters);
9885        }
9886        List<JoinEntity> upgraded = new ArrayList<>(joins.size());
9887        for (int ix = 0; ix < joins.size(); ix++) {
9888            JoinEntity e = joins.get(ix);
9889            List<Predicate> conds = condsByEntity.get(ix);
9890            if (conds == null || conds.isEmpty()) {
9891                upgraded.add(e);
9892            } else {
9893                upgraded.add(new JoinEntity(finalType.get(ix),
9894                        e.getLeftEndpoint(), e.getRightEndpoint(), e.getOrder(),
9895                        e.getSourceSyntax(), e.isNatural(), e.getUsingColumns(),
9896                        conds, e.getSourceSpan(), e.getConditionText(), e.isLateral()));
9897            }
9898        }
9899        return new ImplicitJoinPromotion(new JoinGraph(upgraded), residualFilters);
9900    }
9901
9902    /**
9903     * Compatibility-only typed signal for non-Oracle dialects whose parser
9904     * reuses the Oracle marker flag. Oracle builds consume validated instance
9905     * identities and never reach this alias-based fallback.
9906     *
9907     * <p>Returns {@code null} when there is no {@code (+)} (or it is not on a
9908     * simple comparison — the only shape Oracle's {@code (+)} attaches to), or
9909     * when the marked column's relation cannot be resolved (caller degrades
9910     * when a marker was nonetheless detected). Returns
9911     * <p>Operand subtrees are deep-scanned for the marker so a parenthesised
9912     * column ({@code (b.y(+))}) is recognised as well as a bare one.
9913     */
9914    private static LegacyPlusAliasSignal legacyPlusAliasSignal(
9915            TExpression conjunct, NameBindingProvider provider) {
9916        boolean markerPresent = expressionHasOraclePlus(conjunct);
9917        TExpression e = unwrapParenExpression(conjunct);
9918        if (e == null || e.getExpressionType() != EExpressionType.simple_comparison_t) {
9919            return new LegacyPlusAliasSignal(markerPresent, false, null);
9920        }
9921        TExpression lo = e.getLeftOperand();
9922        TExpression ro = e.getRightOperand();
9923        boolean leftPlus = expressionHasOraclePlus(lo);
9924        boolean rightPlus = expressionHasOraclePlus(ro);
9925        if (leftPlus && rightPlus) {
9926            return new LegacyPlusAliasSignal(true, true, null);
9927        }
9928        String alias = leftPlus
9929                ? soleOperandRelationAlias(lo, provider)
9930                : rightPlus ? soleOperandRelationAlias(ro, provider) : null;
9931        return new LegacyPlusAliasSignal(markerPresent, false, alias);
9932    }
9933
9934    private static final class LegacyPlusAliasSignal {
9935        private final boolean markerPresent;
9936        private final boolean invalid;
9937        private final String outerAlias;
9938
9939        private LegacyPlusAliasSignal(boolean markerPresent, boolean invalid,
9940                                      String outerAlias) {
9941            this.markerPresent = markerPresent;
9942            this.invalid = invalid;
9943            this.outerAlias = outerAlias;
9944        }
9945    }
9946
9947    /**
9948     * True iff {@code e} or any descendant (outside a nested subquery) carries
9949     * the Oracle {@code (+)} outer-join marker. Used both to notice a marker we
9950     * cannot otherwise classify and to find it under a {@code parenthesis_t}
9951     * operand wrapper.
9952     */
9953    private static boolean expressionHasOraclePlus(TExpression e) {
9954        if (e == null) return false;
9955        java.util.Set<TExpression> seen = java.util.Collections.newSetFromMap(
9956                new java.util.IdentityHashMap<TExpression, Boolean>());
9957        java.util.Deque<TExpression> work = new java.util.ArrayDeque<>();
9958        work.push(e);
9959        while (!work.isEmpty()) {
9960            TExpression current = work.pop();
9961            if (!seen.add(current)) continue;
9962            if (current.isOracleOuterJoin()) return true;
9963            if (current.getSubQuery() != null) continue;
9964            if (current.getExprList() != null) {
9965                for (int i = current.getExprList().size() - 1; i >= 0; i--) {
9966                    TExpression child = current.getExprList().getExpression(i);
9967                    if (child != null) work.push(child);
9968                }
9969            }
9970            if (current.getBetweenOperand() != null) {
9971                work.push(current.getBetweenOperand());
9972            }
9973            if (current.getRightOperand() != null) {
9974                work.push(current.getRightOperand());
9975            }
9976            if (current.getLeftOperand() != null) {
9977                work.push(current.getLeftOperand());
9978            }
9979        }
9980        return false;
9981    }
9982
9983    /**
9984     * The single relation alias an operand expression references, lower-cased,
9985     * or {@code null} when it references zero or more than one relation (an
9986     * Oracle {@code (+)} marks exactly one column).
9987     */
9988    private static String soleOperandRelationAlias(TExpression operand,
9989                                                   NameBindingProvider provider) {
9990        java.util.Set<String> aliases = collectConjunctRelationAliases(operand, provider);
9991        return aliases.size() == 1 ? aliases.iterator().next() : null;
9992    }
9993
9994    /**
9995     * Map an Oracle {@code (+)} outer (null-producing) relation alias to the
9996     * {@link SemanticJoinType} for {@code entity}, relative to its endpoints:
9997     * a null-producing RIGHT endpoint yields {@link SemanticJoinType#LEFT}
9998     * (the left side is preserved); a null-producing LEFT endpoint yields
9999     * {@link SemanticJoinType#RIGHT} (the right side is preserved). Returns
10000     * {@code null} when the outer alias is not one of this entity's endpoints.
10001     */
10002    private static SemanticJoinType oraclePlusJoinDirection(JoinEntity entity,
10003                                                            String outerAliasLc) {
10004        String right = entity.getRightEndpoint().getAlias();
10005        if (right != null && right.toLowerCase(Locale.ROOT).equals(outerAliasLc)) {
10006            return SemanticJoinType.LEFT;
10007        }
10008        for (String left : endpointAliasesLowerCase(entity.getLeftEndpoint())) {
10009            if (left.equals(outerAliasLc)) {
10010                return SemanticJoinType.RIGHT;
10011            }
10012        }
10013        return null;
10014    }
10015
10016    /** Map a validated relation-instance intent to this join boundary. */
10017    private static SemanticJoinType oraclePlusJoinDirection(
10018            JoinEndpointInstanceIndex endpointIndex, JoinEntity entity,
10019            PredicateJoinIntent intent) {
10020        if (entity == null || entity.getRightEndpoint() == null
10021                || intent == null) {
10022            return null;
10023        }
10024        java.util.Set<Integer> rightIds = endpointIndex.resolve(
10025                entity.getRightEndpoint());
10026        java.util.Set<Integer> leftIds = endpointIndex.resolve(
10027                entity.getLeftEndpoint());
10028        Integer nullId = intent.getNullGeneratedRelationInstanceId();
10029        if (nullId != null && rightIds.contains(nullId)) {
10030            return SemanticJoinType.LEFT;
10031        }
10032        Integer preservedId = intent.getPreservedRelationInstanceId();
10033        if (nullId != null && leftIds.contains(nullId)
10034                && (preservedId == null || rightIds.contains(preservedId))) {
10035            return SemanticJoinType.RIGHT;
10036        }
10037        return null;
10038    }
10039
10040    /** Unwrap nested parenthesis expressions iteratively; returns the inner non-paren expression. */
10041    private static TExpression unwrapParenExpression(TExpression e) {
10042        while (e != null && e.getExpressionType() == EExpressionType.parenthesis_t
10043                && e.getLeftOperand() != null) {
10044            e = e.getLeftOperand();
10045        }
10046        return e;
10047    }
10048
10049    /**
10050     * True iff the comma-join {@code entity} is the boundary at which a WHERE
10051     * conjunct referencing {@code aliases} should be evaluated. Three
10052     * conditions must hold:
10053     *
10054     * <ul>
10055     *   <li>the conjunct references the entity's right relation (so the
10056     *       newly-added relation actually participates — otherwise the
10057     *       predicate belongs to an earlier boundary);</li>
10058     *   <li>it references at least one left-side relation (so it is genuinely
10059     *       two-sided, not a single-table filter);</li>
10060     *   <li>EVERY relation it references is available at this boundary — i.e.
10061     *       contained in the left aliases plus the right alias. This prevents
10062     *       a predicate that also touches a not-yet-joined relation (e.g.
10063     *       {@code a.x + c.z = b.y} at the {@code a×b} boundary) from being
10064     *       promoted too early; it is instead picked up at the later boundary
10065     *       where its last relation is added.</li>
10066     * </ul>
10067     */
10068    private static boolean conjunctLinksImplicitJoin(JoinEntity entity,
10069                                                     java.util.Set<String> aliases) {
10070        String right = entity.getRightEndpoint().getAlias();
10071        if (right == null || right.isEmpty()) return false;
10072        String rightLc = right.toLowerCase(Locale.ROOT);
10073        if (!aliases.contains(rightLc)) return false;
10074        java.util.Set<String> available = new java.util.HashSet<>();
10075        available.add(rightLc);
10076        boolean linksLeft = false;
10077        for (String left : endpointAliasesLowerCase(entity.getLeftEndpoint())) {
10078            available.add(left);
10079            if (!left.equals(rightLc) && aliases.contains(left)) {
10080                linksLeft = true;
10081            }
10082        }
10083        if (!linksLeft) return false;
10084        // All referenced relations must be available at this boundary.
10085        return available.containsAll(aliases);
10086    }
10087
10088    /** Instance-identity counterpart used for validated Oracle (+) facts. */
10089    private static boolean conjunctInstanceIdsLinkImplicitJoin(
10090            JoinEndpointInstanceIndex endpointIndex, JoinEntity entity,
10091            java.util.Set<Integer> relationInstanceIds) {
10092        if (entity == null || entity.getRightEndpoint() == null
10093                || relationInstanceIds == null
10094                || relationInstanceIds.size() < 2) {
10095            return false;
10096        }
10097        java.util.Set<Integer> left = endpointIndex.resolve(
10098                entity.getLeftEndpoint());
10099        java.util.Set<Integer> right = endpointIndex.resolve(
10100                entity.getRightEndpoint());
10101        java.util.Set<Integer> covered = new java.util.HashSet<>(left);
10102        covered.addAll(right);
10103        return !java.util.Collections.disjoint(relationInstanceIds, left)
10104                && !java.util.Collections.disjoint(relationInstanceIds, right)
10105                && covered.containsAll(relationInstanceIds);
10106    }
10107
10108    private static int findImplicitJoinEntity(
10109            List<JoinEntity> joins,
10110            JoinEndpointInstanceIndex endpointIndex,
10111            java.util.Set<Integer> relationInstanceIds) {
10112        for (int i = 0; i < joins.size(); i++) {
10113            JoinEntity entity = joins.get(i);
10114            if (entity.getJoinType() == SemanticJoinType.IMPLICIT_CROSS
10115                    && conjunctInstanceIdsLinkImplicitJoin(
10116                            endpointIndex, entity, relationInstanceIds)) {
10117                return i;
10118            }
10119        }
10120        return -1;
10121    }
10122
10123    /**
10124     * Query-block index resolving RELATION/JOIN_RESULT endpoints to exact
10125     * relation instance ids. The producing-join map and endpoint results are
10126     * built once per promotion pass; traversal stays iterative for deep join
10127     * chains.
10128     */
10129    private static final class JoinEndpointInstanceIndex {
10130        private final java.util.Map<Integer, JoinEntity> byOrder =
10131                new java.util.HashMap<>();
10132        private final java.util.Map<JoinEndpoint, java.util.Set<Integer>> cache =
10133                new java.util.IdentityHashMap<>();
10134
10135        private JoinEndpointInstanceIndex(List<JoinEntity> joins) {
10136            for (JoinEntity entity : joins) {
10137                byOrder.put(Integer.valueOf(entity.getOrder()), entity);
10138            }
10139        }
10140
10141        private java.util.Set<Integer> resolve(JoinEndpoint endpoint) {
10142            if (endpoint == null) return java.util.Collections.emptySet();
10143            java.util.Set<Integer> cached = cache.get(endpoint);
10144            if (cached != null) return cached;
10145
10146            java.util.Set<Integer> ids = new java.util.LinkedHashSet<>();
10147            java.util.Deque<JoinEndpoint> work = new java.util.ArrayDeque<>();
10148            work.push(endpoint);
10149            java.util.Set<JoinEndpoint> seen =
10150                    java.util.Collections.newSetFromMap(
10151                            new java.util.IdentityHashMap<JoinEndpoint, Boolean>());
10152            while (!work.isEmpty()) {
10153                JoinEndpoint current = work.pop();
10154                if (!seen.add(current)) continue;
10155                if (current.getKind() == JoinEndpointKind.RELATION) {
10156                    if (current.getInstanceId() != JoinEndpoint.NO_INSTANCE_ID) {
10157                        ids.add(Integer.valueOf(current.getInstanceId()));
10158                    }
10159                    continue;
10160                }
10161                if (current.getKind() == JoinEndpointKind.JOIN_RESULT) {
10162                    JoinEntity producer = byOrder.get(
10163                            Integer.valueOf(current.getProducingJoinOrder()));
10164                    if (producer != null) {
10165                        work.push(producer.getRightEndpoint());
10166                        work.push(producer.getLeftEndpoint());
10167                    }
10168                }
10169            }
10170            java.util.Set<Integer> result =
10171                    java.util.Collections.unmodifiableSet(ids);
10172            cache.put(endpoint, result);
10173            return result;
10174        }
10175    }
10176
10177    /** Lower-cased relation aliases an endpoint represents (RELATION → one; JOIN_RESULT → all contributors). */
10178    private static java.util.List<String> endpointAliasesLowerCase(JoinEndpoint ep) {
10179        java.util.List<String> out = new ArrayList<>();
10180        if (ep == null) return out;
10181        if (ep.getKind() == JoinEndpointKind.RELATION) {
10182            if (ep.getAlias() != null && !ep.getAlias().isEmpty()) {
10183                out.add(ep.getAlias().toLowerCase(Locale.ROOT));
10184            }
10185        } else if (ep.getKind() == JoinEndpointKind.JOIN_RESULT) {
10186            for (String a : ep.getContributingAliases()) {
10187                if (a != null && !a.isEmpty()) {
10188                    out.add(a.toLowerCase(Locale.ROOT));
10189                }
10190            }
10191        }
10192        return out;
10193    }
10194
10195    /**
10196     * Collect the lower-cased relation aliases a WHERE conjunct references, by
10197     * walking its column {@link TObjectName}s. Each column contributes exactly
10198     * ONE canonical alias: the resolver-bound relation alias (which matches the
10199     * join endpoints' {@code effectiveAliasOf}, even when the column is written
10200     * with the table name rather than the alias); when the column does not
10201     * bind, the SQL-written qualifier is the fallback. A column that neither
10202     * binds nor carries a qualifier (a bare unqualified name that the resolver
10203     * cannot place) contributes nothing — it cannot pick a side deterministically.
10204     *
10205     * <p>One canonical alias per column keeps the set comparable to the
10206     * endpoint alias sets, which the containment check in
10207     * {@link #conjunctLinksImplicitJoin} relies on. Nested subqueries are not
10208     * descended into.
10209     */
10210    private static java.util.Set<String> collectConjunctRelationAliases(
10211            TExpression conjunct, final NameBindingProvider provider) {
10212        final java.util.Set<String> aliases = new java.util.HashSet<>();
10213        conjunct.acceptChildren(new TParseTreeVisitor() {
10214            int nestedSelectDepth = 0;
10215
10216            @Override
10217            public void preVisit(TSelectSqlStatement nested) {
10218                nestedSelectDepth++;
10219            }
10220
10221            @Override
10222            public void postVisit(TSelectSqlStatement nested) {
10223                nestedSelectDepth--;
10224            }
10225
10226            @Override
10227            public void preVisit(TObjectName node) {
10228                if (nestedSelectDepth > 0) return;
10229                if (node.getDbObjectType() != EDbObjectType.column) return;
10230                ColumnBinding binding = provider.bindColumn(node);
10231                if (binding != null && binding.getRelationAlias() != null
10232                        && !binding.getRelationAlias().isEmpty()) {
10233                    aliases.add(binding.getRelationAlias().toLowerCase(Locale.ROOT));
10234                    return;
10235                }
10236                String qualifier = node.getTableString();
10237                if (qualifier != null && !qualifier.isEmpty()) {
10238                    aliases.add(qualifier.toLowerCase(Locale.ROOT));
10239                }
10240            }
10241        });
10242        return aliases;
10243    }
10244
10245    // -----------------------------------------------------------------
10246    // Slice 129 — PIVOT sub-slice (a): source binding + consumed-refs slot.
10247    //
10248    // A PIVOT FROM source is a TTable of type pivoted_table carrying a
10249    // TPivotedTable: getRelations().get(0) is the underlying source relation
10250    // and getPivotClause() carries the aggregation function(s), the FOR /
10251    // pivot column(s), and the IN-list. Before slice 129 this rejected with
10252    // a misleading generic TABLE_BINDING_UNRESOLVED ("could not bind table
10253    // null(...)") because bindRelation returns null for a pivoted_table.
10254    //
10255    // Sub-slice (a) admits only the narrow base-table-source / explicit-
10256    // projection / no-other-clauses shape; it binds the source relation and
10257    // captures the consumed FOR + aggregation-arg columns into the new
10258    // StatementGraph.pivotColumnRefs slot. Output-column lineage and
10259    // SELECT * expansion are sub-slice (b); UNPIVOT is (c); subquery source,
10260    // chained PIVOT, extra clauses and nested PIVOT are later sub-slices and
10261    // reject here with structured PIVOT_* diagnostics.
10262    // -----------------------------------------------------------------
10263
10264    /**
10265     * True when {@code select}'s sole FROM source is a PIVOT/UNPIVOT operator
10266     * with no trailing JOINs. A trailing JOIN on the pivot result
10267     * (e.g. {@code FROM t PIVOT(...) p JOIN x ON ...}) is out of scope for
10268     * slice 129 and stays on the normal path.
10269     */
10270    private static boolean isPivotSelect(TSelectSqlStatement select) {
10271        if (select.joins == null || select.joins.size() != 1) {
10272            return false;
10273        }
10274        TJoin join = select.joins.getJoin(0);
10275        if (join == null) {
10276            return false;
10277        }
10278        TJoinItemList items = join.getJoinItems();
10279        if (items != null && items.size() > 0) {
10280            return false;
10281        }
10282        TTable t = join.getTable();
10283        return t != null
10284                && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
10285                && t.getPivotedTable() != null;
10286    }
10287
10288    /**
10289     * Slice 143 — mirror of {@link #isPivotSelect} but for the PIVOT-then-JOIN
10290     * shape: a single explicit JOIN ({@code joinItems} PRESENT) whose driver
10291     * table is a {@code pivoted_table}. Routes to a precise deferral reject
10292     * (reusing {@code PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED}) instead of the
10293     * generic {@code TABLE_BINDING_UNRESOLVED} the normal path would emit.
10294     */
10295    private static boolean isPivotWithJoinPartner(TSelectSqlStatement select) {
10296        if (select.joins == null || select.joins.size() != 1) {
10297            return false;
10298        }
10299        TJoin join = select.joins.getJoin(0);
10300        if (join == null) {
10301            return false;
10302        }
10303        TJoinItemList items = join.getJoinItems();
10304        if (items == null || items.size() == 0) {
10305            return false;
10306        }
10307        TTable t = join.getTable();
10308        return t != null
10309                && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
10310                && t.getPivotedTable() != null;
10311    }
10312
10313    /**
10314     * Slice 144 — find a PIVOT/UNPIVOT operator that appears as a JOIN
10315     * PARTNER rather than the sole FROM driver. Two positions are covered:
10316     * <ul>
10317     *   <li>the right-hand side of an explicit JOIN
10318     *       ({@code FROM dim d JOIN sales PIVOT(...) AS p ON ...}), and</li>
10319     *   <li>a comma-join sibling ({@code FROM sales PIVOT(...) AS p, dim d}),
10320     *       which the parser represents as {@code select.joins.size() > 1}.</li>
10321     * </ul>
10322     *
10323     * <p>The admitted single-PIVOT shape ({@link #isPivotSelect}) and the
10324     * slice-143 pivot-as-driver-with-a-join shape
10325     * ({@link #isPivotWithJoinPartner}) are both routed earlier and never reach
10326     * here, so this helper only upgrades shapes that would otherwise fall to the
10327     * generic {@code TABLE_BINDING_UNRESOLVED}. The driver of the SOLE
10328     * {@code TJoin} is the slice-129/-143 driver and is skipped; for comma-join
10329     * siblings ({@code size > 1}) every {@code TJoin} is an independent FROM
10330     * item whose driver may itself be the pivot.
10331     *
10332     * @return the pivoted {@link TTable} to anchor the diagnostic on, or null.
10333     */
10334    private static TTable findPivotJoinPartner(TSelectSqlStatement select) {
10335        if (select.joins == null) {
10336            return null;
10337        }
10338        boolean singleJoin = select.joins.size() == 1;
10339        for (int j = 0; j < select.joins.size(); j++) {
10340            TJoin join = select.joins.getJoin(j);
10341            if (join == null) {
10342                continue;
10343            }
10344            if (!singleJoin && isPivotedTable(join.getTable())) {
10345                return join.getTable();
10346            }
10347            TJoinItemList items = join.getJoinItems();
10348            if (items != null) {
10349                for (int i = 0; i < items.size(); i++) {
10350                    if (items.getJoinItem(i) != null
10351                            && isPivotedTable(items.getJoinItem(i).getTable())) {
10352                        return items.getJoinItem(i).getTable();
10353                    }
10354                }
10355            }
10356        }
10357        return null;
10358    }
10359
10360    /**
10361     * Slice 146 — the SQL keyword ("PIVOT" or "UNPIVOT") naming the row-shaping
10362     * operator carried by {@code pivotedTable}, used to make the JOIN-deferral
10363     * message name the actual operator instead of always saying "PIVOT".
10364     * Defaults to "PIVOT" when the clause is absent (e.g. a chained-clause-list
10365     * shape where {@link TPivotClause#getType()} is not reachable via
10366     * {@code getPivotClause()}), which is harmless for the deferral message.
10367     */
10368    private static String pivotOperatorKeyword(TTable pivotedTable) {
10369        if (pivotedTable != null && pivotedTable.getPivotedTable() != null) {
10370            TPivotedTable pivoted = pivotedTable.getPivotedTable();
10371            // Slice 149 — a CHAINED pivoted_table (clause list > 1) has no single
10372            // getPivotClause(): that accessor surfaces only ONE of the chained
10373            // clauses, so a MIXED chain (PIVOT(...) UNPIVOT(...)) as the JOIN
10374            // driver/partner would name just that one operator. Delegate to the
10375            // slice-148 list-walking helper so a mixed chain names the generic
10376            // "PIVOT / UNPIVOT" (an all-PIVOT chain still names "PIVOT", an
10377            // all-UNPIVOT chain still names "UNPIVOT" — byte-identical to before).
10378            if (pivoted.getPivotClauseList() != null
10379                    && pivoted.getPivotClauseList().size() > 1) {
10380                return chainedPivotOperatorKeyword(pivoted);
10381            }
10382            TPivotClause pc = pivoted.getPivotClause();
10383            if (pc != null && pc.getType() != TPivotClause.pivot) {
10384                return "UNPIVOT";
10385            }
10386        }
10387        return "PIVOT";
10388    }
10389
10390    /**
10391     * Slice 148 — the SQL keyword(s) naming the operator(s) carried by a
10392     * CHAINED {@code pivoted_table} ({@code pivotClauseList.size() > 1}), used to
10393     * make the {@code PIVOT_MULTIPLE_CLAUSES_NOT_SUPPORTED} deferral message name
10394     * the actual operator(s) instead of always saying "PIVOT / UNPIVOT".
10395     * Inspects every clause's {@link TPivotClause#getType()}: all PIVOT &rarr;
10396     * "PIVOT"; all UNPIVOT &rarr; "UNPIVOT"; a genuine mix (or an empty/unknown
10397     * list) &rarr; the generic "PIVOT / UNPIVOT". Unlike
10398     * {@link #pivotOperatorKeyword}, this reads the whole clause LIST because the
10399     * single {@code getPivotClause()} accessor is null for a chained shape.
10400     */
10401    private static String chainedPivotOperatorKeyword(TPivotedTable pivoted) {
10402        boolean sawPivot = false;
10403        boolean sawUnpivot = false;
10404        if (pivoted != null && pivoted.getPivotClauseList() != null) {
10405            for (int i = 0; i < pivoted.getPivotClauseList().size(); i++) {
10406                TPivotClause pc = pivoted.getPivotClauseList().getElement(i);
10407                if (pc == null) {
10408                    continue;
10409                }
10410                if (pc.getType() == TPivotClause.pivot) {
10411                    sawPivot = true;
10412                } else {
10413                    sawUnpivot = true;
10414                }
10415            }
10416        }
10417        if (sawPivot && !sawUnpivot) {
10418            return "PIVOT";
10419        }
10420        if (sawUnpivot && !sawPivot) {
10421            return "UNPIVOT";
10422        }
10423        return "PIVOT / UNPIVOT";
10424    }
10425
10426    /**
10427     * Slice 150 — true when {@code pc} is Oracle's PIVOT XML variant
10428     * ({@code PIVOT XML (...)}). PIVOT XML aggregates all pivoted values into a
10429     * SINGLE XMLType output column rather than producing one column per IN-list
10430     * value, so its output-column lineage differs from a regular PIVOT and
10431     * {@link #buildPivotSelect} defers it.
10432     *
10433     * <p>The parser exposes no XML flag on {@link TPivotClause}; the only signal
10434     * is the clause's start token, which is the {@code XML} keyword when the XML
10435     * modifier is present (it is the opening {@code (} otherwise). UNPIVOT has no
10436     * XML variant, so this only matches a {@code pivot}-type clause.
10437     */
10438    private static boolean isPivotXml(TPivotClause pc) {
10439        if (pc == null || pc.getType() != TPivotClause.pivot) {
10440            return false;
10441        }
10442        TSourceToken start = pc.getStartToken();
10443        return start != null && "XML".equalsIgnoreCase(start.astext);
10444    }
10445
10446    /**
10447     * Slice 144 — true when {@code t} is a PIVOT/UNPIVOT operator
10448     * ({@code pivoted_table} carrying a {@code TPivotedTable}).
10449     */
10450    private static boolean isPivotedTable(TTable t) {
10451        return t != null
10452                && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
10453                && t.getPivotedTable() != null;
10454    }
10455
10456    /**
10457     * Slice 129 — build the StatementGraph for a PIVOT-source SELECT
10458     * (see {@link #isPivotSelect}). Applies the admit-set rejects in a fixed
10459     * order, binds the underlying source relation, captures the consumed
10460     * FOR + aggregation-arg columns, and emits output-column NAMES with empty
10461     * sources (lineage deferred to a later sub-slice).
10462     */
10463    private static StatementGraph buildPivotSelect(TSelectSqlStatement select,
10464                                                   NameBindingProvider provider,
10465                                                   String name) {
10466        TTable pivotTable = select.joins.getJoin(0).getTable();
10467        TPivotedTable pivoted = pivotTable.getPivotedTable();
10468
10469        // 1. single pivot clause only (chained PIVOTs deferred).
10470        if (pivoted.getPivotClauseList() != null
10471                && pivoted.getPivotClauseList().size() > 1) {
10472            // Slice 148 — name the ACTUAL chained operator(s) (all PIVOT,
10473            // all UNPIVOT, or a genuine mix), completing the message-operator
10474            // consistency begun on the JOIN-deferral (slice 146) and
10475            // extra-clause-deferral (slice 147) paths.
10476            throw new SemanticIRBuildException(
10477                    Diagnostic.error(DiagnosticCode.PIVOT_MULTIPLE_CLAUSES_NOT_SUPPORTED,
10478                    "chained " + chainedPivotOperatorKeyword(pivoted)
10479                            + " clauses are not supported yet", pivotTable));
10480        }
10481        TPivotClause pc = pivoted.getPivotClause();
10482        if (pc == null) {
10483            throw new SemanticIRBuildException(
10484                    Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED,
10485                    "could not bind PIVOT source (no pivot clause)", pivotTable));
10486        }
10487        // 1b. Slice 150 — Oracle PIVOT XML is a fundamentally different shape:
10488        //     it produces a SINGLE XMLType output column (an XML aggregate of
10489        //     all pivoted values), NOT one column per IN-list value. Treating
10490        //     it as a regular PIVOT (the only path below) would emit incorrect
10491        //     output-column lineage, so defer it with the generic
10492        //     PIVOT/UNPIVOT-unsupported code. Checked here — before the
10493        //     UNPIVOT-shape, extra-clause, and bare-* handling — so the XML
10494        //     limitation is the reason reported regardless of the other clauses.
10495        if (isPivotXml(pc)) {
10496            throw new SemanticIRBuildException(
10497                    Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED,
10498                    "PIVOT XML is not supported yet (the single XMLType output "
10499                            + "column has different lineage than a regular PIVOT)",
10500                    pivotTable));
10501        }
10502        // 2. UNPIVOT sub-slice (c): the deterministic single-value-column /
10503        //    simple-IN-list shape is now supported (see buildUnpivotOutputColumns).
10504        //    A multi-value-column or column-group ((a,b) ...) UNPIVOT stays
10505        //    deferred — checked here so PIVOT_UNPIVOT_NOT_SUPPORTED stays reached
10506        //    and the harder positional mapping is not mis-attributed.
10507        boolean isUnpivot = pc.getType() != TPivotClause.pivot;
10508        if (isUnpivot) {
10509            rejectUnsupportedUnpivotShape(pc, pivotTable);
10510        }
10511        // 3. extra query clauses deferred (WHERE / GROUP BY / HAVING /
10512        //    ORDER BY / QUALIFY / DISTINCT / row-limit / set-op). Processing
10513        //    them would hit the same resolver NOT_FOUND quirk that forces the
10514        //    direct consumed-ref construction below.
10515        rejectPivotExtraClauses(select, pivotTable);
10516        // 4. source must be a base table (subquery source deferred).
10517        if (pivoted.getRelations().isEmpty() || pivoted.getRelations().get(0) == null) {
10518            throw new SemanticIRBuildException(
10519                    Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED,
10520                    "could not bind PIVOT source (no source relation)", pivotTable));
10521        }
10522        TTable sourceTable = pivoted.getRelations().get(0);
10523        // Slice 136: a FROM-subquery source is admitted for EXPLICIT projections.
10524        // The inner SELECT was already extracted as its own statement by the
10525        // processDirectSubqueryTable pre-pass (which unwraps the pivoted_table);
10526        // binding here with allowFromSubqueries=true yields a SUBQUERY-kind
10527        // relation whose alias matches the registered subquery-statement alias, so
10528        // emitLineageForStatement wires the pivot output → subquery output edges.
10529        // Slice 137: a bare SELECT * over a subquery source is now ALSO admitted —
10530        // the slice-137 addRelationToInScopeMap unwrap registers the subquery's
10531        // published columns under the source alias, so expandPivot/UnpivotBareStar
10532        // can synthesise the passthrough schema without an external catalog.
10533        RelationSource sourceRel = buildRelation(sourceTable, provider, /*allowFromSubqueries=*/ true);
10534
10535        // 4b. Slice 156 (PIVOT) / slice 157 (UNPIVOT) — a WHERE clause over a
10536        //     PIVOT or UNPIVOT is now admitted when every ref is a PROVABLE
10537        //     passthrough source column (resolved directly to the underlying
10538        //     source column); anything else (a pivot-output / UNPIVOT value or
10539        //     FOR-name / consumed / non-provable ref, or a subquery predicate)
10540        //     keeps the slice-129 deferral. Resolved BEFORE output building so the
10541        //     WHERE deferral wins over a (deferred) output shape, mirroring the
10542        //     previous rejectPivotExtraClauses precedence.
10543        List<ColumnRef> filterRefs = buildPivotWhereColumnRefs(select, pc, isUnpivot,
10544                sourceRel.getAlias(), sourceTable, provider, pivotTable);
10545
10546        // 4c. Slice 158 — a GROUP BY clause over a PIVOT or UNPIVOT is now
10547        //     admitted (into groupByColumnRefs) when every grouped column is a
10548        //     PROVABLE passthrough source column, the sibling of the slice-156/157
10549        //     WHERE admission. Resolved AFTER the WHERE so a WHERE deferral wins
10550        //     over a GROUP BY deferral (preserving the previous
10551        //     rejectPivotExtraClauses precedence, which checked WHERE before
10552        //     GROUP BY). A GROUP BY paired with a HAVING already deferred earlier
10553        //     in rejectPivotExtraClauses (HAVING branch), so it never reaches here.
10554        List<ColumnRef> groupByRefs = buildPivotGroupByColumnRefs(select, pc, isUnpivot,
10555                sourceRel.getAlias(), sourceTable, provider, pivotTable);
10556
10557        // 4d. Slice 160 — a HAVING clause over a PIVOT or UNPIVOT is now admitted
10558        //     (into havingColumnRefs) when every column the HAVING touches is a
10559        //     PROVABLE passthrough source column, the sibling of the slice-156/157
10560        //     WHERE, slice-158 GROUP BY, and slice-159 QUALIFY admissions. Resolved
10561        //     AFTER the WHERE and GROUP BY and BEFORE the QUALIFY — the natural SQL
10562        //     clause order (WHERE, GROUP BY, HAVING, QUALIFY) — so a deferred WHERE
10563        //     / GROUP BY wins over a deferred HAVING, and a deferred HAVING wins
10564        //     over a deferred QUALIFY.
10565        List<ColumnRef> havingRefs = buildPivotHavingColumnRefs(select, pc, isUnpivot,
10566                sourceRel.getAlias(), sourceTable, provider, pivotTable);
10567
10568        // 4e. Slice 159 — a QUALIFY clause over a PIVOT or UNPIVOT is now admitted
10569        //     (into qualifyColumnRefs) when every column the QUALIFY touches is a
10570        //     PROVABLE passthrough source column, the sibling of the slice-156/157
10571        //     WHERE, slice-158 GROUP BY, and slice-160 HAVING admissions. Resolved
10572        //     AFTER the WHERE, GROUP BY, and HAVING so any earlier-clause deferral
10573        //     wins — the natural SQL clause order (WHERE, GROUP BY, HAVING,
10574        //     QUALIFY).
10575        List<ColumnRef> qualifyRefs = buildPivotQualifyColumnRefs(select, pc, isUnpivot,
10576                sourceRel.getAlias(), sourceTable, provider, pivotTable);
10577
10578        // 4f. Slice 161 - an ORDER BY clause over a PIVOT or UNPIVOT is already
10579        //     admitted (slice 142, lineage-neutral); this records its
10580        //     orderByColumnRefs when every sort-key column is a PROVABLE passthrough
10581        //     source column, the sibling of the slice-156/157 WHERE, slice-158 GROUP
10582        //     BY, slice-159 QUALIFY, and slice-160 HAVING admissions. UNLIKE those,
10583        //     it NEVER defers: a non-passthrough / unprovable / subquery sort key
10584        //     simply leaves the slot empty (the lineage-neutral slice-142 behaviour).
10585        List<ColumnRef> orderByRefs = buildPivotOrderByColumnRefs(select, pc, isUnpivot,
10586                sourceRel.getAlias(), sourceTable, provider);
10587
10588        // 5. + 6. consumed refs and output-column lineage, branched on PIVOT
10589        //    vs UNPIVOT. PIVOT consumes the FOR + aggregation-arg columns and
10590        //    its outputs are the passthrough + pivot-output columns; UNPIVOT
10591        //    consumes the IN-list source columns and its outputs are the
10592        //    passthrough + value + FOR columns (slice 131 sub-slice (c)).
10593        List<ColumnRef> consumedRefs;
10594        List<OutputColumn> outputs;
10595        if (isUnpivot) {
10596            consumedRefs = collectUnpivotInListRefs(pc, sourceRel.getAlias());
10597            outputs = buildUnpivotOutputColumns(select, pivotTable, pc,
10598                    sourceRel.getAlias(), sourceTable, provider);
10599        } else {
10600            consumedRefs = collectPivotConsumedRefs(pc, sourceRel.getAlias());
10601            outputs = buildPivotOutputColumns(select, pivotTable, pc,
10602                    sourceRel.getAlias(), sourceTable, provider);
10603        }
10604
10605        // Slice 180 (R5): PIVOT/UNPIVOT SELECT blocks are built here, not at
10606        // the main SELECT site, so set their block span too.
10607        return new StatementGraph(name, "SELECT",
10608                Collections.singletonList(sourceRel), outputs, filterRefs, groupByRefs,
10609                havingRefs, qualifyRefs, orderByRefs, consumedRefs)
10610                .withSourceSpan(SourceSpan.of(select));
10611    }
10612
10613    /**
10614     * Slice 129 — reject a PIVOT SELECT that carries a COLUMN-TOUCHING query
10615     * clause beyond the projection and the FROM-PIVOT source. One
10616     * {@code DiagnosticCode} with clause-discriminated message text (the
10617     * deferral is the same semantic boundary: "PIVOT combined with an
10618     * additional query clause is not supported yet"). Deferred because
10619     * resolving a {@code WHERE} / {@code GROUP BY} / {@code HAVING} /
10620     * {@code QUALIFY} column reference needs to disambiguate a passthrough
10621     * SOURCE column from a PIVOT-OUTPUT column.
10622     *
10623     * <p>Slice 142 — the ROW-SHAPING clauses ({@code ORDER BY}, {@code DISTINCT},
10624     * and the row-limit clauses {@code TOP} / {@code LIMIT} / {@code OFFSET} /
10625     * {@code FETCH FIRST}) are NO LONGER rejected: they only re-shape the row
10626     * SET (order / dedup / truncate) and do not change the output COLUMN set or
10627     * the column lineage, which {@link #buildPivotSelect} reads only from the
10628     * pivot clause, the projection, and the source. Admitting them is therefore
10629     * lineage-neutral — the resulting {@code StatementGraph} is byte-identical
10630     * to the same query without the clause. The (defensive) set-operator branch
10631     * stays: a pivot SELECT node never carries a set operator in practice
10632     * ({@link #isPivotSelect} requires a FROM-PIVOT, which a union node has not),
10633     * but the guard is kept for robustness.
10634     */
10635    private static void rejectPivotExtraClauses(TSelectSqlStatement select, TTable pivotTable) {
10636        String clause = null;
10637        // Slice 156 — the WHERE clause is no longer rejected here; it is handled
10638        // by buildPivotWhereColumnRefs (called after the source is bound), which
10639        // ADMITS a WHERE whose every ref is a provable passthrough source column
10640        // and otherwise throws the same PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED
10641        // deferral. Slice 158 — likewise the GROUP BY clause is now handled by
10642        // buildPivotGroupByColumnRefs (passthrough-only admission). Slice 159 —
10643        // likewise the QUALIFY clause is now handled by buildPivotQualifyColumnRefs
10644        // (passthrough-only admission). Slice 160 — likewise the HAVING clause is
10645        // now handled by buildPivotHavingColumnRefs (passthrough-only admission).
10646        // The only remaining column-touching clause that still defers
10647        // unconditionally here is a set operator (a pivot SELECT node never carries
10648        // one in practice, but the guard is kept defensively). The WHERE / GROUP BY
10649        // / HAVING / QUALIFY admission helpers run after this guard, in natural SQL
10650        // clause order, so a deferred earlier clause wins over a deferred later one.
10651        if (select.getSetOperatorType() != ESetOperatorType.none) {
10652            clause = "a set operator";
10653        }
10654        if (clause != null) {
10655            // Slice 147 — name the ACTUAL operator (PIVOT vs UNPIVOT) via the
10656            // shared slice-146 helper, completing the message-operator
10657            // consistency begun on the JOIN-deferral path.
10658            throw new SemanticIRBuildException(
10659                    Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10660                    pivotOperatorKeyword(pivotTable) + " combined with " + clause
10661                            + " is not supported yet", pivotTable));
10662        }
10663    }
10664
10665    /**
10666     * Slice 156 (PIVOT) / slice 157 (UNPIVOT) — resolve the column refs of a
10667     * {@code WHERE} clause over a PIVOT or UNPIVOT, admitting the clause only when
10668     * EVERY referenced column is a PROVABLE passthrough source column. Returns the
10669     * resolved refs (document order, deduped) for the {@code StatementGraph}
10670     * filterColumnRefs slot, or an empty list when there is no WHERE.
10671     *
10672     * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the
10673     * slice-129 deferral, code kept reached) when the WHERE cannot be admitted:
10674     * the WHERE contains a subquery predicate; or any ref is NOT a provable
10675     * passthrough column. For a PIVOT, a non-passthrough ref is a pivot-output
10676     * column, a consumed FOR / aggregation-arg column, or a column whose source
10677     * membership cannot be proven without a catalog. For an UNPIVOT (slice 157),
10678     * it is the synthesised VALUE or FOR/name output column, a consumed IN-list
10679     * source column (narrowed into rows), or — again — a column with no provable
10680     * source membership. A passthrough column passes through the operator
10681     * unchanged, so its WHERE ref resolves directly to the underlying source
10682     * column ({@code sourceAlias.col}) regardless of the aggregation count or
10683     * IN-list shape — the same provable-membership doctrine as slice 153's
10684     * passthrough output columns. The {@code dbObjectType == column} filter
10685     * excludes function names and other non-column tokens.
10686     */
10687    private static List<ColumnRef> buildPivotWhereColumnRefs(TSelectSqlStatement select,
10688                                                             TPivotClause pc,
10689                                                             boolean isUnpivot,
10690                                                             String sourceAlias,
10691                                                             TTable sourceTable,
10692                                                             NameBindingProvider provider,
10693                                                             TTable pivotTable) {
10694        TWhereClause where = select.getWhereClause();
10695        if (where == null || where.getCondition() == null) {
10696            return Collections.emptyList();
10697        }
10698        // Slice 156 (PIVOT) / slice 157 (UNPIVOT) — the PROVABLE passthrough source
10699        // columns of the operator. A passthrough column survives the PIVOT/UNPIVOT
10700        // unchanged, so a WHERE touching ONLY those columns resolves directly to
10701        // the underlying source column. The set is empty when membership cannot be
10702        // proven (a base table with no catalog) — keeping the WHERE deferred.
10703        Set<String> passthroughLC = isUnpivot
10704                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10705                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10706        if (!passthroughLC.isEmpty()) {
10707            final List<TObjectName> whereCols = new ArrayList<>();
10708            final boolean[] hasSubquery = {false};
10709            TParseTreeVisitor v = new TParseTreeVisitor() {
10710                @Override
10711                public void preVisit(TObjectName n) {
10712                    whereCols.add(n);
10713                }
10714
10715                @Override
10716                public void preVisit(TSelectSqlStatement s) {
10717                    hasSubquery[0] = true;
10718                }
10719            };
10720            where.getCondition().acceptChildren(v);
10721            if (!hasSubquery[0]) {
10722                LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10723                boolean allPassthrough = true;
10724                for (TObjectName n : whereCols) {
10725                    if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10726                        continue;
10727                    }
10728                    String col = n.getColumnNameOnly();
10729                    if (col == null || col.isEmpty() || "*".equals(col)) {
10730                        continue;
10731                    }
10732                    if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10733                        allPassthrough = false;
10734                        break;
10735                    }
10736                    refs.add(new ColumnRef(sourceAlias, col));
10737                }
10738                if (allPassthrough) {
10739                    return new ArrayList<>(refs);
10740                }
10741            }
10742        }
10743        throw new SemanticIRBuildException(
10744                Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10745                pivotOperatorKeyword(pivotTable) + " combined with a WHERE clause "
10746                        + "is not supported yet", pivotTable));
10747    }
10748
10749    /**
10750     * Slice 158 — resolve the column refs of a {@code GROUP BY} clause over a
10751     * PIVOT or UNPIVOT, admitting the clause only when EVERY grouped column is a
10752     * PROVABLE passthrough source column. Returns the resolved refs (document
10753     * order, deduped) for the {@code StatementGraph} groupByColumnRefs slot, or an
10754     * empty list when there is no GROUP BY. This is the GROUP BY sibling of the
10755     * slice-156/157 WHERE admission ({@link #buildPivotWhereColumnRefs}) and uses
10756     * the same provable-passthrough doctrine: a passthrough column survives the
10757     * PIVOT/UNPIVOT unchanged, so grouping on it resolves directly to the
10758     * underlying source column ({@code sourceAlias.col}).
10759     *
10760     * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the
10761     * slice-129 deferral, code kept reached) when the GROUP BY cannot be admitted:
10762     * it contains a subquery; or any grouped ref is NOT a provable passthrough
10763     * column (a pivot-output column, a consumed FOR / aggregation-arg or IN-list
10764     * column, or a column whose source membership cannot be proven without a
10765     * catalog). A GROUP BY paired with a HAVING already deferred earlier in
10766     * {@link #rejectPivotExtraClauses}, so it never reaches here. The
10767     * {@code dbObjectType == column} filter excludes function names and other
10768     * non-column tokens.
10769     */
10770    private static List<ColumnRef> buildPivotGroupByColumnRefs(TSelectSqlStatement select,
10771                                                               TPivotClause pc,
10772                                                               boolean isUnpivot,
10773                                                               String sourceAlias,
10774                                                               TTable sourceTable,
10775                                                               NameBindingProvider provider,
10776                                                               TTable pivotTable) {
10777        TGroupBy groupBy = select.getGroupByClause();
10778        if (groupBy == null || groupBy.getItems() == null
10779                || groupBy.getItems().size() == 0) {
10780            return Collections.emptyList();
10781        }
10782        // The PROVABLE passthrough source columns of the operator — the same set
10783        // the WHERE admission uses. Empty when membership cannot be proven (a base
10784        // table with no catalog), keeping the GROUP BY deferred.
10785        Set<String> passthroughLC = isUnpivot
10786                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10787                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10788        if (!passthroughLC.isEmpty()) {
10789            final List<TObjectName> groupCols = new ArrayList<>();
10790            final boolean[] hasSubquery = {false};
10791            TParseTreeVisitor v = new TParseTreeVisitor() {
10792                @Override
10793                public void preVisit(TObjectName n) {
10794                    groupCols.add(n);
10795                }
10796
10797                @Override
10798                public void preVisit(TSelectSqlStatement s) {
10799                    hasSubquery[0] = true;
10800                }
10801            };
10802            TGroupByItemList items = groupBy.getItems();
10803            for (int i = 0; i < items.size(); i++) {
10804                TGroupByItem item = items.getGroupByItem(i);
10805                if (item != null && item.getExpr() != null) {
10806                    item.getExpr().acceptChildren(v);
10807                }
10808            }
10809            if (!hasSubquery[0]) {
10810                LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10811                boolean allPassthrough = true;
10812                for (TObjectName n : groupCols) {
10813                    if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10814                        continue;
10815                    }
10816                    String col = n.getColumnNameOnly();
10817                    if (col == null || col.isEmpty() || "*".equals(col)) {
10818                        continue;
10819                    }
10820                    if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10821                        allPassthrough = false;
10822                        break;
10823                    }
10824                    refs.add(new ColumnRef(sourceAlias, col));
10825                }
10826                if (allPassthrough) {
10827                    return new ArrayList<>(refs);
10828                }
10829            }
10830        }
10831        throw new SemanticIRBuildException(
10832                Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10833                pivotOperatorKeyword(pivotTable) + " combined with a GROUP BY clause "
10834                        + "is not supported yet", pivotTable));
10835    }
10836
10837    /**
10838     * Slice 159 — resolve the column refs of a {@code QUALIFY} clause over a PIVOT
10839     * or UNPIVOT, admitting the clause only when EVERY column the QUALIFY touches
10840     * (its window-function partition / order columns and any other column refs) is
10841     * a PROVABLE passthrough source column. Returns the resolved refs (document
10842     * order, deduped) for the {@code StatementGraph} qualifyColumnRefs slot, or an
10843     * empty list when there is no QUALIFY. This is the QUALIFY sibling of the
10844     * slice-156/157 WHERE admission ({@link #buildPivotWhereColumnRefs}) and the
10845     * slice-158 GROUP BY admission ({@link #buildPivotGroupByColumnRefs}), using
10846     * the same provable-passthrough doctrine: a passthrough column survives the
10847     * PIVOT/UNPIVOT unchanged, so a QUALIFY touching only those columns resolves
10848     * directly to the underlying source column ({@code sourceAlias.col}).
10849     *
10850     * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the
10851     * slice-129 deferral, code kept reached) when the QUALIFY cannot be admitted:
10852     * it contains a subquery; or any ref is NOT a provable passthrough column (a
10853     * pivot-output column, an UNPIVOT VALUE / FOR column, a consumed FOR / agg-arg
10854     * or IN-list column, or a column whose source membership cannot be proven
10855     * without a catalog). A QUALIFY paired with a HAVING already deferred earlier
10856     * in {@link #rejectPivotExtraClauses}, so it never reaches here. The
10857     * {@code dbObjectType == column} filter excludes the window-function name and
10858     * other non-column tokens.
10859     */
10860    private static List<ColumnRef> buildPivotQualifyColumnRefs(TSelectSqlStatement select,
10861                                                               TPivotClause pc,
10862                                                               boolean isUnpivot,
10863                                                               String sourceAlias,
10864                                                               TTable sourceTable,
10865                                                               NameBindingProvider provider,
10866                                                               TTable pivotTable) {
10867        if (select.getQualifyClause() == null
10868                || select.getQualifyClause().getSearchConditoin() == null) {
10869            return Collections.emptyList();
10870        }
10871        TExpression qualify = select.getQualifyClause().getSearchConditoin();
10872        // The PROVABLE passthrough source columns of the operator — the same set
10873        // the WHERE and GROUP BY admissions use. Empty when membership cannot be
10874        // proven (a base table with no catalog), keeping the QUALIFY deferred.
10875        Set<String> passthroughLC = isUnpivot
10876                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10877                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10878        if (!passthroughLC.isEmpty()) {
10879            final List<TObjectName> qualifyCols = new ArrayList<>();
10880            final boolean[] hasSubquery = {false};
10881            TParseTreeVisitor v = new TParseTreeVisitor() {
10882                @Override
10883                public void preVisit(TObjectName n) {
10884                    qualifyCols.add(n);
10885                }
10886
10887                @Override
10888                public void preVisit(TSelectSqlStatement s) {
10889                    hasSubquery[0] = true;
10890                }
10891            };
10892            qualify.acceptChildren(v);
10893            if (!hasSubquery[0]) {
10894                LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10895                boolean allPassthrough = true;
10896                for (TObjectName n : qualifyCols) {
10897                    if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10898                        continue;
10899                    }
10900                    String col = n.getColumnNameOnly();
10901                    if (col == null || col.isEmpty() || "*".equals(col)) {
10902                        continue;
10903                    }
10904                    if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10905                        allPassthrough = false;
10906                        break;
10907                    }
10908                    refs.add(new ColumnRef(sourceAlias, col));
10909                }
10910                if (allPassthrough) {
10911                    return new ArrayList<>(refs);
10912                }
10913            }
10914        }
10915        throw new SemanticIRBuildException(
10916                Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
10917                pivotOperatorKeyword(pivotTable) + " combined with a QUALIFY clause "
10918                        + "is not supported yet", pivotTable));
10919    }
10920
10921    /**
10922     * Slice 161 — resolve the column refs of an {@code ORDER BY} clause over a
10923     * PIVOT or UNPIVOT, recording them in the {@code orderByColumnRefs} slot only
10924     * when EVERY sort-key column is a PROVABLE passthrough source column. Returns
10925     * the resolved refs (document order, deduped) or an empty list.
10926     *
10927     * <p>UNLIKE the slice-156/157 WHERE, slice-158 GROUP BY, slice-159 QUALIFY, and
10928     * slice-160 HAVING admissions (which THROW
10929     * {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} when the clause
10930     * cannot be admitted), an ORDER BY over a pivot is ALREADY admitted (slice 142,
10931     * lineage-neutral — it only re-shapes the row order, not the output column set).
10932     * This helper therefore NEVER throws: it merely refines the lineage by recording
10933     * the passthrough refs when they can be proven, and otherwise returns an empty
10934     * list — exactly the lineage-neutral slice-142 behaviour. The slot stays empty
10935     * when: there is no ORDER BY; the source has no provable schema (a base table
10936     * with no catalog); any sort key contains a subquery; or any sort-key column is
10937     * NOT a provable passthrough column (a pivot-output / UNPIVOT-value / FOR /
10938     * consumed column, an ordinal, or a projection-alias reference). The
10939     * all-or-nothing gate mirrors the WHERE doctrine: a mixed ORDER BY (one
10940     * passthrough + one non-passthrough key) records nothing rather than partial
10941     * lineage. The {@code dbObjectType == column} filter excludes function names and
10942     * other non-column tokens; ordinals/aliases that are not source columns fail the
10943     * passthrough membership test and keep the slot empty.
10944     */
10945    private static List<ColumnRef> buildPivotOrderByColumnRefs(TSelectSqlStatement select,
10946                                                               TPivotClause pc,
10947                                                               boolean isUnpivot,
10948                                                               String sourceAlias,
10949                                                               TTable sourceTable,
10950                                                               NameBindingProvider provider) {
10951        TOrderBy orderBy = select.getOrderbyClause();
10952        if (orderBy == null || orderBy.getItems() == null) {
10953            return Collections.emptyList();
10954        }
10955        // The PROVABLE passthrough source columns of the operator — the same set the
10956        // WHERE, GROUP BY, HAVING, and QUALIFY admissions use. Empty when membership
10957        // cannot be proven (a base table with no catalog), keeping ORDER BY
10958        // lineage-neutral.
10959        Set<String> passthroughLC = isUnpivot
10960                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
10961                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
10962        if (passthroughLC.isEmpty()) {
10963            return Collections.emptyList();
10964        }
10965        final List<TObjectName> orderByCols = new ArrayList<>();
10966        final boolean[] hasSubquery = {false};
10967        TParseTreeVisitor v = new TParseTreeVisitor() {
10968            @Override
10969            public void preVisit(TObjectName n) {
10970                orderByCols.add(n);
10971            }
10972
10973            @Override
10974            public void preVisit(TSelectSqlStatement s) {
10975                hasSubquery[0] = true;
10976            }
10977        };
10978        TOrderByItemList items = orderBy.getItems();
10979        for (int i = 0; i < items.size(); i++) {
10980            TOrderByItem item = items.getOrderByItem(i);
10981            if (item == null || item.getSortKey() == null) {
10982                continue;
10983            }
10984            item.getSortKey().acceptChildren(v);
10985        }
10986        if (hasSubquery[0]) {
10987            return Collections.emptyList();
10988        }
10989        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
10990        for (TObjectName n : orderByCols) {
10991            if (n == null || n.getDbObjectType() != EDbObjectType.column) {
10992                continue;
10993            }
10994            String col = n.getColumnNameOnly();
10995            if (col == null || col.isEmpty() || "*".equals(col)) {
10996                continue;
10997            }
10998            if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
10999                // Not all passthrough (a pivot-output / consumed column, or an
11000                // alias) — stay lineage-neutral (slice 142), do NOT defer.
11001                return Collections.emptyList();
11002            }
11003            refs.add(new ColumnRef(sourceAlias, col));
11004        }
11005        return new ArrayList<>(refs);
11006    }
11007
11008    /**
11009     * Slice 160 — resolve the column refs of a {@code HAVING} clause over a PIVOT
11010     * or UNPIVOT, admitting the clause only when EVERY column the HAVING touches
11011     * (its aggregate-function arguments and any other column refs) is a PROVABLE
11012     * passthrough source column. Returns the resolved refs (document order,
11013     * deduped) for the {@code StatementGraph} havingColumnRefs slot, or an empty
11014     * list when there is no HAVING. This is the HAVING sibling of the slice-156/157
11015     * WHERE admission ({@link #buildPivotWhereColumnRefs}), the slice-158 GROUP BY
11016     * admission ({@link #buildPivotGroupByColumnRefs}), and the slice-159 QUALIFY
11017     * admission ({@link #buildPivotQualifyColumnRefs}), using the same
11018     * provable-passthrough doctrine: a passthrough column survives the
11019     * PIVOT/UNPIVOT unchanged, so a HAVING touching only those columns resolves
11020     * directly to the underlying source column ({@code sourceAlias.col}). A
11021     * {@code HAVING COUNT(*)} touches no column at all, so it admits over a provable
11022     * source (empty refs) but still defers over a base table with no catalog (the
11023     * provable-passthrough set is empty, so membership cannot be proven).
11024     *
11025     * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the
11026     * slice-129 deferral, code kept reached) when the HAVING cannot be admitted: it
11027     * contains a subquery; or any ref is NOT a provable passthrough column (a
11028     * pivot-output column, an UNPIVOT VALUE / FOR column, a consumed FOR / agg-arg
11029     * or IN-list column, or a column whose source membership cannot be proven
11030     * without a catalog). The {@code dbObjectType == column} filter excludes the
11031     * aggregate-function names and other non-column tokens.
11032     */
11033    private static List<ColumnRef> buildPivotHavingColumnRefs(TSelectSqlStatement select,
11034                                                              TPivotClause pc,
11035                                                              boolean isUnpivot,
11036                                                              String sourceAlias,
11037                                                              TTable sourceTable,
11038                                                              NameBindingProvider provider,
11039                                                              TTable pivotTable) {
11040        TGroupBy groupBy = select.getGroupByClause();
11041        TExpression having = (groupBy == null) ? null : groupBy.getHavingClause();
11042        if (having == null) {
11043            return Collections.emptyList();
11044        }
11045        // The PROVABLE passthrough source columns of the operator — the same set
11046        // the WHERE, GROUP BY, and QUALIFY admissions use. Empty when membership
11047        // cannot be proven (a base table with no catalog), keeping the HAVING
11048        // deferred.
11049        Set<String> passthroughLC = isUnpivot
11050                ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider)
11051                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
11052        if (!passthroughLC.isEmpty()) {
11053            final List<TObjectName> havingCols = new ArrayList<>();
11054            final boolean[] hasSubquery = {false};
11055            TParseTreeVisitor v = new TParseTreeVisitor() {
11056                @Override
11057                public void preVisit(TObjectName n) {
11058                    havingCols.add(n);
11059                }
11060
11061                @Override
11062                public void preVisit(TSelectSqlStatement s) {
11063                    hasSubquery[0] = true;
11064                }
11065            };
11066            having.acceptChildren(v);
11067            if (!hasSubquery[0]) {
11068                LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11069                boolean allPassthrough = true;
11070                for (TObjectName n : havingCols) {
11071                    if (n == null || n.getDbObjectType() != EDbObjectType.column) {
11072                        continue;
11073                    }
11074                    String col = n.getColumnNameOnly();
11075                    if (col == null || col.isEmpty() || "*".equals(col)) {
11076                        continue;
11077                    }
11078                    if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) {
11079                        allPassthrough = false;
11080                        break;
11081                    }
11082                    refs.add(new ColumnRef(sourceAlias, col));
11083                }
11084                if (allPassthrough) {
11085                    return new ArrayList<>(refs);
11086                }
11087            }
11088        }
11089        throw new SemanticIRBuildException(
11090                Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED,
11091                pivotOperatorKeyword(pivotTable) + " combined with a HAVING clause "
11092                        + "is not supported yet", pivotTable));
11093    }
11094
11095    /**
11096     * Slice 156 — the lower-cased PROVABLE passthrough (group) source column
11097     * names of a PIVOT: the source's published columns (the catalog or a
11098     * FROM-subquery's published schema) MINUS the columns consumed by the PIVOT
11099     * (the FOR / pivot column(s) and the aggregation argument(s)). Empty when the
11100     * source has no provable schema (a base table with no catalog). This is the
11101     * slice-153 passthrough rule, factored out so the WHERE-clause admission
11102     * (slice 156) and {@link #buildPivotOutputColumns}' passthrough column
11103     * resolution share one source of truth.
11104     */
11105    private static Set<String> provablePivotPassthroughColsLC(TPivotClause pc,
11106                                                              String sourceAlias,
11107                                                              TTable sourceTable,
11108                                                              NameBindingProvider provider) {
11109        List<String> srcCols = lookupRelationColumnNames(sourceTable, provider);
11110        if (srcCols == null || srcCols.isEmpty()) {
11111            return Collections.emptySet();
11112        }
11113        Set<String> consumedLC = new HashSet<>();
11114        List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias));
11115        consumed.addAll(collectPivotAggArgRefs(pc, sourceAlias));
11116        for (ColumnRef r : consumed) {
11117            String cn = (r == null) ? null : r.getColumnName();
11118            if (cn != null && !cn.isEmpty()) {
11119                consumedLC.add(cn.toLowerCase(Locale.ROOT));
11120            }
11121        }
11122        Set<String> passthrough = new HashSet<>();
11123        for (String c : srcCols) {
11124            if (c == null || c.isEmpty()) {
11125                continue;
11126            }
11127            String lc = c.toLowerCase(Locale.ROOT);
11128            if (!consumedLC.contains(lc)) {
11129                passthrough.add(lc);
11130            }
11131        }
11132        return passthrough;
11133    }
11134
11135    /**
11136     * Slice 157 — the lower-cased PROVABLE passthrough source column names of an
11137     * UNPIVOT: the source's published columns (the catalog or a FROM-subquery's
11138     * published schema) MINUS the IN-list SOURCE columns the UNPIVOT narrows into
11139     * rows ({@link #collectUnpivotInListRefs}). Empty when the source has no
11140     * provable schema (a base table with no catalog). These are exactly the
11141     * passthrough OUTPUT columns {@link #expandUnpivotBareStar} keeps unchanged;
11142     * the synthesised VALUE / FOR-name output columns are NOT source columns (by
11143     * UNPIVOT semantics they cannot name an existing input column), so they are
11144     * absent from the published set and a WHERE touching them stays deferred. This
11145     * is the UNPIVOT mirror of {@link #provablePivotPassthroughColsLC}, shared by
11146     * the WHERE-clause admission ({@link #buildPivotWhereColumnRefs}).
11147     */
11148    private static Set<String> provableUnpivotPassthroughColsLC(TPivotClause pc,
11149                                                                String sourceAlias,
11150                                                                TTable sourceTable,
11151                                                                NameBindingProvider provider) {
11152        List<String> srcCols = lookupRelationColumnNames(sourceTable, provider);
11153        if (srcCols == null || srcCols.isEmpty()) {
11154            return Collections.emptySet();
11155        }
11156        Set<String> consumedLC = new HashSet<>();
11157        for (ColumnRef r : collectUnpivotInListRefs(pc, sourceAlias)) {
11158            String cn = (r == null) ? null : r.getColumnName();
11159            if (cn != null && !cn.isEmpty()) {
11160                consumedLC.add(cn.toLowerCase(Locale.ROOT));
11161            }
11162        }
11163        Set<String> passthrough = new HashSet<>();
11164        for (String c : srcCols) {
11165            if (c == null || c.isEmpty()) {
11166                continue;
11167            }
11168            String lc = c.toLowerCase(Locale.ROOT);
11169            if (!consumedLC.contains(lc)) {
11170                passthrough.add(lc);
11171            }
11172        }
11173        return passthrough;
11174    }
11175
11176    /**
11177     * Slice 129 — capture the columns CONSUMED by a PIVOT: the FOR / pivot
11178     * column(s) first, then the aggregation-function argument column(s), in
11179     * document order, deduplicated by (alias, columnName). Each is built
11180     * DIRECTLY as a {@link ColumnRef} against the bound source relation —
11181     * resolver2 marks these refs NOT_FOUND in PIVOT context even though
11182     * Phase-1 {@code linkColumnToTable} sets their {@code sourceTable}, so the
11183     * strict {@code collectColumnRefs} path would reject them.
11184     */
11185    private static List<ColumnRef> collectPivotConsumedRefs(TPivotClause pc,
11186                                                            String sourceAlias) {
11187        // Consumed = FOR / pivot column(s) FIRST, then aggregation-arg
11188        // column(s), document order, deduped (LinkedHashSet). Slice 130 split
11189        // the two halves into reusable helpers: the agg-arg refs are also the
11190        // sources of a pivot-output column (see buildPivotOutputColumns).
11191        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11192        refs.addAll(collectPivotForRefs(pc, sourceAlias));
11193        refs.addAll(collectPivotAggArgRefs(pc, sourceAlias));
11194        return new ArrayList<>(refs);
11195    }
11196
11197    /**
11198     * Slice 130 — the FOR / pivot column ref(s) consumed by a PIVOT, built
11199     * directly against {@code sourceAlias} (deduped, document order). Split out
11200     * of {@link #collectPivotConsumedRefs} so the consumed-refs slot and the
11201     * output-column lineage share one source of truth.
11202     */
11203    private static List<ColumnRef> collectPivotForRefs(TPivotClause pc, String sourceAlias) {
11204        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11205        TObjectNameList forCols = pc.getPivotColumnList();
11206        if (forCols != null) {
11207            for (int i = 0; i < forCols.size(); i++) {
11208                addPivotConsumedRef(refs, forCols.getObjectName(i), sourceAlias);
11209            }
11210        } else if (pc.getPivotColumn() != null) {
11211            addPivotConsumedRef(refs, pc.getPivotColumn(), sourceAlias);
11212        }
11213        return new ArrayList<>(refs);
11214    }
11215
11216    /**
11217     * Slice 130 — the aggregation-function argument column ref(s) (the value
11218     * being aggregated), built directly against {@code sourceAlias} (deduped,
11219     * document order). SQL Server / Snowflake carry a single
11220     * {@code TFunctionCall}; Oracle a {@code TResultColumnList}. These are the
11221     * sources of a pivot-output column. Function names and column-alias nodes
11222     * are skipped by the {@code dbObjectType} filter in addPivotConsumedRef.
11223     */
11224    private static List<ColumnRef> collectPivotAggArgRefs(TPivotClause pc, String sourceAlias) {
11225        final List<TObjectName> argCols = new ArrayList<>();
11226        TParseTreeVisitor argVisitor = new TParseTreeVisitor() {
11227            @Override
11228            public void preVisit(TObjectName n) {
11229                argCols.add(n);
11230            }
11231        };
11232        if (pc.getAggregation_function() != null
11233                && pc.getAggregation_function().getArgs() != null) {
11234            pc.getAggregation_function().getArgs().acceptChildren(argVisitor);
11235        }
11236        if (pc.getAggregation_function_list() != null) {
11237            pc.getAggregation_function_list().acceptChildren(argVisitor);
11238        }
11239        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11240        for (TObjectName n : argCols) {
11241            addPivotConsumedRef(refs, n, sourceAlias);
11242        }
11243        return new ArrayList<>(refs);
11244    }
11245
11246    /**
11247     * Slice 154 — the aggregation-argument column ref(s) for a SINGLE aggregation
11248     * in a multi-aggregation PIVOT's {@code aggregation_function_list} (the value
11249     * being aggregated by THIS one function), built directly against
11250     * {@code sourceAlias} (deduped, document order). This is the per-aggregation
11251     * counterpart of {@link #collectPivotAggArgRefs} (which unions ALL
11252     * aggregations' args): a multi-agg cross-product output column
11253     * {@code <in_value>_<agg_alias>} draws from ONLY the named aggregation's
11254     * argument, not every aggregation's. Function names / alias nodes are excluded
11255     * by the {@code dbObjectType == column} filter in {@link #addPivotConsumedRef}.
11256     */
11257    private static List<ColumnRef> collectSingleAggArgRefs(TResultColumn aggRc,
11258                                                           String sourceAlias) {
11259        final List<TObjectName> argCols = new ArrayList<>();
11260        TParseTreeVisitor argVisitor = new TParseTreeVisitor() {
11261            @Override
11262            public void preVisit(TObjectName n) {
11263                argCols.add(n);
11264            }
11265        };
11266        if (aggRc != null && aggRc.getExpr() != null
11267                && aggRc.getExpr().getFunctionCall() != null
11268                && aggRc.getExpr().getFunctionCall().getArgs() != null) {
11269            aggRc.getExpr().getFunctionCall().getArgs().acceptChildren(argVisitor);
11270        }
11271        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11272        for (TObjectName n : argCols) {
11273            addPivotConsumedRef(refs, n, sourceAlias);
11274        }
11275        return new ArrayList<>(refs);
11276    }
11277
11278    /** Slice 130 — number of aggregation functions in the PIVOT clause. */
11279    private static int pivotAggregationCount(TPivotClause pc) {
11280        if (pc.getAggregation_function() != null) {
11281            return 1;
11282        }
11283        if (pc.getAggregation_function_list() != null) {
11284            return pc.getAggregation_function_list().size();
11285        }
11286        return 0;
11287    }
11288
11289    /**
11290     * Slice 130 — true when the PIVOT IN-list is a STATIC value list, so the
11291     * pivot-output column names are known at build time. False for a subquery
11292     * or {@code ANY} dynamic IN-list (the parser leaves {@code getItems()}
11293     * null or empty), where output lineage stays deferred.
11294     */
11295    private static boolean hasStaticInList(TPivotClause pc) {
11296        return pc.getPivotInClause() != null
11297                && pc.getPivotInClause().getItems() != null
11298                && pc.getPivotInClause().getItems().size() > 0;
11299    }
11300
11301    /**
11302     * Slice 130 — the set of pivot-output column names: the IN-list items'
11303     * display names ({@code [2006]} for SQL Server bracket values, the IN-list
11304     * alias such as {@code pa} / {@code jan} for Oracle / Snowflake). A
11305     * projected column whose underlying column name
11306     * ({@link TResultColumn#getColumnNameOnly()}) is in this set is a pivot
11307     * output (sourced from the aggregation argument); any other simple-column
11308     * projection is a passthrough (group) column. Caller must have confirmed a
11309     * static IN-list via {@link #hasStaticInList}.
11310     *
11311     * <p>Returns a {@link LinkedHashSet} so the names are in IN-list DOCUMENT
11312     * ORDER. Slice 130 only used this for {@code contains()} membership, but
11313     * slice 133's {@code SELECT *} expansion ({@link #expandPivotBareStar})
11314     * relies on the ordered iteration to emit pivot-output columns in IN-list
11315     * order — so the ordered return type is part of the contract.
11316     */
11317    private static LinkedHashSet<String> collectPivotOutputNames(TPivotClause pc) {
11318        LinkedHashSet<String> names = new LinkedHashSet<>();
11319        TPivotInClause in = pc.getPivotInClause();
11320        if (in != null && in.getItems() != null) {
11321            for (int i = 0; i < in.getItems().size(); i++) {
11322                TResultColumn item = in.getItems().getResultColumn(i);
11323                if (item == null) {
11324                    continue;
11325                }
11326                String dn = item.getDisplayName();
11327                if (dn != null && !dn.isEmpty()) {
11328                    names.add(dn);
11329                }
11330            }
11331        }
11332        return names;
11333    }
11334
11335    /**
11336     * Slice 129 helper — add one consumed column ref to {@code refs} iff it is
11337     * an actual column (not a function name, column alias, or {@code *}). The
11338     * ref is attributed DIRECTLY to {@code sourceAlias}.
11339     *
11340     * <p>No source-table match guard: sub-slice (a) admits only a single
11341     * base-table PIVOT source, so every column consumed by the FOR clause or
11342     * an aggregation argument necessarily belongs to that one source relation
11343     * — there is no other relation to mis-attribute to, and resolver2 leaves
11344     * these refs NOT_FOUND in PIVOT context anyway. Guarding on
11345     * {@code sourceTable} identity / name would SILENTLY DROP a valid consumed
11346     * column when Phase-1 left {@code sourceTable} null or shaped its name
11347     * differently (alias-normalized / schema-qualified) — codex diff-review
11348     * round 1 BLOCKING. The {@code dbObjectType == column} filter already
11349     * excludes function names and column-alias nodes.
11350     */
11351    private static void addPivotConsumedRef(LinkedHashSet<ColumnRef> refs,
11352                                            TObjectName ref,
11353                                            String sourceAlias) {
11354        if (ref == null || ref.getDbObjectType() != EDbObjectType.column) {
11355            return;
11356        }
11357        String col = ref.getColumnNameOnly();
11358        if (col == null || col.isEmpty() || "*".equals(col)) {
11359            return;
11360        }
11361        refs.add(new ColumnRef(sourceAlias, col));
11362    }
11363
11364    /**
11365     * Slice 129 / 130 — build output columns for a PIVOT SELECT. Each projected
11366     * result column yields one OutputColumn whose name is the projection's
11367     * effective name. A {@code SELECT *} / {@code t.*} over a PIVOT is deferred
11368     * (it needs the full synthesised output schema / a catalog).
11369     *
11370     * <p>Slice 130 sub-slice (b1) attaches output-column LINEAGE when the
11371     * output schema maps deterministically to source columns — i.e. a SINGLE
11372     * aggregation over a STATIC IN-list ({@code lineageEnabled}):
11373     * <ul>
11374     *   <li><b>pivot-output column</b> — the projected column's underlying name
11375     *       ({@link TResultColumn#getColumnNameOnly()}) is one of the IN-list
11376     *       display names. It is the aggregate sliced by the FOR value, so
11377     *       {@code derived=true}, {@code aggregate=true}, and sources are the
11378     *       aggregation-argument column(s).</li>
11379     *   <li><b>passthrough (group) column</b> — any other simple-column
11380     *       projection. It is a direct reference to the source column, so
11381     *       {@code derived=false}, {@code aggregate=false}, and the single
11382     *       source is {@code sourceAlias.getColumnNameOnly()}. The output NAME
11383     *       may be aliased ({@code vendorid AS v}) but the SOURCE column is the
11384     *       underlying name.</li>
11385     * </ul>
11386     * Multi-aggregation (vendor-specific cross-product output names like
11387     * {@code pa_s}), a dynamic / subquery IN-list, and expression projections
11388     * keep the slice-129 NAME-only skeleton ({@code derived=true},
11389     * {@code aggregate=false}, EMPTY sources) — no false lineage.
11390     *
11391     * <p>All refs are built DIRECTLY against {@code sourceAlias}: sub-slice (a)
11392     * admits only a single base-table source, so every passthrough / agg-arg
11393     * column belongs to that one relation, and resolver2 marks them NOT_FOUND
11394     * in PIVOT context anyway (slice 129).
11395     */
11396    private static List<OutputColumn> buildPivotOutputColumns(TSelectSqlStatement select,
11397                                                              TTable pivotTable,
11398                                                              TPivotClause pc,
11399                                                              String sourceAlias,
11400                                                              TTable sourceTable,
11401                                                              NameBindingProvider provider) {
11402        TResultColumnList rcl = select.getResultColumnList();
11403        if (rcl == null || rcl.size() == 0) {
11404            throw new SemanticIRBuildException(
11405                    Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS,
11406                    "SELECT has no projected columns", select));
11407        }
11408        boolean lineageEnabled = pivotAggregationCount(pc) == 1 && hasStaticInList(pc);
11409        Set<String> pivotOutputNames = lineageEnabled
11410                ? collectPivotOutputNames(pc) : Collections.<String>emptySet();
11411        List<ColumnRef> aggArgRefs = lineageEnabled
11412                ? collectPivotAggArgRefs(pc, sourceAlias) : Collections.<ColumnRef>emptyList();
11413
11414        // Slice 153 — a NON-lineage-enabled PIVOT (a multi-aggregation PIVOT or a
11415        // dynamic / subquery IN-list) still has determinable PASSTHROUGH (group)
11416        // column lineage: a passthrough column passes through UNCHANGED from the
11417        // source regardless of the aggregation count or IN-list shape. Slice 130
11418        // emitted every projected column of such a PIVOT as a NAME-only skeleton
11419        // (empty sources); this attaches the source lineage to the passthrough
11420        // columns only. The set below is the lower-cased SOURCE column names that
11421        // are NOT consumed by the PIVOT (the FOR / pivot column(s) and the
11422        // aggregation-argument column(s)); a projected simple-column whose name is
11423        // in it is a passthrough. This is keyed off PROVABLE source membership
11424        // (the source catalog or a FROM-subquery's published columns) so it never
11425        // invents a false source for a vendor-specific cross-product output column
11426        // (e.g. `y6_s`) — those names are not source columns. Without a source
11427        // catalog (a base table and no Catalog) the set is empty, so the behaviour
11428        // is unchanged. (The lineage-enabled single-agg path below keys off the
11429        // KNOWN IN-list output-name set instead — for multi-agg those names are
11430        // vendor-specific cross-products we do not enumerate, hence the
11431        // catalog-confirmation rule here.)
11432        // Slice 156 — factored into provablePivotPassthroughColsLC (shared with
11433        // the WHERE-clause admission). Computed only for a non-lineage-enabled
11434        // PIVOT: the lineage-enabled single-agg path below keys off the KNOWN
11435        // IN-list output-name set instead.
11436        Set<String> passthroughSourceColsLC = lineageEnabled
11437                ? Collections.<String>emptySet()
11438                : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider);
11439
11440        // Slice 154 — a MULTI-aggregation PIVOT over a STATIC IN-list produces one
11441        // CROSS-PRODUCT output column per (IN-value × aggregation), named
11442        // `<in_value_display_name>_<agg_alias>` (`y6_s`, `y6_a`). Slice 130 / 153
11443        // left these as NAME-only skeletons because the names are vendor-specific
11444        // and were not enumerated. Build the map `<in_value>_<agg> -> that one
11445        // aggregation's argument column ref(s)` so a projected cross-product column
11446        // carries the SPECIFIC aggregation's lineage (NOT the union of all agg
11447        // args). Unlike the slice-153 passthrough rule, this needs NO source
11448        // catalog: the IN-value names and each aggregation's argument are fully
11449        // determined by the PIVOT clause itself (so a base table without a catalog
11450        // resolves the same as a subquery source — mirroring the lineage-enabled
11451        // single-agg path, which also reads the agg arg straight from the clause).
11452        // Keys are lower-cased (Locale.ROOT) to match the project's identifier
11453        // folding. A key produced by two (IN-value, agg) pairs with DIFFERENT
11454        // sources is AMBIGUOUS (e.g. degenerate names whose `_` concatenation
11455        // collides) and is dropped so it never attaches a wrong source.
11456        Map<String, List<ColumnRef>> crossProductSourcesLC = Collections.emptyMap();
11457        Set<String> ambiguousCrossProductLC = Collections.emptySet();
11458        if (!lineageEnabled && hasStaticInList(pc)
11459                && pc.getAggregation_function_list() != null
11460                && pc.getAggregation_function_list().size() > 1) {
11461            Map<String, List<ColumnRef>> xpMap = new HashMap<>();
11462            Set<String> xpAmbiguous = new HashSet<>();
11463            TResultColumnList aggList = pc.getAggregation_function_list();
11464            for (String inVal : collectPivotOutputNames(pc)) {
11465                if (inVal == null || inVal.isEmpty()) {
11466                    continue;
11467                }
11468                for (int ai = 0; ai < aggList.size(); ai++) {
11469                    TResultColumn aggRc = aggList.getResultColumn(ai);
11470                    if (aggRc == null) {
11471                        continue;
11472                    }
11473                    String aggAlias = aggRc.getDisplayName();
11474                    if (aggAlias == null || aggAlias.isEmpty()) {
11475                        continue;
11476                    }
11477                    String key = (inVal + "_" + aggAlias).toLowerCase(Locale.ROOT);
11478                    List<ColumnRef> argRefs = collectSingleAggArgRefs(aggRc, sourceAlias);
11479                    if (xpMap.containsKey(key)) {
11480                        if (!xpMap.get(key).equals(argRefs)) {
11481                            xpAmbiguous.add(key);
11482                        }
11483                    } else {
11484                        xpMap.put(key, argRefs);
11485                    }
11486                }
11487            }
11488            crossProductSourcesLC = xpMap;
11489            ambiguousCrossProductLC = xpAmbiguous;
11490        }
11491
11492        // Slice 133 (sub-slice (b2-pivot)) — a SOLE `SELECT *` over a
11493        // lineage-enabled PIVOT is expanded from the source table's catalog
11494        // column list into the synthesised output schema (group / passthrough
11495        // columns then pivot-output columns). A `*` mixed with explicit columns
11496        // and the non-lineage-enabled (multi-agg / dynamic-IN) `*` stay deferred
11497        // via the per-column reject below.
11498        //
11499        // Slice 151 — the SOLE star is now admitted whether it is BARE (`*`) or
11500        // QUALIFIED (`p.*` / `src.*`). An admitted PIVOT SELECT has exactly ONE
11501        // relation in scope — the pivot output (isPivotSelect requires a
11502        // FROM-PIVOT with no JOIN) — so a qualified star can only mean that one
11503        // relation and is therefore equivalent to a bare `*`; expandPivotBareStar
11504        // synthesises the identical schema (it never reads the star's qualifier,
11505        // only the source columns and the pivot clause). Slice 152 dropped the
11506        // mirror gate on the UNPIVOT bare-star path below.
11507        if (lineageEnabled && rcl.size() == 1) {
11508            TResultColumn only = rcl.getResultColumn(0);
11509            if ("*".equals(only.getColumnNameOnly())) {
11510                // Slice 137 lifted the slice-136 subquery-source deferral. A bare
11511                // SELECT * over a FROM-subquery PIVOT now expands the same way as a
11512                // base-table PIVOT: expandPivotBareStar reads the source's published
11513                // columns via lookupRelationColumnNames, which the slice-137
11514                // addRelationToInScopeMap unwrap registers under the subquery alias.
11515                // No catalog is required for a subquery source — the subquery's
11516                // explicit projection IS the published schema.
11517                return expandPivotBareStar(pc, sourceTable, sourceAlias, provider,
11518                        aggArgRefs, only);
11519            }
11520        }
11521
11522        // Slice 155 — admit a SOLE bare/qualified `*` over a MULTI-aggregation
11523        // static-IN PIVOT. Slice 130 / 133 deferred this (PIVOT_STAR_NOT_SUPPORTED)
11524        // because the cross-product output names were not enumerated; slice 154
11525        // enumerated them (`<in_value>_<agg_alias>` from the IN-list display names
11526        // and each aggregation's alias), so the full output schema is now
11527        // synthesisable: the passthrough (group) columns followed by the
11528        // cross-product output columns. (A dynamic / subquery IN-list multi-agg
11529        // bare `*` keeps the deferral via the per-column reject below — its
11530        // cross-product names are unknown. A single-agg static-IN bare `*` is
11531        // handled by the lineage-enabled block above.)
11532        if (!lineageEnabled && hasStaticInList(pc)
11533                && pc.getAggregation_function_list() != null
11534                && pc.getAggregation_function_list().size() > 1
11535                && rcl.size() == 1) {
11536            TResultColumn only = rcl.getResultColumn(0);
11537            if ("*".equals(only.getColumnNameOnly())) {
11538                return expandPivotMultiAggBareStar(pc, sourceTable, sourceAlias,
11539                        provider, only);
11540            }
11541        }
11542
11543        List<OutputColumn> outs = new ArrayList<>();
11544        for (int i = 0; i < rcl.size(); i++) {
11545            TResultColumn rc = rcl.getResultColumn(i);
11546            if ("*".equals(rc.getColumnNameOnly())) {
11547                throw new SemanticIRBuildException(
11548                        Diagnostic.error(DiagnosticCode.PIVOT_STAR_NOT_SUPPORTED,
11549                        "SELECT * over a PIVOT is not supported yet "
11550                                + "(the synthesised output schema is deferred); "
11551                                + "project explicit columns", rc));
11552            }
11553            // effectiveOutputName throws RESULT_COLUMN_NO_NAME for an
11554            // unaliased non-name expression — acceptable deferral.
11555            String outName = effectiveOutputName(rc);
11556            if (!lineageEnabled) {
11557                // A non-lineage-enabled PIVOT (multi-agg, or dynamic / subquery
11558                // IN-list). A simple-column projection can be one of two resolvable
11559                // shapes; everything else (expressions) keeps the skeleton.
11560                String colName = rc.getColumnNameOnly();
11561                boolean simpleCol = colName != null && !colName.isEmpty()
11562                        && rc.getExpr() != null
11563                        && rc.getExpr().getExpressionType()
11564                                == EExpressionType.simple_object_name_t;
11565                String colNameLC = simpleCol ? colName.toLowerCase(Locale.ROOT) : null;
11566                if (simpleCol && passthroughSourceColsLC.contains(colNameLC)) {
11567                    // Slice 153 — passthrough (group) column: a simple source
11568                    // column proven NOT consumed by the PIVOT, passes through
11569                    // unchanged. (Disjoint from a cross-product name: PIVOT
11570                    // semantics forbid an IN-value naming an existing source
11571                    // column, so `<in>_<agg>` is never a source column.)
11572                    outs.add(new OutputColumn(outName, /*derived=*/ false,
11573                            /*aggregate=*/ false,
11574                            Collections.singletonList(new ColumnRef(sourceAlias, colName)),
11575                            /*windowSpec=*/ null));
11576                } else if (simpleCol && crossProductSourcesLC.containsKey(colNameLC)
11577                        && !ambiguousCrossProductLC.contains(colNameLC)) {
11578                    // Slice 154 — multi-agg cross-product pivot-output column:
11579                    // the aggregate of its SPECIFIC aggregation sliced by the FOR
11580                    // value (derived + aggregate, sourced from that one
11581                    // aggregation's argument column(s)).
11582                    outs.add(new OutputColumn(outName, /*derived=*/ true,
11583                            /*aggregate=*/ true,
11584                            crossProductSourcesLC.get(colNameLC),
11585                            /*windowSpec=*/ null));
11586                } else {
11587                    outs.add(pivotSkeletonOutput(outName));
11588                }
11589                continue;
11590            }
11591            String colOnly = rc.getColumnNameOnly();
11592            // Name-collision is a non-issue here: PIVOT semantics forbid an
11593            // IN-list value from naming a column that already exists in the
11594            // input source (see TPivotClause), so an IN-list display name can
11595            // never coincide with a real passthrough source column.
11596            if (colOnly != null && !colOnly.isEmpty() && pivotOutputNames.contains(colOnly)) {
11597                // Pivot-output column: aggregate over the value column, sliced
11598                // by the FOR value. Sources = aggregation-arg column(s).
11599                outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ true,
11600                        aggArgRefs, /*windowSpec=*/ null));
11601            } else if (colOnly != null && !colOnly.isEmpty()
11602                    && rc.getExpr() != null
11603                    && rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t) {
11604                // Passthrough (group) column: direct reference to the source
11605                // column (the underlying name, not the output alias).
11606                outs.add(new OutputColumn(outName, /*derived=*/ false, /*aggregate=*/ false,
11607                        Collections.singletonList(new ColumnRef(sourceAlias, colOnly)),
11608                        /*windowSpec=*/ null));
11609            } else {
11610                // Expression projection (or no underlying column) — keep skeleton.
11611                outs.add(pivotSkeletonOutput(outName));
11612            }
11613        }
11614        return outs;
11615    }
11616
11617    /**
11618     * Slice 133 (sub-slice (b2-pivot)) — synthesise the full output schema for a
11619     * bare {@code SELECT *} over a lineage-enabled (single-agg / static-IN)
11620     * PIVOT, using the source table's catalog column list:
11621     * <ol>
11622     *   <li><b>group / passthrough columns</b> — every catalog column that is
11623     *       NOT consumed by the PIVOT (i.e. not the FOR / pivot column(s) and not
11624     *       an aggregation-argument column), in CATALOG declaration order. Each is
11625     *       a direct source reference ({@code derived=false},
11626     *       {@code aggregate=false}, single source {@code sourceAlias.col}).</li>
11627     *   <li><b>pivot-output columns</b> — one per IN-list value
11628     *       ({@link #collectPivotOutputNames}, IN-list order). Each is the
11629     *       aggregate sliced by the FOR value ({@code derived=true},
11630     *       {@code aggregate=true}, sources = the aggregation-argument column(s)).</li>
11631     * </ol>
11632     * This is the SQL-standard {@code SELECT *} PIVOT shape (SQL Server / Oracle /
11633     * Snowflake): the implicit GROUP BY columns first, then the generated pivot
11634     * columns. (There is no dlineage {@code SELECT *} golden to match: dlineage
11635     * cannot expand {@code *} without a catalog either.)
11636     *
11637     * <p>The consumed-column subtraction folds names with
11638     * {@code toLowerCase(Locale.ROOT)} — the SAME identifier canonicalisation the
11639     * other semantic-IR catalog matchers use ({@link #expandBareStarOverUsing},
11640     * {@link #lookupRelationColumnNames}); so it is aligned with the project's
11641     * catalog name folding. (A degenerate catalog holding two columns that differ
11642     * only by case would have both excluded by a single consumed name — an
11643     * inherently ambiguous catalog, not a realistic PIVOT source.)
11644     *
11645     * <p>Throws {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED} when no catalog
11646     * column list is available (mirrors slice-90
11647     * {@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED}).
11648     */
11649    private static List<OutputColumn> expandPivotBareStar(TPivotClause pc,
11650                                                          TTable sourceTable,
11651                                                          String sourceAlias,
11652                                                          NameBindingProvider provider,
11653                                                          List<ColumnRef> aggArgRefs,
11654                                                          TResultColumn star) {
11655        List<String> cols = lookupRelationColumnNames(sourceTable, provider);
11656        if (cols == null || cols.isEmpty()) {
11657            throw new SemanticIRBuildException(
11658                    Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED,
11659                    "SELECT * over a PIVOT requires catalog metadata for source '"
11660                            + sourceAlias + "' to expand the implicit group columns; "
11661                            + "supply a Catalog via "
11662                            + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star));
11663        }
11664        // Columns consumed by the PIVOT (FOR / pivot column(s) + aggregation-arg
11665        // column(s)); these are NOT passthrough group columns. Folded to lower
11666        // case (Locale.ROOT) to match the catalog identifier canonicalisation
11667        // used by the other semantic-IR catalog matchers (expandBareStarOverUsing,
11668        // lookupRelationColumnNames). addPivotConsumedRef already filters out
11669        // null / empty column names, so getColumnName() is non-null here; the
11670        // guard below is defensive.
11671        Set<String> consumedLC = new HashSet<>();
11672        List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias));
11673        consumed.addAll(aggArgRefs);
11674        for (ColumnRef r : consumed) {
11675            String cn = (r == null) ? null : r.getColumnName();
11676            if (cn != null && !cn.isEmpty()) {
11677                consumedLC.add(cn.toLowerCase(Locale.ROOT));
11678            }
11679        }
11680        List<OutputColumn> outs = new ArrayList<>();
11681        // 1. group / passthrough columns (catalog order, minus consumed).
11682        for (String c : cols) {
11683            if (c == null || c.isEmpty()) {
11684                continue;
11685            }
11686            if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) {
11687                continue;
11688            }
11689            outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false,
11690                    Collections.singletonList(new ColumnRef(sourceAlias, c)),
11691                    /*windowSpec=*/ null));
11692        }
11693        // 2. pivot-output columns (IN-list document order — collectPivotOutputNames
11694        //    returns a LinkedHashSet whose order is the IN-list order).
11695        for (String name : collectPivotOutputNames(pc)) {
11696            outs.add(new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ true,
11697                    aggArgRefs, /*windowSpec=*/ null));
11698        }
11699        return outs;
11700    }
11701
11702    /**
11703     * Slice 155 — synthesise the full output schema for a bare {@code SELECT *}
11704     * over a MULTI-aggregation static-IN PIVOT:
11705     * <ol>
11706     *   <li><b>passthrough / group columns</b> — every published source column
11707     *       NOT consumed by the PIVOT (the FOR / pivot column(s) and EVERY
11708     *       aggregation-argument column), in catalog / published declaration
11709     *       order. Each is a direct source reference ({@code derived=false},
11710     *       {@code aggregate=false}).</li>
11711     *   <li><b>cross-product output columns</b> — one per (IN-value × aggregation)
11712     *       named {@code <in_value_display_name>_<agg_alias>}, in
11713     *       IN-value-outer / aggregation-inner order (the SQL-standard / Oracle
11714     *       column order). Each is {@code derived=true}, {@code aggregate=true},
11715     *       sourced from that ONE aggregation's argument column(s).</li>
11716     * </ol>
11717     * Like {@link #expandPivotBareStar}, the passthrough expansion needs a source
11718     * catalog (or a FROM-subquery's published columns) — otherwise
11719     * {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED}. The cross-product part,
11720     * by contrast, is fully clause-determined (mirroring slice 154's explicit
11721     * cross-product lineage), so it needs no catalog. Caller has confirmed a
11722     * static IN-list and a multi-aggregation PIVOT.
11723     */
11724    private static List<OutputColumn> expandPivotMultiAggBareStar(TPivotClause pc,
11725                                                                  TTable sourceTable,
11726                                                                  String sourceAlias,
11727                                                                  NameBindingProvider provider,
11728                                                                  TResultColumn star) {
11729        List<String> cols = lookupRelationColumnNames(sourceTable, provider);
11730        if (cols == null || cols.isEmpty()) {
11731            throw new SemanticIRBuildException(
11732                    Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED,
11733                    "SELECT * over a PIVOT requires catalog metadata for source '"
11734                            + sourceAlias + "' to expand the implicit group columns; "
11735                            + "supply a Catalog via "
11736                            + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star));
11737        }
11738        // Columns consumed by the PIVOT (FOR / pivot column(s) + EVERY
11739        // aggregation-argument column); these are NOT passthrough group columns.
11740        // Folded to lower case (Locale.ROOT) to match the catalog identifier
11741        // canonicalisation used by the other semantic-IR catalog matchers.
11742        Set<String> consumedLC = new HashSet<>();
11743        List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias));
11744        consumed.addAll(collectPivotAggArgRefs(pc, sourceAlias));
11745        for (ColumnRef r : consumed) {
11746            String cn = (r == null) ? null : r.getColumnName();
11747            if (cn != null && !cn.isEmpty()) {
11748                consumedLC.add(cn.toLowerCase(Locale.ROOT));
11749            }
11750        }
11751        List<OutputColumn> outs = new ArrayList<>();
11752        // 1. passthrough / group columns (published order, minus consumed).
11753        for (String c : cols) {
11754            if (c == null || c.isEmpty()) {
11755                continue;
11756            }
11757            if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) {
11758                continue;
11759            }
11760            outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false,
11761                    Collections.singletonList(new ColumnRef(sourceAlias, c)),
11762                    /*windowSpec=*/ null));
11763        }
11764        // 2. cross-product output columns — IN-value outer (IN-list document
11765        //    order), aggregation inner (declaration order). Each traces to its
11766        //    OWN aggregation's argument column(s), not the union of all aggs.
11767        TResultColumnList aggList = pc.getAggregation_function_list();
11768        for (String inVal : collectPivotOutputNames(pc)) {
11769            if (inVal == null || inVal.isEmpty()) {
11770                continue;
11771            }
11772            for (int ai = 0; ai < aggList.size(); ai++) {
11773                TResultColumn aggRc = aggList.getResultColumn(ai);
11774                if (aggRc == null) {
11775                    continue;
11776                }
11777                String aggAlias = aggRc.getDisplayName();
11778                if (aggAlias == null || aggAlias.isEmpty()) {
11779                    continue;
11780                }
11781                outs.add(new OutputColumn(inVal + "_" + aggAlias,
11782                        /*derived=*/ true, /*aggregate=*/ true,
11783                        collectSingleAggArgRefs(aggRc, sourceAlias),
11784                        /*windowSpec=*/ null));
11785            }
11786        }
11787        return outs;
11788    }
11789
11790    /**
11791     * Slice 129 / 130 — a NAME-only deferred PIVOT output column ({@code
11792     * derived=true}, {@code aggregate=false}, EMPTY sources). Used for the
11793     * multi-aggregation / dynamic-IN / expression cases where output lineage
11794     * cannot be attributed without risking a false source.
11795     */
11796    private static OutputColumn pivotSkeletonOutput(String name) {
11797        return new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ false,
11798                Collections.<ColumnRef>emptyList(), /*windowSpec=*/ null);
11799    }
11800
11801    // -----------------------------------------------------------------
11802    // Slice 131 — PIVOT sub-slice (c): UNPIVOT output-column lineage.
11803    //
11804    // UNPIVOT narrows several source columns into rows. The clause carries:
11805    //   getValueColumnList() — the NEW value column(s) holding the unpivoted
11806    //                          values (e.g. `orders`);
11807    //   getPivotColumnList() — the NEW FOR/name column holding the literal
11808    //                          NAMES of the unpivoted columns (e.g. `employee`);
11809    //   getUnpivotInClause().getItems() — the narrowed SOURCE columns
11810    //                          (`emp1`, `emp2`, ...), each `getColumn()` a
11811    //                          base-table column with a Phase-1 sourceTable.
11812    // The value and FOR column names cannot name an existing input column
11813    // (PIVOT semantics, TPivotClause javadoc) — so they never collide with a
11814    // real passthrough source column.
11815    //
11816    // Sub-slice (c) admits the deterministic shape: a SINGLE value column over a
11817    // simple (non-column-group) IN-list. Output-column lineage mirrors the
11818    // authoritative dlineage UNPIVOT handler — each IN-list source column feeds
11819    // BOTH the value column and the FOR column. A multi-value-column or
11820    // column-group UNPIVOT stays deferred (PIVOT_UNPIVOT_NOT_SUPPORTED).
11821    // -----------------------------------------------------------------
11822
11823    /**
11824     * Slice 131 / 132 — validate the UNPIVOT shape, deferring (reject with
11825     * {@code PIVOT_UNPIVOT_NOT_SUPPORTED}, code kept reached) the shapes whose
11826     * value↔source mapping is not deterministic.
11827     *
11828     * <p>Slice 131 admitted ONLY the single-value-column / simple-IN shape.
11829     * Slice 132 (sub-slice (c2)) generalises this to the well-formed
11830     * multi-value-column / column-group shape (Oracle / Redshift) — e.g.
11831     * {@code UNPIVOT ((s1, s2) FOR yr IN ((a1, a2) AS 'Y1', (b1, b2) AS 'Y2'))}.
11832     * The admit rule is a single width invariant: every IN-list item must have
11833     * EXACTLY one source column per value column ({@code itemWidth == valueCount}),
11834     * so each value column maps positionally to one group position.
11835     *
11836     * <p>The malformed / mixed shapes that stay deferred (and keep the code
11837     * reached): no value column; a group whose width differs from the value
11838     * count (e.g. {@code ((s1, s2) FOR yr IN ((a1, a2, a3) ...))}); a single
11839     * column item under a multi-value UNPIVOT; or a column-group item under a
11840     * single-value UNPIVOT. The harder positional mapping for those is left for
11841     * a later sub-slice.
11842     */
11843    private static void rejectUnsupportedUnpivotShape(TPivotClause pc, TTable pivotTable) {
11844        TObjectNameList valueCols = pc.getValueColumnList();
11845        if (valueCols == null || valueCols.size() < 1) {
11846            throw new SemanticIRBuildException(
11847                    Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED,
11848                    "UNPIVOT without a value column is not supported yet", pivotTable));
11849        }
11850        int valueCount = valueCols.size();
11851        TUnpivotInClause in = pc.getUnpivotInClause();
11852        if (in != null && in.getItems() != null) {
11853            for (int i = 0; i < in.getItems().size(); i++) {
11854                TUnpivotInClauseItem item = in.getItems().getElement(i);
11855                if (item == null) {
11856                    continue;
11857                }
11858                int itemWidth;
11859                if (item.getColumn() != null) {
11860                    itemWidth = 1;
11861                } else if (item.getColumnList() != null) {
11862                    itemWidth = item.getColumnList().size();
11863                } else {
11864                    itemWidth = 0;
11865                }
11866                if (itemWidth != valueCount) {
11867                    throw new SemanticIRBuildException(
11868                            Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED,
11869                            "UNPIVOT IN-list item width does not match the value-column "
11870                                    + "count (each IN-list group must have exactly "
11871                                    + valueCount + " column(s) for the positional value "
11872                                    + "mapping); this shape is not supported yet", pivotTable));
11873                }
11874            }
11875        }
11876    }
11877
11878    /**
11879     * Slice 131 / 132 — ALL columns CONSUMED by an UNPIVOT: the IN-list SOURCE
11880     * columns being narrowed (NOT the new value / FOR columns), in document
11881     * order, deduped. Built DIRECTLY against {@code sourceAlias} via the shared
11882     * {@link #addPivotConsumedRef} filter (resolver2 NOT_FOUND-in-pivot quirk,
11883     * slice 129). This is also the source list for the FOR output column (the
11884     * FOR values are the literal labels of the narrowed columns, attributed to
11885     * every source column for slice-131 / dlineage parity).
11886     *
11887     * <p>Slice 132 extends slice 131 to walk column-group items: a multi-value
11888     * UNPIVOT IN-list item is a column GROUP ({@code getColumn() == null},
11889     * {@code getColumnList()} populated), so the group columns are flattened
11890     * into this list in document order.
11891     *
11892     * <p>Note: this does NOT reuse {@link #collectPivotConsumedRefs}, which reads
11893     * {@code getPivotColumnList()} — for an UNPIVOT that is the NEW FOR column,
11894     * not a source column, and would be wrongly captured as a consumed ref.
11895     */
11896    private static List<ColumnRef> collectUnpivotInListRefs(TPivotClause pc,
11897                                                            String sourceAlias) {
11898        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11899        TUnpivotInClause in = pc.getUnpivotInClause();
11900        if (in != null && in.getItems() != null) {
11901            for (int i = 0; i < in.getItems().size(); i++) {
11902                TUnpivotInClauseItem item = in.getItems().getElement(i);
11903                if (item == null) {
11904                    continue;
11905                }
11906                if (item.getColumn() != null) {
11907                    addPivotConsumedRef(refs, item.getColumn(), sourceAlias);
11908                } else if (item.getColumnList() != null) {
11909                    TObjectNameList group = item.getColumnList();
11910                    for (int k = 0; k < group.size(); k++) {
11911                        addPivotConsumedRef(refs, group.getObjectName(k), sourceAlias);
11912                    }
11913                }
11914            }
11915        }
11916        return new ArrayList<>(refs);
11917    }
11918
11919    /**
11920     * Slice 132 — the source column ref(s) for the value column at
11921     * {@code position} (positional mapping per roadmap §13): for each IN-list
11922     * item, the group column at {@code position} (or {@code getColumn()} for a
11923     * single-column item when {@code position == 0}). Document order, deduped,
11924     * built directly against {@code sourceAlias}.
11925     *
11926     * <p>This unifies the single-value and multi-value cases: for a single-value
11927     * UNPIVOT each item is a single column and {@code position} is always 0, so
11928     * this returns ALL IN-list source columns — byte-identical to the slice-131
11929     * value-column lineage. For a multi-value UNPIVOT, value column {@code s_k}
11930     * at position {@code k} draws only from group position {@code k}
11931     * ({@code s1 <- a1, b1}; {@code s2 <- a2, b2}), which is more precise than
11932     * the authoritative dlineage handler (it feeds every source column into
11933     * every output column). The shape gate
11934     * ({@link #rejectUnsupportedUnpivotShape}) guarantees every item width
11935     * equals the value count, so {@code position} is always in range; the bounds
11936     * check is defensive.
11937     */
11938    private static List<ColumnRef> collectUnpivotValueColumnRefs(TPivotClause pc,
11939                                                                 String sourceAlias,
11940                                                                 int position) {
11941        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
11942        TUnpivotInClause in = pc.getUnpivotInClause();
11943        if (in != null && in.getItems() != null) {
11944            for (int i = 0; i < in.getItems().size(); i++) {
11945                TUnpivotInClauseItem item = in.getItems().getElement(i);
11946                if (item == null) {
11947                    continue;
11948                }
11949                TObjectName col = null;
11950                if (item.getColumn() != null) {
11951                    if (position == 0) {
11952                        col = item.getColumn();
11953                    }
11954                } else if (item.getColumnList() != null
11955                        && position < item.getColumnList().size()) {
11956                    col = item.getColumnList().getObjectName(position);
11957                }
11958                addPivotConsumedRef(refs, col, sourceAlias);
11959            }
11960        }
11961        return new ArrayList<>(refs);
11962    }
11963
11964    /**
11965     * Slice 132 — map each UNPIVOT value-column NAME to its position in the
11966     * value-column list, so a projected column can be matched to its positional
11967     * source group. First occurrence wins on a (degenerate) duplicate name.
11968     */
11969    private static Map<String, Integer> unpivotValueColumnPositions(TPivotClause pc) {
11970        Map<String, Integer> positions = new HashMap<>();
11971        TObjectNameList vcl = pc.getValueColumnList();
11972        if (vcl != null) {
11973            for (int i = 0; i < vcl.size(); i++) {
11974                TObjectName n = vcl.getObjectName(i);
11975                if (n == null) {
11976                    continue;
11977                }
11978                String name = n.getColumnNameOnly();
11979                if (name != null && !name.isEmpty() && !positions.containsKey(name)) {
11980                    positions.put(name, i);
11981                }
11982            }
11983        }
11984        return positions;
11985    }
11986
11987    /** Slice 131 — the UNPIVOT FOR/name-column name, or null. */
11988    private static String unpivotForColumnName(TPivotClause pc) {
11989        TObjectNameList pcl = pc.getPivotColumnList();
11990        if (pcl != null && pcl.size() >= 1 && pcl.getObjectName(0) != null) {
11991            return pcl.getObjectName(0).getColumnNameOnly();
11992        }
11993        if (pc.getPivotColumn() != null) {
11994            return pc.getPivotColumn().getColumnNameOnly();
11995        }
11996        return null;
11997    }
11998
11999    /**
12000     * Slice 131 — build output columns for an UNPIVOT SELECT (the deterministic
12001     * single-value / simple-IN shape; multi-value / column-group already
12002     * deferred by {@link #rejectUnsupportedUnpivotShape}). Each projected result
12003     * column is matched by its underlying name ({@link TResultColumn#getColumnNameOnly()},
12004     * NOT the output alias — mirrors slice 130):
12005     * <ul>
12006     *   <li><b>value column</b> — underlying name == a value-column name. Holds
12007     *       the unpivoted values, so {@code derived=true}, {@code aggregate=false}.
12008     *       Sources are POSITIONAL (slice 132): value column at position {@code k}
12009     *       draws from group position {@code k} across all IN-list items
12010     *       ({@code s1 <- a1, b1}; {@code s2 <- a2, b2}). For a single-value
12011     *       UNPIVOT this collapses to ALL IN-list source columns (slice 131).</li>
12012     *   <li><b>FOR/name column</b> — underlying name == the FOR-column name. Holds
12013     *       the literal NAMES / labels of the unpivoted columns; attributed to ALL
12014     *       IN-list source columns (slice-131 / dlineage parity — both halves of a
12015     *       single-value UNPIVOT trace to every narrowed source column).
12016     *       {@code derived=true}, {@code aggregate=false}.</li>
12017     *   <li><b>passthrough column</b> — any other {@code simple_object_name_t}
12018     *       projection. Direct reference to the source column:
12019     *       {@code derived=false}, {@code aggregate=false}, single source
12020     *       {@code sourceAlias.colName} (underlying name, alias respected in the
12021     *       output NAME).</li>
12022     *   <li><b>expression projection</b> — kept as a NAME-only skeleton
12023     *       ({@link #pivotSkeletonOutput}); no single source column.</li>
12024     * </ul>
12025     * Value / FOR matching is exact (mirrors slice 130). No name-collision risk:
12026     * PIVOT/UNPIVOT semantics forbid the value / FOR column from naming an
12027     * existing input column, so a passthrough source column can never coincide
12028     * with the value / FOR name. All refs are built DIRECTLY against
12029     * {@code sourceAlias} (single base-table source; resolver2 NOT_FOUND quirk).
12030     */
12031    private static List<OutputColumn> buildUnpivotOutputColumns(TSelectSqlStatement select,
12032                                                                TTable pivotTable,
12033                                                                TPivotClause pc,
12034                                                                String sourceAlias,
12035                                                                TTable sourceTable,
12036                                                                NameBindingProvider provider) {
12037        TResultColumnList rcl = select.getResultColumnList();
12038        if (rcl == null || rcl.size() == 0) {
12039            throw new SemanticIRBuildException(
12040                    Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS,
12041                    "SELECT has no projected columns", select));
12042        }
12043
12044        // Slice 134 / 135 (sub-slice (b2-unpivot)) — a SOLE bare `SELECT *` over a
12045        // well-formed UNPIVOT (single-value, slice 131; or multi-value-column /
12046        // column-group, slice 132 — both already validated by the
12047        // rejectUnsupportedUnpivotShape width invariant run earlier in
12048        // buildPivotSelect) is expanded from the source table's catalog column list
12049        // into the synthesised output schema (passthrough columns then the FOR/name
12050        // column then the value column(s)). Slice 135 lifted the slice-134
12051        // valueCount==1 gate to valueCount>=1 so the multi-value-column case is
12052        // admitted too.
12053        //
12054        // Slice 152 — the SOLE star is now admitted whether it is BARE (`*`) or
12055        // QUALIFIED (`u.*` / `src.*`), mirroring slice 151 on the PIVOT path. An
12056        // admitted UNPIVOT SELECT has exactly ONE relation in scope — the unpivot
12057        // output (isPivotSelect requires a FROM-UNPIVOT with no JOIN) — so a
12058        // qualified star can only mean that one relation and is therefore
12059        // equivalent to a bare `*`; expandUnpivotBareStar synthesises the identical
12060        // schema (it never reads the star's qualifier, only the source columns and
12061        // the unpivot clause). The isBareStar gate is dropped here. A `*` mixed
12062        // with explicit columns (rcl.size() > 1) still defers via the per-column
12063        // reject below (PIVOT_STAR_NOT_SUPPORTED kept reached).
12064        TObjectNameList valueCols = pc.getValueColumnList();
12065        if (rcl.size() == 1 && valueCols != null && valueCols.size() >= 1) {
12066            TResultColumn only = rcl.getResultColumn(0);
12067            if ("*".equals(only.getColumnNameOnly())) {
12068                // Slice 137 lifted the slice-136 subquery-source deferral. A bare
12069                // SELECT * over a FROM-subquery UNPIVOT now expands the same way as
12070                // a base-table UNPIVOT: expandUnpivotBareStar reads the source's
12071                // published columns via lookupRelationColumnNames, which the
12072                // slice-137 addRelationToInScopeMap unwrap registers under the
12073                // subquery alias. No catalog is required for a subquery source —
12074                // the subquery's explicit projection IS the published schema.
12075                return expandUnpivotBareStar(pc, sourceTable, sourceAlias, provider, only);
12076            }
12077        }
12078
12079        Map<String, Integer> valuePositions = unpivotValueColumnPositions(pc);
12080        String forName = unpivotForColumnName(pc);
12081        List<ColumnRef> inListRefs = collectUnpivotInListRefs(pc, sourceAlias);
12082
12083        List<OutputColumn> outs = new ArrayList<>();
12084        for (int i = 0; i < rcl.size(); i++) {
12085            TResultColumn rc = rcl.getResultColumn(i);
12086            if ("*".equals(rc.getColumnNameOnly())) {
12087                throw new SemanticIRBuildException(
12088                        Diagnostic.error(DiagnosticCode.PIVOT_STAR_NOT_SUPPORTED,
12089                        "SELECT * over an UNPIVOT is not supported yet "
12090                                + "(the synthesised output schema is deferred); "
12091                                + "project explicit columns", rc));
12092            }
12093            String outName = effectiveOutputName(rc);
12094            String colOnly = rc.getColumnNameOnly();
12095            Integer valuePos = (colOnly == null || colOnly.isEmpty())
12096                    ? null : valuePositions.get(colOnly);
12097            if (valuePos != null) {
12098                // Value column (the unpivoted values): POSITIONAL sources — the
12099                // group column at this value column's position across every
12100                // IN-list item.
12101                outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ false,
12102                        collectUnpivotValueColumnRefs(pc, sourceAlias, valuePos),
12103                        /*windowSpec=*/ null));
12104            } else if (colOnly != null && !colOnly.isEmpty()
12105                    && colOnly.equals(forName)) {
12106                // FOR/name column (the source column NAMES / labels): traces to
12107                // every narrowed IN-list source column (dlineage parity).
12108                outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ false,
12109                        inListRefs, /*windowSpec=*/ null));
12110            } else if (colOnly != null && !colOnly.isEmpty()
12111                    && rc.getExpr() != null
12112                    && rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t) {
12113                // Passthrough column: direct reference to the source column.
12114                outs.add(new OutputColumn(outName, /*derived=*/ false, /*aggregate=*/ false,
12115                        Collections.singletonList(new ColumnRef(sourceAlias, colOnly)),
12116                        /*windowSpec=*/ null));
12117            } else {
12118                // Expression projection (or no underlying column) — keep skeleton.
12119                outs.add(pivotSkeletonOutput(outName));
12120            }
12121        }
12122        return outs;
12123    }
12124
12125    /**
12126     * Slice 134 / 135 (sub-slice (b2-unpivot)) — synthesise the full output schema
12127     * for a bare {@code SELECT *} over a well-formed UNPIVOT (single-value, slice
12128     * 131; or multi-value-column / column-group, slice 132 — slice 135 lifted the
12129     * value-count gate), using the source table's catalog column list:
12130     * <ol>
12131     *   <li><b>passthrough columns</b> — every catalog column that is NOT a
12132     *       narrowed IN-list SOURCE column ({@link #collectUnpivotInListRefs}), in
12133     *       CATALOG declaration order. Each is a direct source reference
12134     *       ({@code derived=false}, {@code aggregate=false}, single source
12135     *       {@code sourceAlias.col}).</li>
12136     *   <li><b>FOR/name column</b> — {@link #unpivotForColumnName}. Holds the
12137     *       literal NAMES of the unpivoted columns; traces to ALL IN-list source
12138     *       columns ({@code derived=true}, {@code aggregate=false}).</li>
12139     *   <li><b>value column(s)</b> — every {@code getValueColumnList()} entry, in
12140     *       declaration order. Holds the unpivoted values; the value column at
12141     *       position {@code k} draws POSITIONALLY from group position {@code k}
12142     *       across every IN-list item ({@link #collectUnpivotValueColumnRefs}, the
12143     *       slice-132 mechanism): {@code s1 <- a1, b1}; {@code s2 <- a2, b2}. For a
12144     *       single-value UNPIVOT (slice 134) the sole position 0 collapses to ALL
12145     *       IN-list source columns ({@code derived=true}, {@code aggregate=false}).</li>
12146     * </ol>
12147     * This is the Oracle / Redshift {@code SELECT *} UNPIVOT order: the passthrough
12148     * columns first, then the FOR/name column, then the value column(s). The
12149     * per-column lineage is byte-identical to the slice-131 / 132 explicit-projection
12150     * path ({@link #buildUnpivotOutputColumns}); only the names and order are
12151     * synthesised here from the catalog + clause.
12152     *
12153     * <p>The passthrough subtraction folds names with {@code toLowerCase(Locale.ROOT)}
12154     * — the same identifier canonicalisation the other semantic-IR catalog matchers
12155     * use ({@link #expandPivotBareStar}, {@link #lookupRelationColumnNames}). The
12156     * FOR/value column names are NEW (UNPIVOT semantics forbid them from colliding
12157     * with a source column), so they are never in the catalog and need no subtraction.
12158     *
12159     * <p>Throws {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED} (reused from
12160     * slice 133) when no catalog column list is available, with an
12161     * UNPIVOT-discriminated message (slice-80 message-text-discrimination contract).
12162     */
12163    private static List<OutputColumn> expandUnpivotBareStar(TPivotClause pc,
12164                                                            TTable sourceTable,
12165                                                            String sourceAlias,
12166                                                            NameBindingProvider provider,
12167                                                            TResultColumn star) {
12168        List<String> cols = lookupRelationColumnNames(sourceTable, provider);
12169        if (cols == null || cols.isEmpty()) {
12170            throw new SemanticIRBuildException(
12171                    Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED,
12172                    "SELECT * over an UNPIVOT requires catalog metadata for source '"
12173                            + sourceAlias + "' to expand the implicit passthrough columns; "
12174                            + "supply a Catalog via "
12175                            + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star));
12176        }
12177        // Columns NARROWED by the UNPIVOT (the IN-list SOURCE columns); these are
12178        // removed from the passthrough set. Folded to lower case (Locale.ROOT) to
12179        // match the catalog identifier canonicalisation used by the other
12180        // semantic-IR catalog matchers. addPivotConsumedRef already filters out
12181        // null / empty names, so getColumnName() is non-null; the guard is defensive.
12182        List<ColumnRef> inListRefs = collectUnpivotInListRefs(pc, sourceAlias);
12183        Set<String> consumedLC = new HashSet<>();
12184        for (ColumnRef r : inListRefs) {
12185            String cn = (r == null) ? null : r.getColumnName();
12186            if (cn != null && !cn.isEmpty()) {
12187                consumedLC.add(cn.toLowerCase(Locale.ROOT));
12188            }
12189        }
12190        List<OutputColumn> outs = new ArrayList<>();
12191        // 1. passthrough columns (catalog order, minus the narrowed IN-list sources).
12192        for (String c : cols) {
12193            if (c == null || c.isEmpty()) {
12194                continue;
12195            }
12196            if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) {
12197                continue;
12198            }
12199            outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false,
12200                    Collections.singletonList(new ColumnRef(sourceAlias, c)),
12201                    /*windowSpec=*/ null));
12202        }
12203        // 2. FOR/name column — the literal labels of the narrowed columns; traces
12204        //    to every IN-list source column (slice-131 / dlineage parity).
12205        String forName = unpivotForColumnName(pc);
12206        if (forName != null && !forName.isEmpty()) {
12207            outs.add(new OutputColumn(forName, /*derived=*/ true, /*aggregate=*/ false,
12208                    inListRefs, /*windowSpec=*/ null));
12209        }
12210        // 3. value column(s) — the unpivoted values, in getValueColumnList()
12211        //    declaration order. Slice 135: value column at position k draws from
12212        //    group position k across every IN-list item (collectUnpivotValueColumnRefs,
12213        //    the slice-132 positional mechanism): s1 <- a1, b1; s2 <- a2, b2. For a
12214        //    single-value UNPIVOT (slice 134) the sole position 0 collapses to all
12215        //    IN-list source columns, byte-identical to the prior behaviour.
12216        TObjectNameList valueCols = pc.getValueColumnList();
12217        if (valueCols != null) {
12218            for (int k = 0; k < valueCols.size(); k++) {
12219                TObjectName valueCol = valueCols.getObjectName(k);
12220                String valueName = (valueCol != null) ? valueCol.getColumnNameOnly() : null;
12221                if (valueName != null && !valueName.isEmpty()) {
12222                    outs.add(new OutputColumn(valueName, /*derived=*/ true, /*aggregate=*/ false,
12223                            collectUnpivotValueColumnRefs(pc, sourceAlias, k), /*windowSpec=*/ null));
12224                }
12225            }
12226        }
12227        return outs;
12228    }
12229
12230    /**
12231     * Slices 70 and 71: build per-statement row-limit metadata from
12232     * {@code TLimitClause}, {@code TTopClause}, {@code TOffsetClause},
12233     * or {@code TFetchFirstClause}. Returns {@code null} when no
12234     * row-limit clause is present. All admit / reject decisions for
12235     * single-SELECT row-limit clauses live here; the set-op outer
12236     * row-limit path is rejected separately by
12237     * {@link #rejectSetOpRowLimit} (slice 72 lifts).
12238     *
12239     * <h4>Admitted shapes</h4>
12240     * <ul>
12241     *   <li>{@link RowLimitKind#LIMIT} — {@code TLimitClause} with
12242     *       non-null {@code getRow_count()}. Offset is populated when
12243     *       {@code TLimitClause.getOffset() != null} (PG / MySQL /
12244     *       SQLite / BigQuery / Snowflake / Redshift inline
12245     *       {@code LIMIT N OFFSET M}, MySQL old-style {@code LIMIT M, N},
12246     *       Informix {@code SKIP m LIMIT n}).</li>
12247     *   <li>{@link RowLimitKind#FETCH_FIRST} — {@code TLimitClause} with
12248     *       non-null {@code getSelectFetchFirstValue()} (PG
12249     *       {@code FETCH FIRST}, Informix {@code FIRST n}). Offset is
12250     *       populated when present (PG
12251     *       {@code OFFSET m FETCH FIRST n}, Informix
12252     *       {@code SKIP m FIRST n}). Also fires for
12253     *       {@code TFetchFirstClause} with non-null
12254     *       {@code getFetchValue()} (Oracle / SQL Server
12255     *       {@code FETCH FIRST/NEXT N ROWS ONLY}) when no
12256     *       {@code TOffsetClause} is present.</li>
12257     *   <li>{@link RowLimitKind#TOP} — {@code TTopClause} with non-null
12258     *       {@code getExpr()} and neither {@code isPercent()} nor
12259     *       {@code isWithties()} set. SQL Server {@code SELECT TOP N}.</li>
12260     *   <li>{@link RowLimitKind#OFFSET_FETCH} — Oracle / SQL Server
12261     *       {@code OFFSET m ROWS [FETCH NEXT n ROWS ONLY]} routed via
12262     *       the dedicated {@code TOffsetClause} + {@code TFetchFirstClause}
12263     *       pair, and PG offset-only {@code OFFSET m} routed via
12264     *       {@code TLimitClause.getOffset()} when {@code row_count} and
12265     *       {@code selectFetchFirstValue} are both null.
12266     *       {@link RowLimit#getCount()} may be {@code null} for
12267     *       offset-only forms.</li>
12268     * </ul>
12269     *
12270     * <h4>Rejects</h4>
12271     * <ul>
12272     *   <li>{@link DiagnosticCode#ROW_LIMIT_TOP_PERCENT_NOT_SUPPORTED}
12273     *       — {@code TOP N PERCENT}. The sampling semantics differ from
12274     *       fixed-row {@code LIMIT} enough to warrant a dedicated slice.</li>
12275     *   <li>{@link DiagnosticCode#ROW_LIMIT_TOP_WITH_TIES_NOT_SUPPORTED}
12276     *       — {@code TOP N WITH TIES}. Requires modeling the ORDER BY
12277     *       tie-handling interaction; deferred.</li>
12278     *   <li>{@link DiagnosticCode#ROW_LIMIT_HIVE_LIMIT_GRAMMAR_QUIRK} —
12279     *       Hive single-argument {@code LIMIT N} parser routes the
12280     *       count through {@code TLimitClause.getOffset()} with
12281     *       {@code row_count == null}, which is indistinguishable at
12282     *       the AST level from PG offset-only {@code OFFSET m}. Pinning
12283     *       this with a vendor-specific guard prevents emitting
12284     *       semantically-wrong {@code OFFSET_FETCH} metadata for what
12285     *       the SQL author wrote as a LIMIT. A future grammar fix
12286     *       should route the count through {@code getRow_count()}; this
12287     *       guard can be removed then.</li>
12288     *   <li>{@link DiagnosticCode#ROW_LIMIT_LIMIT_NOT_SUPPORTED} —
12289     *       Vertica TIMESERIES windowing on {@code TLimitClause}
12290     *       ({@code getWindowDef() != null}). Defensive; not modeled.</li>
12291     *   <li>{@link DiagnosticCode#ROW_LIMIT_COUNT_UNRESOLVED} — the
12292     *       parser constructed a row-limit clause node but did not
12293     *       populate any count slot:
12294     *       <ul>
12295     *         <li>{@code TLimitClause} with {@code row_count},
12296     *             {@code selectFetchFirstValue}, and {@code offset} all
12297     *             null (defensive; not observed in probe runs).</li>
12298     *         <li>{@code TFetchFirstClause} with null fetchValue —
12299     *             ANSI / DB2 grammar incompleteness (the parser
12300     *             constructs the clause node but does not populate the
12301     *             count). Future grammar fix can lift this.</li>
12302     *         <li>{@code TTopClause} with null expression (defensive).</li>
12303     *       </ul></li>
12304     * </ul>
12305     */
12306    private static RowLimit buildRowLimit(TSelectSqlStatement select) {
12307        TLimitClause limit = select.getLimitClause();
12308        if (limit != null) {
12309            // Vertica TIMESERIES window on TLimitClause — defensive; rare.
12310            // Pre-empts the row_count / fff branches because the windowed
12311            // form is its own semantic surface.
12312            if (limit.getWindowDef() != null) {
12313                throw new SemanticIRBuildException(
12314                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_LIMIT_NOT_SUPPORTED,
12315                        "row-limit clause LIMIT with Vertica TIMESERIES window "
12316                                + "is not supported yet", limit));
12317            }
12318
12319            TExpression rc = limit.getRow_count();
12320            TExpression off = limit.getOffset();
12321            TExpression fff = limit.getSelectFetchFirstValue();
12322
12323            // Hive single-argument LIMIT parser quirk: the count ends
12324            // up on offset with row_count=null. Vendor-conditional
12325            // because the same AST shape is legitimate PG offset-only.
12326            if (select.dbvendor == EDbVendor.dbvhive
12327                    && rc == null && off != null && fff == null) {
12328                throw new SemanticIRBuildException(
12329                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_HIVE_LIMIT_GRAMMAR_QUIRK,
12330                        "Hive single-argument LIMIT N is currently mis-routed "
12331                                + "by the parser (count appears on TLimitClause.getOffset() "
12332                                + "with row_count=null); fix the Hive grammar to route "
12333                                + "the count through getRow_count() to lift this guard", limit));
12334            }
12335
12336            if (rc != null) {
12337                // LIMIT N with optional OFFSET M (PG/MySQL/SQLite/
12338                // BigQuery/Snowflake/Redshift inline LIMIT-OFFSET,
12339                // MySQL old-style LIMIT M,N, Informix SKIP m LIMIT n).
12340                return new RowLimit(RowLimitKind.LIMIT,
12341                        rc.toString(),
12342                        off != null ? off.toString() : null);
12343            }
12344            if (fff != null) {
12345                // FETCH FIRST via the PG/Informix routing through
12346                // TLimitClause, with optional OFFSET (PG
12347                // OFFSET m FETCH FIRST n; Informix SKIP m FIRST n).
12348                return new RowLimit(RowLimitKind.FETCH_FIRST,
12349                        fff.toString(),
12350                        off != null ? off.toString() : null);
12351            }
12352            if (off != null) {
12353                // Offset-only via TLimitClause (PG OFFSET m [ROWS]).
12354                return new RowLimit(RowLimitKind.OFFSET_FETCH,
12355                        /*count=*/ null,
12356                        off.toString());
12357            }
12358            // Defensive: TLimitClause present with all four slots null.
12359            throw new SemanticIRBuildException(
12360                    Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
12361                    "row-limit clause LIMIT is present but no count, offset, "
12362                            + "or FETCH FIRST value is populated on the parser AST", limit));
12363        }
12364
12365        TTopClause top = select.getTopClause();
12366        if (top != null) {
12367            if (top.isPercent()) {
12368                throw new SemanticIRBuildException(
12369                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_TOP_PERCENT_NOT_SUPPORTED,
12370                        "row-limit clause TOP N PERCENT is not supported yet; "
12371                                + "sampling semantics warrant a dedicated slice", top));
12372            }
12373            if (top.isWithties()) {
12374                throw new SemanticIRBuildException(
12375                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_TOP_WITH_TIES_NOT_SUPPORTED,
12376                        "row-limit clause TOP N WITH TIES is not supported yet; "
12377                                + "tie-handling semantics warrant a dedicated slice", top));
12378            }
12379            TExpression e = top.getExpr();
12380            if (e == null) {
12381                throw new SemanticIRBuildException(
12382                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
12383                        "row-limit clause TOP is present but the count expression "
12384                                + "is not populated on the parser AST", top));
12385            }
12386            return new RowLimit(RowLimitKind.TOP, e.toString(), /*offset=*/ null);
12387        }
12388
12389        TOffsetClause offClause = select.getOffsetClause();
12390        TFetchFirstClause fetch = select.getFetchFirstClause();
12391        if (offClause != null) {
12392            // Oracle / SQL Server OFFSET m ROWS [FETCH NEXT n ROWS ONLY].
12393            // The optional FETCH NEXT counterpart populates
12394            // TFetchFirstClause when present.
12395            String offsetText = offClause.getSelectOffsetValue() != null
12396                    ? offClause.getSelectOffsetValue().toString()
12397                    : null;
12398            if (offsetText == null) {
12399                throw new SemanticIRBuildException(
12400                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
12401                        "row-limit clause OFFSET is present but the offset value "
12402                                + "is not populated on the parser AST", offClause));
12403            }
12404            String countText = null;
12405            if (fetch != null && fetch.getFetchValue() != null) {
12406                countText = fetch.getFetchValue().toString();
12407            }
12408            return new RowLimit(RowLimitKind.OFFSET_FETCH, countText, offsetText);
12409        }
12410        if (fetch != null) {
12411            if (fetch.getFetchValue() != null) {
12412                // Oracle / SQL Server FETCH FIRST/NEXT N ROWS ONLY
12413                // without OFFSET (TOffsetClause was null above).
12414                return new RowLimit(RowLimitKind.FETCH_FIRST,
12415                        fetch.getFetchValue().toString(), /*offset=*/ null);
12416            }
12417            // ANSI / DB2: TFetchFirstClause is non-null but fetchValue
12418            // is null because the grammar does not pass the count into
12419            // the node initializer. Reject so the gap is visible.
12420            throw new SemanticIRBuildException(
12421                    Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
12422                    "row-limit clause FETCH FIRST is present but the count "
12423                            + "expression is not populated on the parser AST "
12424                            + "(ANSI / DB2 grammar gap)", fetch));
12425        }
12426        return null;
12427    }
12428
12429    /**
12430     * Slice 72: build the OUTER set-op statement's row-limit. Same
12431     * decision tree as {@link #buildRowLimit} for SELECT-level routing
12432     * (PG/MySQL/SQLite/BigQuery/Snowflake/Redshift via
12433     * {@code TLimitClause}, plus Hive/Vertica/ANSI-DB2 defensives),
12434     * with an additional MSSQL-only fallback that reads the OFFSET /
12435     * FETCH FIRST clauses off the outer {@code TOrderBy} node.
12436     *
12437     * <p>Empirical AST shapes (probed against the current parser):
12438     * <ul>
12439     *   <li>PG / MySQL / SQLite / BigQuery / Snowflake / Redshift route
12440     *       set-op outer LIMIT / OFFSET / FETCH FIRST onto
12441     *       {@code setOp.getLimitClause()} — handled by the primary
12442     *       {@code buildRowLimit} path.</li>
12443     *   <li>MSSQL routes set-op outer {@code OFFSET m ROWS [FETCH NEXT
12444     *       n ROWS ONLY]} EXCLUSIVELY onto
12445     *       {@code setOp.getOrderbyClause().getOffsetClause()} /
12446     *       {@code .getFetchFirstClause()} — NOT duplicated onto the
12447     *       SELECT node (opposite of single-SELECT MSSQL where slice 71
12448     *       saw duplication onto both). The TOrderBy fallback below
12449     *       handles this.</li>
12450     *   <li>Oracle drops set-op outer OFFSET / FETCH from both SELECT
12451     *       and TOrderBy slots silently; nothing for slice 72 to
12452     *       emit. A future Oracle grammar fix can lift this.</li>
12453     * </ul>
12454     *
12455     * <p>The TOrderBy fallback is vendor-gated to MSSQL to avoid
12456     * over-admitting on unprobed dialects (per codex round-1 B1).
12457     * Kind mapping mirrors {@link #buildRowLimit}'s single-SELECT
12458     * decision tree:
12459     * <ul>
12460     *   <li>{@code TOffsetClause} + {@code TFetchFirstClause} both
12461     *       populated → {@code OFFSET_FETCH/count/offset}</li>
12462     *   <li>{@code TOffsetClause} only → {@code OFFSET_FETCH/null/offset}</li>
12463     *   <li>{@code TFetchFirstClause} only → {@code FETCH_FIRST/count/null}
12464     *       (unreachable via current MSSQL grammar which requires
12465     *       OFFSET before FETCH; retained as defensive routing-shape
12466     *       parity with single-SELECT)</li>
12467     *   <li>Defensive null-value rejects mirror single-SELECT
12468     *       {@link #buildRowLimit}: a present {@code TOffsetClause}
12469     *       with a null offset value throws
12470     *       {@code ROW_LIMIT_COUNT_UNRESOLVED}; a present bare
12471     *       {@code TFetchFirstClause} (no companion OFFSET) with a
12472     *       null fetch value throws the same code. When both clauses
12473     *       are present, only the offset slot must be populated; a
12474     *       null fetch value is silently treated as offset-only
12475     *       (matches the single-SELECT
12476     *       {@code TOffsetClause + TFetchFirstClause} branch in
12477     *       {@code buildRowLimit}).</li>
12478     * </ul>
12479     */
12480    private static RowLimit buildSetOpRowLimit(TSelectSqlStatement setOp) {
12481        // Primary path: SELECT-level routing. Covers PG/MySQL/SQLite/
12482        // BigQuery/Snowflake/Redshift via TLimitClause, plus inherited
12483        // Hive / Vertica / ANSI-DB2 defensives from buildRowLimit.
12484        RowLimit fromSelect = buildRowLimit(setOp);
12485        if (fromSelect != null) {
12486            return fromSelect;
12487        }
12488        // MSSQL-only TOrderBy fallback (codex B1 vendor gate).
12489        if (setOp.dbvendor != EDbVendor.dbvmssql) {
12490            return null;
12491        }
12492        TOrderBy orderBy = setOp.getOrderbyClause();
12493        if (orderBy == null) return null;
12494        TOffsetClause oc = orderBy.getOffsetClause();
12495        TFetchFirstClause fc = orderBy.getFetchFirstClause();
12496        if (oc == null && fc == null) return null;
12497
12498        String offsetText = (oc != null && oc.getSelectOffsetValue() != null)
12499                ? oc.getSelectOffsetValue().toString() : null;
12500        String countText = (fc != null && fc.getFetchValue() != null)
12501                ? fc.getFetchValue().toString() : null;
12502
12503        if (oc != null && fc != null) {
12504            if (offsetText == null) {
12505                // Mirrors single-SELECT buildRowLimit TOffsetClause path:
12506                // a present OFFSET clause must populate its value (the
12507                // FETCH NEXT counterpart is optional; null countText is
12508                // silently treated as offset-only).
12509                throw new SemanticIRBuildException(
12510                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
12511                        "MSSQL set-op outer OFFSET clause present on TOrderBy "
12512                                + "but offset value is not populated on the parser AST", orderBy));
12513            }
12514            return new RowLimit(RowLimitKind.OFFSET_FETCH, countText, offsetText);
12515        }
12516        if (oc != null) {
12517            if (offsetText == null) {
12518                throw new SemanticIRBuildException(
12519                        Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
12520                        "MSSQL set-op outer OFFSET clause present on TOrderBy "
12521                                + "but offset value is not populated on the parser AST", orderBy));
12522            }
12523            return new RowLimit(RowLimitKind.OFFSET_FETCH, /*count=*/ null, offsetText);
12524        }
12525        // fc only (oc == null). Defensive: not reachable via current
12526        // MSSQL grammar which requires OFFSET before FETCH NEXT.
12527        if (countText == null) {
12528            throw new SemanticIRBuildException(
12529                    Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED,
12530                    "MSSQL set-op outer FETCH FIRST clause present on TOrderBy "
12531                            + "but fetch value is not populated on the parser AST", orderBy));
12532        }
12533        return new RowLimit(RowLimitKind.FETCH_FIRST, countText, /*offset=*/ null);
12534    }
12535
12536    /**
12537     * Resolve a {@link TSelectSqlStatement}'s row-filter clause to the IR's
12538     * {@code distinct} flag. Mapping:
12539     *
12540     * <ul>
12541     *   <li>no clause / {@code urfNone} / {@code urfAll}: {@code false}</li>
12542     *   <li>{@code urfDistinct}: {@code true}</li>
12543     *   <li>{@code urfUnique}: {@code true} — Oracle treats
12544     *       {@code SELECT UNIQUE} as a deprecated synonym for
12545     *       {@code SELECT DISTINCT}; both produce the same row-set.</li>
12546     *   <li>{@code urfDistinctOn}: admits (slice 73). Returns
12547     *       {@code true} for the boolean flag; the
12548     *       {@code DISTINCT ON (cols)} partition keys are collected
12549     *       separately by
12550     *       {@link #buildDistinctOnColumnRefs(TSelectSqlStatement,
12551     *       NameBindingProvider)} so the column-ref collection runs
12552     *       AFTER {@code UsingScope} is installed (matching the timing
12553     *       of {@link #buildGroupByColumnRefs} and friends).</li>
12554     *   <li>{@code urfDistinctRow}, {@code urfNormalize}: rejected
12555     *       (vendor-specific; not yet a documented IR shape).</li>
12556     *   <li>null filter on a non-null {@code TSelectDistinct}, or a
12557     *       new enum value the switch hasn't seen yet: rejected, so a
12558     *       future {@code EUniqueRowFilterType} addition fails loudly
12559     *       rather than silently classifying as {@code distinct=false}.</li>
12560     * </ul>
12561     */
12562    private static boolean resolveDistinctFlag(TSelectSqlStatement select) {
12563        TSelectDistinct sd = select.getSelectDistinct();
12564        if (sd == null) return false;
12565        EUniqueRowFilterType urf = sd.getUniqueRowFilter();
12566        if (urf == null) {
12567            throw new SemanticIRBuildException(
12568                    Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_NULL,
12569                    "SELECT row-filter is null; expected one of "
12570                            + "{none, all, distinct, unique}", select));
12571        }
12572        switch (urf) {
12573            case urfNone:
12574            case urfAll:
12575                return false;
12576            case urfDistinct:
12577            case urfUnique:        // Oracle deprecated synonym for DISTINCT
12578            case urfDistinctOn:    // slice 73: refs collected separately
12579                return true;
12580            case urfDistinctRow:
12581            case urfNormalize:
12582                throw new SemanticIRBuildException(
12583                        Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_NOT_SUPPORTED,
12584                        "SELECT row-filter " + urf + " is not supported yet", select));
12585            default:
12586                throw new SemanticIRBuildException(
12587                        Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_UNKNOWN,
12588                        "unknown SELECT row-filter " + urf, select));
12589        }
12590    }
12591
12592    /**
12593     * Slice 73: collect physical column references from a
12594     * {@code SELECT DISTINCT ON (cols)} expression list. Returns the
12595     * empty list for plain {@code DISTINCT}, {@code UNIQUE},
12596     * {@code ALL}, and the no-filter case. Only PostgreSQL and
12597     * Greenplum expose {@code urfDistinctOn} with a populated
12598     * {@link TSelectDistinct#getExpressionList()}; Oracle, MySQL, and
12599     * Redshift silently drop the {@code ON (...)} clause and parse the
12600     * SELECT as plain {@code DISTINCT}, so this helper returns
12601     * {@code []} for those vendors regardless of the surface SQL.
12602     *
12603     * <p>Mirrors {@link #buildGroupByColumnRefs}: subqueries and window
12604     * functions in the expression list are rejected BEFORE
12605     * {@link #collectColumnRefs} descends, so inner-scope refs cannot
12606     * leak into {@code distinctOnColumnRefs}. Compound expressions
12607     * ({@code a + b}, {@code CASE WHEN ...}) and aggregate arguments
12608     * ({@code COUNT(x)}) are descended into so the underlying column
12609     * refs are captured.
12610     */
12611    private static List<ColumnRef> buildDistinctOnColumnRefs(
12612            TSelectSqlStatement select, NameBindingProvider provider) {
12613        TSelectDistinct sd = select.getSelectDistinct();
12614        if (sd == null
12615                || sd.getUniqueRowFilter() != EUniqueRowFilterType.urfDistinctOn) {
12616            return new ArrayList<>();
12617        }
12618        TExpressionList el = sd.getExpressionList();
12619        if (el == null || el.size() == 0) {
12620            // PG grammar requires at least one expression after
12621            // DISTINCT ON (; this branch is defensive — surface a
12622            // clear diagnostic rather than silently emit [].
12623            throw new SemanticIRBuildException(
12624                    Diagnostic.error(DiagnosticCode.DISTINCT_ON_EMPTY_COLUMN_LIST,
12625                    "DISTINCT ON requires at least one expression but the "
12626                            + "AST exposes an empty list", sd));
12627        }
12628        // Iterate items explicitly so each per-expression reject
12629        // diagnostic points at the offending expression. Equivalent
12630        // to running containsAnySubquery / rejectWindowFunctionInScope
12631        // / collectColumnRefs on the whole list (TExpressionList
12632        // inherits TParseTreeNodeList.acceptChildren which already
12633        // iterates element children), but the loop body gives
12634        // clearer rejection sites and lets us dedup refs across
12635        // expressions in declaration order.
12636        List<ColumnRef> refs = new ArrayList<>();
12637        for (int i = 0; i < el.size(); i++) {
12638            TExpression expr = el.getExpression(i);
12639            if (containsAnySubqueryExpression(expr)) {
12640                throw new SemanticIRBuildException(
12641                        Diagnostic.error(DiagnosticCode.DISTINCT_ON_HAS_SUBQUERY_NOT_SUPPORTED,
12642                        "DISTINCT ON expression list contains a subquery; "
12643                                + "subqueries in DISTINCT ON are not supported yet", sd));
12644            }
12645            rejectWindowFunctionInScope(expr, "DISTINCT ON expression list");
12646            for (ColumnRef ref : collectColumnRefs(expr, provider)) {
12647                if (!refs.contains(ref)) refs.add(ref);
12648            }
12649        }
12650        return refs;
12651    }
12652
12653    private static boolean hasNoFromSource(TSelectSqlStatement select) {
12654        return select.joins == null || select.joins.size() == 0;
12655    }
12656
12657    private static boolean allResultColumnsAreConstantExpressions(TSelectSqlStatement select) {
12658        TResultColumnList rcl = select.getResultColumnList();
12659        if (rcl == null || rcl.size() == 0) return false;
12660        for (int i = 0; i < rcl.size(); i++) {
12661            TResultColumn rc = rcl.getResultColumn(i);
12662            if (rc == null || rc.getExpr() == null || !isConstantExpression(rc.getExpr())) {
12663                return false;
12664            }
12665        }
12666        return true;
12667    }
12668
12669    private static List<ColumnRef> buildGroupByColumnRefs(TSelectSqlStatement select, NameBindingProvider provider) {
12670        TGroupBy groupBy = select.getGroupByClause();
12671        if (groupBy == null || groupBy.getItems() == null || groupBy.getItems().size() == 0) {
12672            return new ArrayList<>();
12673        }
12674        TGroupByItemList items = groupBy.getItems();
12675        // Slice 127: flatten plain, ROLLUP, CUBE, and GROUPING SETS items
12676        // into the set of grouping expressions before guarding/collecting.
12677        // A plain visitor over {@code items} silently drops every column
12678        // inside a compound grouping element, because the AST visitor links
12679        // are broken there: {@link TGroupByItem#acceptChildren} descends
12680        // ONLY into its plain {@code expr} (not {@code rollupCube},
12681        // {@code groupingSet}, or {@code exprList}), and
12682        // {@link TRollupCube#acceptChildren} /
12683        // {@link TGroupingExpressionItem#acceptChildren} do not descend at
12684        // all. Pre-slice-127, `GROUP BY ROLLUP(a, c)` produced empty
12685        // groupByColumnRefs and a subquery/window function buried inside a
12686        // compound element bypassed the slice-61/slice-13 guards. We do NOT
12687        // patch the shared AST {@code acceptChildren} methods (that would
12688        // ripple into dlineage / resolver); the fix is localized here.
12689        List<TExpression> groupingExprs = flattenGroupByExpressions(items);
12690        // Slice 61: reject subqueries in GROUP BY before column collection
12691        // descends into them. Pre-slice-61, queries such as `SELECT 1
12692        // FROM employees GROUP BY (SELECT id FROM departments)` reached
12693        // the constant-only projection guard and failed there; with the
12694        // slice-61 lift the projection now builds and the GROUP BY
12695        // collection would leak `departments.id` into groupByColumnRefs
12696        // even though `departments` is not in {@code relations}, breaking
12697        // the IR invariant that column refs reference an in-scope
12698        // relation. Mirrors the WHERE / HAVING / ORDER BY subquery guards.
12699        // Slice 127 runs the guard over the flattened expressions so a
12700        // subquery inside ROLLUP/CUBE/GROUPING SETS is also rejected; the
12701        // anchor stays the {@code groupBy} node for contract stability.
12702        for (TExpression e : groupingExprs) {
12703            if (containsAnySubquery(e)) {
12704                throw new SemanticIRBuildException(
12705                        Diagnostic.error(DiagnosticCode.GROUP_BY_HAS_SUBQUERY_NOT_SUPPORTED,
12706                        "GROUP BY clause contains a subquery; subqueries in "
12707                                + "GROUP BY are not supported yet", groupBy));
12708            }
12709        }
12710        // Slice 13: reject window functions in GROUP BY before collection
12711        // (slice 127 runs the guard over the flattened expressions).
12712        for (TExpression e : groupingExprs) {
12713            rejectWindowFunctionInScope(e, "GROUP BY clause");
12714        }
12715        // Visitor-based collection ensures column refs in any nested
12716        // expression (e.g. GROUP BY date_trunc('day', t)) are captured.
12717        //
12718        // Slice 127: collect with the Phase-1 source-table fallback enabled.
12719        // Resolver2's Phase-2 GROUP BY scope builder (processGroupBy) only
12720        // resolves columns inside a plain {@code item.getExpr()}; columns
12721        // inside ROLLUP / CUBE never reach processColumnReferences, so they
12722        // carry {@code resolution == null}. The slice-93 fallback promotes
12723        // such refs to EXACT_MATCH when Phase 1's linkColumnToTable has set
12724        // a source table and the SQL-written qualifier (if any) is
12725        // consistent with it — exactly the Hive multi-insert situation the
12726        // facet was built for. Plain grouping columns ARE resolved by
12727        // Resolver2 ({@code resolution != null}), so the fallback is inert
12728        // for them and the plain-GROUP-BY contract is unchanged.
12729        return collectColumnRefsFromExpressions(
12730                groupingExprs, provider.withSourceTableFallback(true));
12731    }
12732
12733    /**
12734     * Flatten a {@link TGroupByItemList} into the ordered list of leaf
12735     * grouping {@link TExpression}s, descending into ROLLUP / CUBE /
12736     * GROUPING SETS compound elements that the AST visitor does not reach
12737     * (slice 127). Leaves are emitted in left-to-right document order.
12738     *
12739     * <p>Each {@link TGroupByItem} carries exactly one (mutually exclusive
12740     * per its {@code init}) of: a plain {@code expr}, an {@code exprList}
12741     * (parenthesized {@code GROUP BY (a, b)}), a {@link TRollupCube}
12742     * (ROLLUP / CUBE), or a {@link TGroupingSet} (GROUPING SETS). Per-item
12743     * descent into the compound forms uses an explicit stack (no recursion,
12744     * per the repo iterative-traversal rule) with reverse-push so leaves
12745     * pop in document order.
12746     */
12747    private static List<TExpression> flattenGroupByExpressions(TGroupByItemList items) {
12748        List<TExpression> out = new ArrayList<>();
12749        for (int i = 0; i < items.size(); i++) {
12750            TGroupByItem item = items.getGroupByItem(i);
12751            if (item == null) {
12752                continue;
12753            }
12754            flattenGroupByItem(item, out);
12755        }
12756        return out;
12757    }
12758
12759    /**
12760     * Flatten a single {@link TGroupByItem} into the ordered list of leaf
12761     * grouping {@link TExpression}s, appending to {@code out}. Extracted
12762     * from {@link #flattenGroupByExpressions} (slice 127) so slice 128's
12763     * {@link #buildGroupingElements} can collect the member columns of one
12764     * top-level grouping element independently. Descent into ROLLUP / CUBE
12765     * / GROUPING SETS uses an explicit stack (no recursion, per the repo
12766     * iterative-traversal rule) with reverse-push so leaves pop in
12767     * document order.
12768     */
12769    private static void flattenGroupByItem(TGroupByItem item, List<TExpression> out) {
12770        if (item == null) {
12771            return;
12772        }
12773        if (item.getExpr() != null) {
12774            out.add(item.getExpr());
12775        }
12776        addExpressionListItems(item.getExprList(), out);
12777        Deque<TParseTreeNode> stack = new ArrayDeque<>();
12778        if (item.getRollupCube() != null) {
12779            stack.push(item.getRollupCube());
12780        }
12781        if (item.getGroupingSet() != null) {
12782            stack.push(item.getGroupingSet());
12783        }
12784        while (!stack.isEmpty()) {
12785            TParseTreeNode node = stack.pop();
12786            if (node instanceof TRollupCube) {
12787                addExpressionListItems(((TRollupCube) node).getItems(), out);
12788            } else if (node instanceof TGroupingSet) {
12789                TGroupingSetItemList gsItems = ((TGroupingSet) node).getItems();
12790                if (gsItems != null) {
12791                    // reverse-push so set items pop in document order
12792                    for (int j = gsItems.size() - 1; j >= 0; j--) {
12793                        TGroupingSetItem gsi = gsItems.getGroupingSetItem(j);
12794                        if (gsi != null) {
12795                            stack.push(gsi);
12796                        }
12797                    }
12798                }
12799            } else if (node instanceof TGroupingSetItem) {
12800                TGroupingSetItem gsi = (TGroupingSetItem) node;
12801                // exactly one payload per item; add direct exprs, push
12802                // the compound child so its leaves emerge in order.
12803                if (gsi.getGrouping_expression() != null) {
12804                    out.add(gsi.getGrouping_expression());
12805                }
12806                if (gsi.getExpressionItem() != null) {
12807                    stack.push(gsi.getExpressionItem());
12808                }
12809                if (gsi.getRollupCubeClause() != null) {
12810                    stack.push(gsi.getRollupCubeClause());
12811                }
12812            } else if (node instanceof TGroupingExpressionItem) {
12813                TGroupingExpressionItem gei = (TGroupingExpressionItem) node;
12814                if (gei.getExpr() != null) {
12815                    out.add(gei.getExpr());
12816                }
12817                addExpressionListItems(gei.getExprList(), out);
12818            }
12819        }
12820    }
12821
12822    /**
12823     * Slice 128 — build the structured per-top-level-element view of the
12824     * {@code GROUP BY}: one {@link GroupingElement} per top-level
12825     * {@link TGroupByItem} in document order, tagged
12826     * {@code SIMPLE} / {@code ROLLUP} / {@code CUBE} / {@code GROUPING_SETS}
12827     * (via {@link #groupingElementKind}) and carrying that element's
12828     * flattened leaf member columns.
12829     *
12830     * <p>Returns an empty list when there is no {@code GROUP BY}. Runs
12831     * <i>after</i> {@link #buildGroupByColumnRefs} in the SELECT builder, so
12832     * the slice-61 subquery and slice-13 window-function guards have already
12833     * rejected illegal grouping expressions and the per-element member
12834     * collection (which reuses {@link #collectColumnRefsFromExpressions}
12835     * with the slice-93 source-table fallback, exactly as the flat path
12836     * does) cannot newly throw. Per-element collection means a column
12837     * appearing in two top-level elements (e.g.
12838     * {@code GROUP BY a, ROLLUP(a)}) is faithfully present in both — the
12839     * flat {@link #buildGroupByColumnRefs} dedups it to a single ref, which
12840     * is the intended difference between the two slots.
12841     */
12842    private static List<GroupingElement> buildGroupingElements(
12843            TSelectSqlStatement select, NameBindingProvider provider) {
12844        TGroupBy groupBy = select.getGroupByClause();
12845        if (groupBy == null || groupBy.getItems() == null || groupBy.getItems().size() == 0) {
12846            return new ArrayList<>();
12847        }
12848        TGroupByItemList items = groupBy.getItems();
12849        NameBindingProvider fallbackProvider = provider.withSourceTableFallback(true);
12850        List<GroupingElement> out = new ArrayList<>();
12851        for (int i = 0; i < items.size(); i++) {
12852            TGroupByItem item = items.getGroupByItem(i);
12853            if (item == null) {
12854                continue;
12855            }
12856            List<TExpression> exprs = new ArrayList<>();
12857            flattenGroupByItem(item, exprs);
12858            List<ColumnRef> members = collectColumnRefsFromExpressions(exprs, fallbackProvider);
12859            out.add(new GroupingElement(groupingElementKind(item), members));
12860        }
12861        return out;
12862    }
12863
12864    /**
12865     * Classify the grouping operation of a single top-level
12866     * {@link TGroupByItem} (slice 128). The authoritative signals are the
12867     * AST node-presence checks: a non-null {@code groupingSet} is
12868     * {@code GROUPING SETS}; a non-null {@code rollupCube} carries its own
12869     * {@code rollup} / {@code cube} / {@code grouping_sets} operation
12870     * (BigQuery spells {@code GROUPING SETS} as a {@link TRollupCube} with
12871     * the {@code grouping_sets} operation). The
12872     * {@link EGroupingSetType} flag is a defensive fallback for any form
12873     * that sets the flag without populating the node; {@code gsExpr} /
12874     * {@code gsList} / {@code gsEmpty} and anything unrecognized fall
12875     * through to {@code SIMPLE} (the plain {@code expr} / {@code exprList}
12876     * grouping path).
12877     */
12878    private static GroupingElement.Kind groupingElementKind(TGroupByItem item) {
12879        if (item.getGroupingSet() != null) {
12880            return GroupingElement.Kind.GROUPING_SETS;
12881        }
12882        TRollupCube rc = item.getRollupCube();
12883        if (rc != null) {
12884            switch (rc.getOperation()) {
12885                case TRollupCube.cube:
12886                    return GroupingElement.Kind.CUBE;
12887                case TRollupCube.grouping_sets:
12888                    return GroupingElement.Kind.GROUPING_SETS;
12889                case TRollupCube.rollup:
12890                default:
12891                    return GroupingElement.Kind.ROLLUP;
12892            }
12893        }
12894        EGroupingSetType gst = item.getGroupingSetType();
12895        if (gst != null) {
12896            switch (gst) {
12897                case gsRollup:
12898                    return GroupingElement.Kind.ROLLUP;
12899                case gsCube:
12900                    return GroupingElement.Kind.CUBE;
12901                case gsSets:
12902                    return GroupingElement.Kind.GROUPING_SETS;
12903                default:
12904                    break;
12905            }
12906        }
12907        return GroupingElement.Kind.SIMPLE;
12908    }
12909
12910    /** Append every {@link TExpression} of {@code list} (if any) to {@code out}. */
12911    private static void addExpressionListItems(TExpressionList list, List<TExpression> out) {
12912        if (list == null) {
12913            return;
12914        }
12915        for (int j = 0; j < list.size(); j++) {
12916            TExpression e = list.getExpression(j);
12917            if (e != null) {
12918                out.add(e);
12919            }
12920        }
12921    }
12922
12923    /**
12924     * Multi-root variant of {@link #collectColumnRefs}: collect EXACT_MATCH
12925     * physical {@link ColumnRef}s from each expression root in order, with
12926     * the same {@code nestedSelectDepth} guard, USING merged-key handling
12927     * via {@link #appendMergedOrBoundColumnRef}, {@link LinkedHashSet}
12928     * dedup, and {@code COLUMN_BINDING_NON_EXACT} rejection. Used by
12929     * {@link #buildGroupByColumnRefs} over the slice-127 flattened
12930     * ROLLUP / CUBE / GROUPING SETS grouping expressions.
12931     */
12932    private static List<ColumnRef> collectColumnRefsFromExpressions(
12933            List<TExpression> exprs, final NameBindingProvider provider) {
12934        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
12935        final List<String> rejects = new ArrayList<>();
12936        // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at
12937        // the wrong side). Such a miss is a genuine error and must stay fatal;
12938        // only all-unqualified rejects are eligible for the join-graph degrade.
12939        final boolean[] sawQualifiedReject = {false};
12940        TParseTreeVisitor visitor = new TParseTreeVisitor() {
12941            int nestedSelectDepth = 0;
12942
12943            @Override
12944            public void preVisit(TSelectSqlStatement nested) {
12945                nestedSelectDepth++;
12946            }
12947
12948            @Override
12949            public void postVisit(TSelectSqlStatement nested) {
12950                nestedSelectDepth--;
12951            }
12952
12953            @Override
12954            public void preVisit(TObjectName node) {
12955                if (nestedSelectDepth > 0) return;
12956                appendMergedOrBoundColumnRef(node, provider, refs, rejects,
12957                        sawQualifiedReject);
12958            }
12959        };
12960        for (TExpression e : exprs) {
12961            e.acceptChildren(visitor);
12962        }
12963        if (!rejects.isEmpty()) {
12964            rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]);
12965        }
12966        return new ArrayList<>(refs);
12967    }
12968
12969    /**
12970     * Collect physical column references from the {@code HAVING} clause.
12971     *
12972     * <p>HAVING is supported regardless of whether {@code GROUP BY} is
12973     * present: standard SQL allows {@code HAVING} without {@code GROUP BY}
12974     * (the whole result set is treated as a single group), and the parser
12975     * still attaches a {@link TGroupBy} node with empty
12976     * {@code getItems()} in that case. Both shapes flow through the same
12977     * collection path.
12978     *
12979     * <p>Per-shape rejections fire <i>before</i> {@link #collectColumnRefs}
12980     * so subquery / OVER children never enter the visitor and can't leak
12981     * inner-scope refs into {@code havingColumnRefs} (mirrors slice-9
12982     * ORDER BY guards):
12983     *
12984     * <ul>
12985     *   <li>Scalar subqueries ({@link EExpressionType#subquery_t}) and
12986     *       predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)},
12987     *       {@code ANY/ALL/SOME}) — checked via both expression-type and
12988     *       {@link TExpression#getSubQuery()}, deep-scanned through the
12989     *       whole HAVING expression subtree.</li>
12990     *   <li>Window functions ({@code OVER (...)}) — standard SQL forbids
12991     *       window functions in HAVING, but defense in depth: the
12992     *       deep-scan rejecter ensures PARTITION BY / OVER ORDER BY refs
12993     *       can't leak.</li>
12994     * </ul>
12995     *
12996     * <p>Aggregate functions in HAVING are <i>not</i> rejected — they're
12997     * the most common HAVING shape ({@code HAVING SUM(salary) > 1000}).
12998     * The visitor walks into the aggregate's argument list and captures
12999     * the underlying column ref ({@code salary}) the same way slice 6
13000     * does for projection-side aggregate args.
13001     */
13002    private static List<ColumnRef> buildHavingColumnRefs(TSelectSqlStatement select,
13003                                                        NameBindingProvider provider) {
13004        TGroupBy groupBy = select.getGroupByClause();
13005        if (groupBy == null) return new ArrayList<>();
13006        TExpression having = groupBy.getHavingClause();
13007        if (having == null) return new ArrayList<>();
13008        rejectHavingScalarSubquery(having);
13009        rejectHavingWindowFunction(having);
13010        return collectColumnRefs(having, provider);
13011    }
13012
13013    /**
13014     * Reject HAVING expressions that contain a subquery anywhere in the
13015     * subtree. Catches both:
13016     *
13017     * <ul>
13018     *   <li>Scalar subqueries ({@link EExpressionType#subquery_t}) —
13019     *       e.g. {@code HAVING (SELECT MAX(salary) FROM employees) > 0}.</li>
13020     *   <li>Predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)},
13021     *       {@code ANY/ALL/SOME (SELECT ...)}) — these don't appear as
13022     *       {@code subquery_t} expression nodes but carry a non-null
13023     *       {@link TExpression#getSubQuery()}, e.g.
13024     *       {@code HAVING EXISTS (SELECT 1 FROM ...)} or
13025     *       {@code HAVING d.id IN (SELECT id FROM ...)}.</li>
13026     * </ul>
13027     *
13028     * <p>Mirrors {@link #rejectOrderByScalarSubquery}: top-level fast
13029     * path + visitor deep-scan over {@link TExpression#acceptChildren}.
13030     * The deep scan is required for nested cases like
13031     * {@code HAVING flag = 1 AND EXISTS (SELECT ...)} or
13032     * {@code HAVING CASE WHEN d.id IN (SELECT ...) THEN 1 ELSE 0 END > 0}.
13033     */
13034    private static void rejectHavingScalarSubquery(TExpression having) {
13035        if (having.getExpressionType() == EExpressionType.subquery_t
13036                || having.getSubQuery() != null) {
13037            throw new SemanticIRBuildException(
13038                    Diagnostic.error(DiagnosticCode.HAVING_SUBQUERY_NOT_SUPPORTED,
13039                    "HAVING subquery '" + having + "' is not supported yet "
13040                            + "(subqueries in HAVING would leak inner column refs)", having));
13041        }
13042        final boolean[] found = {false};
13043        having.acceptChildren(new TParseTreeVisitor() {
13044            @Override
13045            public void preVisit(TExpression e) {
13046                if (found[0]) return;
13047                if (e.getExpressionType() == EExpressionType.subquery_t
13048                        || e.getSubQuery() != null) {
13049                    found[0] = true;
13050                }
13051            }
13052        });
13053        if (found[0]) {
13054            throw new SemanticIRBuildException(
13055                    Diagnostic.error(DiagnosticCode.HAVING_HAS_SUBQUERY_NOT_SUPPORTED,
13056                    "HAVING expression '" + having + "' contains a subquery "
13057                            + "(scalar, EXISTS, IN, or ANY/ALL/SOME); not supported yet", having));
13058        }
13059    }
13060
13061    /**
13062     * Reject HAVING expressions that contain a window function. Standard
13063     * SQL forbids window functions in HAVING (analytic functions are
13064     * computed after HAVING), but defense in depth: the visitor would
13065     * descend into {@code OVER (PARTITION BY ... ORDER BY ...)} and the
13066     * inner-scope refs would otherwise leak into {@code havingColumnRefs}.
13067     * Mirrors the projection-side {@link #rejectWindowFunctions} and the
13068     * ORDER BY-side {@link #rejectOrderByWindowFunction}.
13069     */
13070    private static void rejectHavingWindowFunction(TExpression having) {
13071        final boolean[] found = {false};
13072        having.acceptChildren(new TParseTreeVisitor() {
13073            @Override
13074            public void preVisit(TFunctionCall fn) {
13075                if (found[0]) return;
13076                if (fn.getWindowDef() != null) found[0] = true;
13077            }
13078        });
13079        if (!found[0] && having.getExpressionType() == EExpressionType.function_t) {
13080            TFunctionCall fn = having.getFunctionCall();
13081            if (fn != null && fn.getWindowDef() != null) found[0] = true;
13082        }
13083        if (found[0]) {
13084            throw new SemanticIRBuildException(
13085                    Diagnostic.error(DiagnosticCode.HAVING_WINDOW_FUNCTION_NOT_SUPPORTED,
13086                    "HAVING window function '" + having + "' is not supported yet "
13087                            + "(window OVER (...) refs would leak into havingColumnRefs)", having));
13088        }
13089    }
13090
13091    /**
13092     * Slice 125: collect physical column references from a {@code QUALIFY}
13093     * clause's predicate (Snowflake / BigQuery / Teradata). QUALIFY filters
13094     * rows on window-function results; like {@code WHERE} / {@code HAVING}
13095     * it is row-influence, so its refs are captured into
13096     * {@link StatementGraph#getQualifyColumnRefs()}.
13097     *
13098     * <p>Two surface forms are admitted and reduce to the SAME influencing
13099     * base columns:
13100     * <ul>
13101     *   <li>Inline window form
13102     *       ({@code QUALIFY ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) = 1})
13103     *       — the window's PARTITION BY / ORDER BY / argument refs bind as
13104     *       base columns and are collected directly; the function-name
13105     *       node and {@code *} are skipped.</li>
13106     *   <li>Projection-alias form ({@code QUALIFY rn = 1}) — the parser
13107     *       leaves {@code rn} as a bare {@code column}-typed
13108     *       {@link TObjectName} (it is NOT retyped to {@code column_alias}
13109     *       the way ORDER BY operands are), so it does not bind to a base
13110     *       column; it resolves to the matching {@link OutputColumn} and
13111     *       contributes that output's {@code getSources()} unioned with its
13112     *       window spec's partition/order refs.</li>
13113     * </ul>
13114     *
13115     * <p>Window functions are admitted (the whole point of QUALIFY) and are
13116     * NOT validated against the slice-13 projection window-function
13117     * allowlist — QUALIFY collects only the refs that influence the row
13118     * set; it does not build a {@link WindowSpec}. Subqueries (scalar,
13119     * EXISTS, IN-SELECT, ANY/ALL/SOME) are rejected before ref collection
13120     * (they would leak inner-scope refs), reusing the repurposed
13121     * {@link DiagnosticCode#QUALIFY_NOT_SUPPORTED} code.
13122     */
13123    private static List<ColumnRef> buildQualifyColumnRefs(TSelectSqlStatement select,
13124                                                          NameBindingProvider provider,
13125                                                          List<OutputColumn> outputColumns) {
13126        if (select.getQualifyClause() == null) {
13127            return new ArrayList<>();
13128        }
13129        TExpression qualify = select.getQualifyClause().getSearchConditoin();
13130        if (qualify == null) {
13131            return new ArrayList<>();
13132        }
13133        rejectQualifySubquery(qualify);
13134        rejectUnsupportedQualifyWindowFunction(qualify);
13135        return collectQualifyColumnRefs(qualify, provider, outputColumns);
13136    }
13137
13138    /**
13139     * Slice 126: validate the function NAME of any INLINE window function
13140     * inside a {@code QUALIFY} predicate against the slice-13
13141     * {@link #WINDOW_FUNCTION_NAMES} allowlist. Slice 125 admitted any
13142     * function in QUALIFY (collecting only its influencing refs); this guard
13143     * applies the same name check {@link #buildWindowOutputColumn} uses for
13144     * projection windows so an unfamiliar windowed function whose semantics
13145     * the engine does not model — {@code QUALIFY FOO(a) OVER (ORDER BY b) = 1}
13146     * — rejects with {@link DiagnosticCode#WINDOW_FUNCTION_UNSUPPORTED}
13147     * instead of silently admitting.
13148     *
13149     * <p>An INLINE window function is a {@link TFunctionCall} carrying
13150     * {@code OVER (...)} ({@code getWindowDef() != null}). This is the SAME
13151     * signal {@code buildWindowOutputColumn} keys on. Non-window functions
13152     * are deliberately left alone:
13153     * <ul>
13154     *   <li>a plain UDF / scalar call with no {@code OVER}
13155     *       ({@code QUALIFY my_udf(a) = 1}), and</li>
13156     *   <li>a non-windowed aggregate ({@code QUALIFY SUM(a) = 1})</li>
13157     * </ul>
13158     * both have {@code getWindowDef() == null} and stay admitted — slice 126
13159     * validates window-function NAMES, it does not require QUALIFY to contain
13160     * a window function (that would be a broader semantic rule outside this
13161     * slice). The projection-alias form ({@code QUALIFY rn = 1}) carries no
13162     * {@link TFunctionCall} in the QUALIFY expression (the window function
13163     * lives in the projection, already slice-13 validated), so the guard is a
13164     * no-op there.
13165     *
13166     * <p>Nested SELECTs are skipped (a window function in an inner scope is
13167     * not this statement's QUALIFY window). In practice none survive —
13168     * {@link #rejectQualifySubquery} runs first and rejects any subquery — but
13169     * the {@code nestedSelectDepth} guard mirrors
13170     * {@link #collectQualifyColumnRefs} for defense-in-depth and parity.
13171     *
13172     * <p>Reuses the existing {@link DiagnosticCode#WINDOW_FUNCTION_UNSUPPORTED}
13173     * code (already reached by {@link #buildWindowOutputColumn}) so the
13174     * DiagnosticCode count stays unchanged.
13175     */
13176    private static void rejectUnsupportedQualifyWindowFunction(TExpression qualify) {
13177        qualify.acceptChildren(new TParseTreeVisitor() {
13178            int nestedSelectDepth = 0;
13179
13180            @Override
13181            public void preVisit(TSelectSqlStatement nested) {
13182                nestedSelectDepth++;
13183            }
13184
13185            @Override
13186            public void postVisit(TSelectSqlStatement nested) {
13187                nestedSelectDepth--;
13188            }
13189
13190            @Override
13191            public void preVisit(TFunctionCall fn) {
13192                if (nestedSelectDepth > 0) return;
13193                if (fn.getWindowDef() == null) return;
13194                String fnName = fn.getFunctionName() == null
13195                        ? null : fn.getFunctionName().toString();
13196                if (fnName == null
13197                        || !WINDOW_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) {
13198                    throw new SemanticIRBuildException(
13199                            Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_UNSUPPORTED,
13200                            "QUALIFY uses unsupported window function '" + fnName
13201                                    + "'; supported names are " + WINDOW_FUNCTION_NAMES, fn));
13202                }
13203            }
13204        });
13205    }
13206
13207    /**
13208     * Reject {@code QUALIFY} expressions that contain a subquery anywhere
13209     * in the subtree — scalar ({@link EExpressionType#subquery_t}) or
13210     * predicate ({@code EXISTS}, {@code IN (SELECT ...)},
13211     * {@code ANY/ALL/SOME}, carried via {@link TExpression#getSubQuery()}).
13212     * Mirrors {@link #rejectHavingScalarSubquery}: top-level fast path plus
13213     * a visitor deep-scan. Reuses the (slice-125-repurposed)
13214     * {@link DiagnosticCode#QUALIFY_NOT_SUPPORTED} code so the
13215     * DiagnosticCode count stays unchanged and the code stays reached.
13216     */
13217    private static void rejectQualifySubquery(TExpression qualify) {
13218        boolean topLevelSubquery = qualify.getExpressionType() == EExpressionType.subquery_t
13219                || qualify.getSubQuery() != null;
13220        final boolean[] found = {topLevelSubquery};
13221        if (!found[0]) {
13222            qualify.acceptChildren(new TParseTreeVisitor() {
13223                @Override
13224                public void preVisit(TExpression e) {
13225                    if (found[0]) return;
13226                    if (e.getExpressionType() == EExpressionType.subquery_t
13227                            || e.getSubQuery() != null) {
13228                        found[0] = true;
13229                    }
13230                }
13231            });
13232        }
13233        if (found[0]) {
13234            throw new SemanticIRBuildException(
13235                    Diagnostic.error(DiagnosticCode.QUALIFY_NOT_SUPPORTED,
13236                    "QUALIFY subquery '" + qualify + "' is not supported yet "
13237                            + "(subqueries in QUALIFY would leak inner column refs)", qualify));
13238        }
13239    }
13240
13241    /**
13242     * Slice 125: QUALIFY-specific column-ref collector. Mirrors
13243     * {@link #collectColumnRefs} / {@link #appendMergedOrBoundColumnRef}
13244     * (nested-SELECT skip, UsingScope merged-key expansion, EXACT_MATCH
13245     * binding) but adds a projection-alias fallback: an unqualified
13246     * {@code column}-typed {@link TObjectName} that does not bind to a base
13247     * column but matches an {@link OutputColumn} name resolves to that
13248     * output's influencing base columns ({@code getSources()} unioned with
13249     * its window spec's partition/order refs). This captures the canonical
13250     * {@code QUALIFY rn = 1} form, where {@code rn} aliases a window
13251     * projection whose own {@code getSources()} is empty.
13252     */
13253    private static List<ColumnRef> collectQualifyColumnRefs(TExpression qualify,
13254                                                            final NameBindingProvider provider,
13255                                                            final List<OutputColumn> outputColumns) {
13256        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
13257        final List<String> rejects = new ArrayList<>();
13258        // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at
13259        // the wrong side). Such a miss is a genuine error and must stay fatal;
13260        // only all-unqualified rejects are eligible for the join-graph degrade.
13261        final boolean[] sawQualifiedReject = {false};
13262        qualify.acceptChildren(new TParseTreeVisitor() {
13263            int nestedSelectDepth = 0;
13264
13265            @Override
13266            public void preVisit(TSelectSqlStatement nested) {
13267                nestedSelectDepth++;
13268            }
13269
13270            @Override
13271            public void postVisit(TSelectSqlStatement nested) {
13272                nestedSelectDepth--;
13273            }
13274
13275            @Override
13276            public void preVisit(TObjectName node) {
13277                if (nestedSelectDepth > 0) return;
13278                if (node.getDbObjectType() != EDbObjectType.column) return;
13279                String name = node.getColumnNameOnly();
13280                if (name == null || "*".equals(name)) return;
13281                UsingScope scope = provider.getUsingScope();
13282                String qualifier = node.getTableString();
13283                boolean unqualified = qualifier == null || qualifier.isEmpty();
13284                if (unqualified && scope.has(name)) {
13285                    if (scope.isAmbiguous(name)) {
13286                        throw new SemanticIRBuildException(
13287                                Diagnostic.error(DiagnosticCode.UNQUALIFIED_COLUMN_AMBIGUOUS,
13288                                "unqualified reference to '" + name + "' is ambiguous: "
13289                                        + scope.ambiguityReason(name)
13290                                        + "; qualify with a table alias", null));
13291                    }
13292                    refs.addAll(scope.mergedSourcesFor(name));
13293                    return;
13294                }
13295                ColumnBinding binding = provider.bindColumn(node);
13296                if (binding != null && binding.getStatus() == ResolutionStatus.EXACT_MATCH) {
13297                    refs.add(new ColumnRef(binding.getRelationAlias(), binding.getColumnName()));
13298                    return;
13299                }
13300                // Base-column binding failed. Projection-alias fallback:
13301                // an unqualified name that matches an output column's name
13302                // (case-insensitive) resolves to that output's influencing
13303                // base columns. QUALIFY (post-window) can reference SELECT
13304                // aliases in Snowflake / BigQuery.
13305                if (unqualified) {
13306                    List<ColumnRef> aliasSources = resolveQualifyProjectionAlias(name, outputColumns);
13307                    if (aliasSources != null) {
13308                        refs.addAll(aliasSources);
13309                        return;
13310                    }
13311                    // Catalog-less NATURAL JOIN degrade (GSP R6): unqualified
13312                    // merged NATURAL key resolves to the left-side relation.
13313                    if (scope.hasNaturalDegrade()) {
13314                        refs.add(new ColumnRef(scope.naturalDegradeFallbackAlias(), name));
13315                        return;
13316                    }
13317                }
13318                rejects.add(node + (binding == null ? "[no binding]"
13319                        : "[" + binding.getStatus() + "]"));
13320                if (!unqualified) {
13321                    sawQualifiedReject[0] = true;
13322                }
13323            }
13324        });
13325        if (!rejects.isEmpty()) {
13326            rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]);
13327        }
13328        return new ArrayList<>(refs);
13329    }
13330
13331    /**
13332     * Slice 125: resolve an unqualified QUALIFY reference name to the
13333     * influencing base columns of the matching projection output column.
13334     * The influencing columns are the output's
13335     * {@link OutputColumn#getSources()} unioned with its window spec's
13336     * {@link WindowSpec#getPartitionRefs()} and
13337     * {@link WindowSpec#getOrderRefs()} — a window output's own
13338     * {@code getSources()} is empty, so the partition/order refs are where
13339     * the row-influencing base columns live.
13340     *
13341     * <p>Return contract (the caller distinguishes the two non-throwing
13342     * cases by {@code null} vs empty):
13343     * <ul>
13344     *   <li>{@code null} — no output column matches by name; the caller
13345     *       records a {@code COLUMN_BINDING_NON_EXACT} reject.</li>
13346     *   <li>non-empty list — the matched output's influencing base
13347     *       columns.</li>
13348     *   <li><b>empty list</b> — an output matched but it has no influencing
13349     *       base columns (a constant projection such as {@code SELECT 1 AS x}
13350     *       referenced by {@code QUALIFY x = 1}, or any sourceless /
13351     *       window-less projection). This is an admitted, deliberate
13352     *       boundary: a QUALIFY predicate over a column-free output has no
13353     *       column dependency, exactly like a constant {@code WHERE 1 = 1}
13354     *       contributes no filter refs. It is NOT treated as a reject — the
13355     *       ref resolves successfully and simply contributes no base-column
13356     *       dependency.</li>
13357     * </ul>
13358     *
13359     * <p>Match strategy: case-insensitive ({@link Locale#ROOT}), FIRST
13360     * (leftmost) match — identical to {@link #tryResolveOrderByProjectionAlias}.
13361     * Duplicate projection aliases ({@code SELECT a AS rn, b AS rn ...
13362     * QUALIFY rn = 1}) therefore resolve to the leftmost projection rather
13363     * than rejecting as ambiguous; this is the documented slice-69 ORDER BY
13364     * boundary applied to QUALIFY for cross-clause consistency.
13365     *
13366     * <p>Resolution order in the caller is base-column-first: a name that
13367     * binds {@code EXACT_MATCH} to a base column is emitted directly and
13368     * never reaches this fallback, which fires only for an UNQUALIFIED ref
13369     * whose base binding failed. A qualified ref ({@code QUALIFY e.rn}) is
13370     * never alias-resolved here (qualifying a projection alias is invalid
13371     * SQL); it binds through the provider like any other qualified ref.
13372     */
13373    private static List<ColumnRef> resolveQualifyProjectionAlias(String name,
13374                                                                 List<OutputColumn> outputColumns) {
13375        String key = name.toLowerCase(Locale.ROOT);
13376        for (OutputColumn oc : outputColumns) {
13377            String outName = oc.getName();
13378            if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) {
13379                LinkedHashSet<ColumnRef> influencing = new LinkedHashSet<>(oc.getSources());
13380                WindowSpec ws = oc.getWindowSpec();
13381                if (ws != null) {
13382                    influencing.addAll(ws.getPartitionRefs());
13383                    influencing.addAll(ws.getOrderRefs());
13384                }
13385                return new ArrayList<>(influencing);
13386            }
13387        }
13388        return null;
13389    }
13390
13391    /**
13392     * Collect physical column references from {@code ORDER BY} sort keys.
13393     *
13394     * <p>Per-item validation rejects shapes that would otherwise vanish
13395     * silently into an empty ref list, leak inner-scope refs, or
13396     * misrepresent presentation as a dependency:
13397     *
13398     * <ul>
13399     *   <li>Ordinal references ({@code ORDER BY 1}) — the sort key is a
13400     *       {@link EExpressionType#simple_constant_t}; its meaning is
13401     *       "first projected column" which depends on the SELECT list,
13402     *       not on a base column. A future slice can model output-position
13403     *       references explicitly.</li>
13404     *   <li>Constant sort keys other than ordinals ({@code ORDER BY 'x'},
13405     *       and the compound {@code ORDER BY (1)} / {@code ORDER BY 1+0}
13406     *       caught by the generic no-physical-column-refs check).</li>
13407     *   <li>Projection-alias references ({@code ORDER BY x} where
13408     *       {@code x} is a SELECT alias) — {@link TOrderByItem#doParse}
13409     *       retypes the operand to {@link TObjectName#ttobjColumnAlias},
13410     *       which lowers {@link TObjectName#getDbObjectType()} to
13411     *       {@link EDbObjectType#column_alias}. Without explicit
13412     *       rejection the visitor would skip it and the IR would lose
13413     *       the dependency entirely. The deep-scan version of this
13414     *       check catches alias nodes nested inside expressions.</li>
13415     *   <li>Subqueries in sort keys — scalar
13416     *       ({@link EExpressionType#subquery_t}) and predicate
13417     *       ({@code EXISTS}, {@code IN (SELECT ...)}, {@code ANY/ALL/SOME})
13418     *       — would otherwise leak inner-scope column refs into the
13419     *       outer statement's {@code orderByColumnRefs}.</li>
13420     *   <li>Window functions in sort keys ({@code ORDER BY ROW_NUMBER()
13421     *       OVER (...)}) — the OVER clause descends through the visitor
13422     *       and would leak its PARTITION BY / ORDER BY refs.</li>
13423     * </ul>
13424     *
13425     * <p>Sub-clauses that change row-set semantics are also rejected
13426     * here: Oracle {@code ORDER SIBLINGS BY} (hierarchical, not yet
13427     * modelled), Teradata {@code RESET WHEN} (window-style restart),
13428     * and the {@link TOrderBy}-level {@code FETCH FIRST}/{@code OFFSET}
13429     * defensive guards (in fresh parses the SELECT-level row-limit
13430     * guards in {@link #rejectUnsupportedShape} fire first because
13431     * {@code TSelectSqlNode.setOrderbyClause()} copies in-clause OFFSET/
13432     * FETCH onto the SELECT node).
13433     *
13434     * <p>For everything else (qualified column refs, expressions like
13435     * {@code UPPER(name)}), {@link #collectColumnRefs} runs over each
13436     * sort key and aggregates the physical column refs. A per-item
13437     * empty-refs check catches anything that slipped past the explicit
13438     * shape rejections (e.g. {@code ORDER BY (1)},
13439     * {@code ORDER BY 1 + 0}). Sort direction ({@code ASC}/{@code DESC})
13440     * and null placement ({@code NULLS FIRST}/{@code NULLS LAST}) are
13441     * presentation metadata and are not modelled.
13442     */
13443    private static List<ColumnRef> buildOrderByColumnRefs(TSelectSqlStatement select,
13444                                                          NameBindingProvider provider,
13445                                                          List<OutputColumn> outputColumns) {
13446        TOrderBy orderBy = select.getOrderbyClause();
13447        if (orderBy == null) {
13448            return new ArrayList<>();
13449        }
13450        if (orderBy.isSiblings()) {
13451            throw new SemanticIRBuildException(
13452                    Diagnostic.error(DiagnosticCode.ORDER_SIBLINGS_BY_NOT_SUPPORTED,
13453                    "ORDER SIBLINGS BY is not supported yet "
13454                            + "(Oracle hierarchical ordering)", orderBy));
13455        }
13456        if (orderBy.getResetWhenCondition() != null) {
13457            throw new SemanticIRBuildException(
13458                    Diagnostic.error(DiagnosticCode.ORDER_BY_RESET_WHEN_NOT_SUPPORTED,
13459                    "ORDER BY ... RESET WHEN is not supported yet "
13460                            + "(Teradata window-style restart)", orderBy));
13461        }
13462        // Slice 71: the in-clause OFFSET/FETCH on TOrderBy is no longer
13463        // rejected. MSSQL parsers duplicate OFFSET/FETCH onto BOTH the
13464        // SELECT node AND the TOrderBy node; slice 71 admits at the
13465        // SELECT level via buildRowLimit, so the TOrderBy duplicates
13466        // are simply ignored here. Oracle parsers populate only the
13467        // SELECT-level fields, so the TOrderBy fields are typically
13468        // null there.
13469        TOrderByItemList items = orderBy.getItems();
13470        if (items == null || items.size() == 0) {
13471            return new ArrayList<>();
13472        }
13473        // Validate + collect per item so a sort key contributing zero
13474        // column refs (e.g. constant arithmetic, parenthesised constant)
13475        // is rejected with an item-specific message instead of silently
13476        // disappearing.
13477        LinkedHashSet<ColumnRef> all = new LinkedHashSet<>();
13478        for (int i = 0; i < items.size(); i++) {
13479            TOrderByItem item = items.getOrderByItem(i);
13480            if (item == null) continue;
13481            TExpression sortKey = item.getSortKey();
13482            if (sortKey == null) continue;
13483            // Slice 68: positive-integer ordinals admit. The helper:
13484            //   - returns null when sortKey is not a positive-integer
13485            //     literal (caller falls through to the existing
13486            //     constant / alias / subquery / window rejecters and the
13487            //     standard ref collection);
13488            //   - returns the matching output column's sources list when
13489            //     sortKey IS a positive-integer literal in range;
13490            //   - throws ORDER_BY_ORDINAL_OUT_OF_RANGE when the ordinal
13491            //     is 0 or exceeds the output column count.
13492            // The sourceless-output case (e.g. SELECT 1 FROM t ORDER BY 1
13493            // or SELECT COUNT(*) FROM t ORDER BY 1) returns an empty
13494            // list and falls through to the per-item empty-refs guard
13495            // below, mirroring the existing ORDER BY COUNT(*) /
13496            // ORDER BY 1 + 0 rejection.
13497            List<ColumnRef> ordinalSources = tryResolveOrderByOrdinal(sortKey, outputColumns);
13498            if (ordinalSources != null) {
13499                if (ordinalSources.isEmpty()) {
13500                    throw new SemanticIRBuildException(
13501                            Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS,
13502                            "ORDER BY ordinal '" + sortKey
13503                                    + "' resolves to output column with no physical column references "
13504                                    + "(constant or sourceless aggregate output)", sortKey));
13505                }
13506                all.addAll(ordinalSources);
13507                continue;
13508            }
13509            // Slice 69: top-level projection-alias references admit. The
13510            // helper returns null for non-alias shapes; an empty list
13511            // (alias of a constant / sourceless aggregate) falls through
13512            // to ORDER_BY_NO_PHYSICAL_COLUMN_REFS, mirroring the slice-68
13513            // sourceless-ordinal handling. Deep-scan alias references
13514            // (e.g. ORDER BY UPPER(<alias>)) are still caught by
13515            // rejectOrderByAliasReference below.
13516            List<ColumnRef> aliasSources = tryResolveOrderByProjectionAlias(sortKey, outputColumns);
13517            if (aliasSources != null) {
13518                if (aliasSources.isEmpty()) {
13519                    throw new SemanticIRBuildException(
13520                            Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS,
13521                            "ORDER BY projection alias '" + sortKey
13522                                    + "' resolves to output column with no physical column references "
13523                                    + "(constant or sourceless aggregate output)", sortKey));
13524                }
13525                all.addAll(aliasSources);
13526                continue;
13527            }
13528            // Slice 68: non-ordinal constants stay rejected. The original
13529            // helper is preserved for the set-op outer path (which keeps
13530            // its ordinal/constant rejection until slice 72).
13531            rejectOrderByNonOrdinalConstant(sortKey);
13532            // Slice 69: top-level bare alias references are consumed by
13533            // tryResolveOrderByProjectionAlias above; this helper now only
13534            // catches DEEP alias references nested inside compound
13535            // expressions (e.g. ORDER BY UPPER(<alias>)).
13536            rejectOrderByAliasReference(sortKey);
13537            // Reject scalar subqueries and window functions BEFORE
13538            // collecting refs. The visitor descends into both, so without
13539            // these guards `ORDER BY (SELECT MAX(salary) FROM employees)`
13540            // and `ORDER BY ROW_NUMBER() OVER (ORDER BY salary)` would
13541            // leak inner refs into orderByColumnRefs as if the outer
13542            // statement physically depended on them.
13543            rejectOrderByScalarSubquery(sortKey);
13544            rejectOrderByWindowFunction(sortKey);
13545            List<ColumnRef> itemRefs = collectColumnRefs(item, provider);
13546            if (itemRefs.isEmpty()) {
13547                // Anything else that produces no physical column refs:
13548                // ORDER BY (1), ORDER BY 1+0, ORDER BY NULL, ORDER BY
13549                // CASE WHEN 1=1 THEN 'a' END, etc. Reject so the IR
13550                // doesn't silently emit empty refs.
13551                throw new SemanticIRBuildException(
13552                        Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS,
13553                        "ORDER BY sort key '" + sortKey + "' has no physical column references "
13554                                + "(constant or non-column expressions are not supported yet)", sortKey));
13555            }
13556            all.addAll(itemRefs);
13557        }
13558        return new ArrayList<>(all);
13559    }
13560
13561    /**
13562     * Slice 68: resolve a positive-integer ORDER BY ordinal to the matching
13563     * output column's sources. Returns:
13564     *
13565     * <ul>
13566     *   <li>{@code null} if {@code sortKey} is not a positive-integer
13567     *       literal (caller continues with the constant / alias / subquery
13568     *       / window rejecters and the standard ref collection);</li>
13569     *   <li>a {@link List} of {@link ColumnRef}s — the source list of the
13570     *       output column at position {@code v - 1} (1-based ordinals);</li>
13571     *   <li>throws {@link SemanticIRBuildException} with
13572     *       {@code ORDER_BY_ORDINAL_OUT_OF_RANGE} when {@code v} is 0 or
13573     *       exceeds the output column count.</li>
13574     * </ul>
13575     *
13576     * <p>{@code sortKey.getExpressionType() == simple_constant_t} for bare
13577     * positive integers; negative integers parse as a {@code unary_minus_t}
13578     * over a {@code simple_constant_t} and are not handled here. Compound
13579     * constant expressions ({@code ORDER BY 1 + 0}, {@code ORDER BY (1)})
13580     * are {@code arithmetic_*_t} / {@code parenthesis_t} respectively and
13581     * fall through to the per-item empty-refs guard.
13582     *
13583     * <p>The empty-list case (output column resolved with
13584     * {@link OutputColumn#getSources()} empty — constant projections,
13585     * {@code COUNT(*)}, sourceless aggregates) is returned to the caller
13586     * which fires {@code ORDER_BY_NO_PHYSICAL_COLUMN_REFS}. Slice 68
13587     * boundary.
13588     *
13589     * <p>Sort direction (ASC/DESC) and null placement (NULLS FIRST/LAST)
13590     * are presentation metadata on {@link TOrderByItem}, not on the sort
13591     * key expression; this helper doesn't inspect them (slice 9 decision).
13592     */
13593    private static List<ColumnRef> tryResolveOrderByOrdinal(TExpression sortKey,
13594                                                             List<OutputColumn> outputs) {
13595        if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) {
13596            return null;
13597        }
13598        String txt = sortKey.toString();
13599        if (txt == null || !txt.matches("\\d+")) {
13600            return null;
13601        }
13602        long v;
13603        try {
13604            v = Long.parseLong(txt);
13605        } catch (NumberFormatException e) {
13606            // Very-long-digit text overflows long; definitely out of range.
13607            throw new SemanticIRBuildException(
13608                    Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_OUT_OF_RANGE,
13609                    "ORDER BY ordinal '" + sortKey + "' is out of range "
13610                            + "(must be between 1 and " + outputs.size() + ")", sortKey));
13611        }
13612        if (v < 1 || v > outputs.size()) {
13613            throw new SemanticIRBuildException(
13614                    Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_OUT_OF_RANGE,
13615                    "ORDER BY ordinal '" + sortKey + "' is out of range "
13616                            + "(must be between 1 and " + outputs.size() + ")", sortKey));
13617        }
13618        return outputs.get((int) v - 1).getSources();
13619    }
13620
13621    /**
13622     * Slice 69: resolve a top-level bare projection-alias ORDER BY sort
13623     * key to the matching output column's sources. Returns:
13624     *
13625     * <ul>
13626     *   <li>{@code null} if {@code sortKey} is not a top-level bare
13627     *       {@code simple_object_name_t} whose object operand has
13628     *       {@code dbObjectType == EDbObjectType.column_alias} (caller
13629     *       continues with {@link #rejectOrderByAliasReference} for the
13630     *       deep-scan case and the standard column-ref collection);</li>
13631     *   <li>a {@link List} of {@link ColumnRef}s — the matching output's
13632     *       source list (which may be empty when the aliased projection
13633     *       is a constant or sourceless aggregate; the caller fires
13634     *       {@code ORDER_BY_NO_PHYSICAL_COLUMN_REFS} in that case);</li>
13635     *   <li>throws {@link SemanticIRBuildException} with
13636     *       {@code ORDER_BY_PROJECTION_ALIAS_NOT_SUPPORTED} when the
13637     *       parser retyped the operand to {@code column_alias} but no
13638     *       matching output exists by case-insensitive name (defensive;
13639     *       theoretically unreachable for parsable SQL).</li>
13640     * </ul>
13641     *
13642     * <p>Match strategy: case-insensitive ({@link Locale#ROOT}) on
13643     * {@link OutputColumn#getName()}, returning the FIRST match. This
13644     * mirrors the set-op outer alias matcher in
13645     * {@link #processSetOpOrderByObjectName} (which uses the identical
13646     * {@code toLowerCase(Locale.ROOT)} pattern and {@code break}s on
13647     * first match) and follows MySQL / PostgreSQL ORDER BY alias
13648     * resolution semantics. Duplicate aliases (e.g. {@code SELECT a AS x,
13649     * b AS x FROM t ORDER BY x}) resolve to the leftmost matching
13650     * projection; this is the documented slice-69 boundary.
13651     *
13652     * <p>The set-op outer alias path
13653     * ({@link #buildSetOpOuterOrderByColumnRefs} →
13654     * {@link #processSetOpOrderByObjectName}) was already admitted by
13655     * slice 21 and is independent of this helper.
13656     *
13657     * <p>Deep-scan alias references inside compound expressions
13658     * ({@code ORDER BY UPPER(<alias>)}) are NOT handled here — the
13659     * parser only retypes the top-level operand to {@code column_alias};
13660     * inside nested expressions the alias may or may not be retyped by
13661     * resolver2 depending on schema heuristics. The slice-9
13662     * {@code orderByNestedAliasReferenceIsHandledSafely} contract is
13663     * preserved: deep alias refs are caught by
13664     * {@link #rejectOrderByAliasReference} with
13665     * {@code ORDER_BY_UNSUPPORTED_SORT_KEY_SHAPE} or by a binding
13666     * failure.
13667     */
13668    private static List<ColumnRef> tryResolveOrderByProjectionAlias(
13669            TExpression sortKey, List<OutputColumn> outputs) {
13670        if (sortKey.getExpressionType() != EExpressionType.simple_object_name_t) {
13671            return null;
13672        }
13673        TObjectName op = sortKey.getObjectOperand();
13674        if (op == null || op.getDbObjectType() != EDbObjectType.column_alias) {
13675            return null;
13676        }
13677        String name = op.toString();
13678        if (name == null || name.isEmpty()) {
13679            return null;
13680        }
13681        String key = name.toLowerCase(Locale.ROOT);
13682        for (OutputColumn oc : outputs) {
13683            String outName = oc.getName();
13684            if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) {
13685                return oc.getSources();
13686            }
13687        }
13688        throw new SemanticIRBuildException(
13689                Diagnostic.error(DiagnosticCode.ORDER_BY_PROJECTION_ALIAS_NOT_SUPPORTED,
13690                "ORDER BY projection alias '" + sortKey
13691                        + "' does not match any output column "
13692                        + "(defensive — parser retyped to column_alias "
13693                        + "but no output by that name)", sortKey));
13694    }
13695
13696    /**
13697     * Slice 68: reject ORDER BY sort keys that are constants but NOT
13698     * positive-integer ordinals. The positive-integer ordinal case is
13699     * admitted separately by {@link #tryResolveOrderByOrdinal} which maps
13700     * the ordinal to the matching output column's sources. This helper
13701     * handles the remaining constant shapes ({@code ORDER BY 'x'},
13702     * {@code ORDER BY 3.14}) — none of which reference an output position
13703     * and so contribute no column dependency.
13704     *
13705     * <p>The set-op outer ORDER BY path
13706     * ({@link #buildSetOpOuterOrderByColumnRefs}) keeps the original
13707     * {@link #rejectOrderByOrdinalOrConstant} helper so ordinals at that
13708     * scope stay rejected (slice 68 lifts only the single-SELECT case;
13709     * slice 72 will lift set-op outer).
13710     */
13711    private static void rejectOrderByNonOrdinalConstant(TExpression sortKey) {
13712        if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) {
13713            return;
13714        }
13715        String txt = sortKey.toString();
13716        boolean looksOrdinal = txt != null && txt.matches("\\d+");
13717        if (looksOrdinal) {
13718            // Admitted by tryResolveOrderByOrdinal; this helper is a no-op
13719            // for positive-integer ordinals.
13720            return;
13721        }
13722        throw new SemanticIRBuildException(
13723                Diagnostic.error(DiagnosticCode.ORDER_BY_CONSTANT_NOT_SUPPORTED,
13724                "ORDER BY constant '" + sortKey + "' is not supported yet "
13725                        + "(constant sort keys add no column dependency)", sortKey));
13726    }
13727
13728    /**
13729     * Reject ORDER BY sort keys that contain a subquery anywhere in the
13730     * subtree. Catches both:
13731     *
13732     * <ul>
13733     *   <li>Scalar subqueries ({@link EExpressionType#subquery_t}) —
13734     *       e.g. {@code ORDER BY (SELECT MAX(salary) FROM employees)}.</li>
13735     *   <li>Predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)},
13736     *       {@code ANY/ALL/SOME (SELECT ...)}) — these don't appear as a
13737     *       {@code subquery_t} expression but carry a non-null
13738     *       {@link TExpression#getSubQuery()}, e.g.
13739     *       {@code ORDER BY CASE WHEN EXISTS (SELECT 1 FROM t WHERE ...)
13740     *       THEN 0 ELSE 1 END}.</li>
13741     * </ul>
13742     *
13743     * <p>The visitor descends into the subquery body, so without an
13744     * explicit reject the inner-scope refs would leak into the outer
13745     * statement's {@code orderByColumnRefs}. The same restriction is
13746     * applied to scalar subqueries in projection (see
13747     * {@link #buildOutputColumns}).
13748     */
13749    private static void rejectOrderByScalarSubquery(TExpression sortKey) {
13750        // Top-level fast path: scalar-subquery message for the common case.
13751        if (sortKey.getExpressionType() == EExpressionType.subquery_t
13752                || sortKey.getSubQuery() != null) {
13753            throw new SemanticIRBuildException(
13754                    Diagnostic.error(DiagnosticCode.ORDER_BY_SUBQUERY_NOT_SUPPORTED,
13755                    "ORDER BY subquery '" + sortKey + "' is not supported yet "
13756                            + "(subqueries in sort keys would leak inner column refs)", sortKey));
13757        }
13758        // Deep scan: any nested expression that owns a subquery (scalar,
13759        // EXISTS, IN (SELECT ...), ANY/ALL/SOME) makes the sort key
13760        // out of scope.
13761        final boolean[] found = {false};
13762        sortKey.acceptChildren(new TParseTreeVisitor() {
13763            @Override
13764            public void preVisit(TExpression e) {
13765                if (found[0]) return;
13766                if (e.getExpressionType() == EExpressionType.subquery_t
13767                        || e.getSubQuery() != null) {
13768                    found[0] = true;
13769                }
13770            }
13771        });
13772        if (found[0]) {
13773            throw new SemanticIRBuildException(
13774                    Diagnostic.error(DiagnosticCode.ORDER_BY_HAS_SUBQUERY_NOT_SUPPORTED,
13775                    "ORDER BY sort key '" + sortKey + "' contains a subquery "
13776                            + "(scalar, EXISTS, IN, or ANY/ALL/SOME); not supported yet", sortKey));
13777        }
13778    }
13779
13780    /**
13781     * Reject ORDER BY sort keys that contain a window function. Window
13782     * functions descend through {@link TFunctionCall#acceptChildren()} so
13783     * their PARTITION BY / ORDER BY column refs would otherwise leak into
13784     * the outer statement's {@code orderByColumnRefs}. Mirrors the
13785     * projection-side {@link #rejectWindowFunctions}, but wired through
13786     * the ORDER BY item-walk instead of the result-column list.
13787     */
13788    private static void rejectOrderByWindowFunction(TExpression sortKey) {
13789        final boolean[] found = {false};
13790        sortKey.acceptChildren(new TParseTreeVisitor() {
13791            @Override
13792            public void preVisit(TFunctionCall fn) {
13793                if (found[0]) return;
13794                if (fn.getWindowDef() != null) found[0] = true;
13795            }
13796        });
13797        if (!found[0] && sortKey.getExpressionType() == EExpressionType.function_t) {
13798            TFunctionCall fn = sortKey.getFunctionCall();
13799            if (fn != null && fn.getWindowDef() != null) found[0] = true;
13800        }
13801        if (found[0]) {
13802            throw new SemanticIRBuildException(
13803                    Diagnostic.error(DiagnosticCode.ORDER_BY_WINDOW_FUNCTION_NOT_SUPPORTED,
13804                    "ORDER BY window function '" + sortKey + "' is not supported yet "
13805                            + "(window OVER (...) refs would leak into orderByColumnRefs)", sortKey));
13806        }
13807    }
13808
13809    /**
13810     * Reject ORDER BY sort keys that are bare constants. Splits the
13811     * message between integer ordinals (which reference the SELECT
13812     * position) and other constants (which add no column dependency). The
13813     * generic no-physical-column-refs check in
13814     * {@link #buildOrderByColumnRefs} catches compound cases like
13815     * {@code ORDER BY (1)} or {@code ORDER BY 1 + 0}.
13816     *
13817     * <p><b>Slice 68:</b> the single-SELECT call site no longer uses this
13818     * helper because positive-integer ordinals now resolve to the matching
13819     * output column's sources (see {@link #tryResolveOrderByOrdinal}).
13820     * This helper remains for {@link #buildSetOpOuterOrderByColumnRefs},
13821     * where ordinal lifting is deferred to slice 72 (set-op outer
13822     * ORDER BY needs output-position references against the set-op output
13823     * row type, not the single-SELECT output column list).
13824     */
13825    private static void rejectOrderByOrdinalOrConstant(TExpression sortKey) {
13826        if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) {
13827            return;
13828        }
13829        String txt = sortKey.toString();
13830        boolean looksOrdinal = txt != null && txt.matches("\\d+");
13831        if (looksOrdinal) {
13832            throw new SemanticIRBuildException(
13833                    Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_NOT_SUPPORTED,
13834                    "ORDER BY ordinal '" + sortKey + "' is not supported yet "
13835                            + "(reference the column or expression directly)", sortKey));
13836        }
13837        throw new SemanticIRBuildException(
13838                Diagnostic.error(DiagnosticCode.ORDER_BY_CONSTANT_NOT_SUPPORTED,
13839                "ORDER BY constant '" + sortKey + "' is not supported yet "
13840                        + "(constant sort keys add no column dependency)", sortKey));
13841    }
13842
13843    /**
13844     * Reject ORDER BY sort keys that contain a projection-alias reference
13845     * NESTED inside a compound expression (e.g.
13846     * {@code ORDER BY UPPER(<alias>)}). The visitor in
13847     * {@link #collectColumnRefs} skips column-alias nodes, so without an
13848     * explicit reject the IR would emit no column refs for them.
13849     *
13850     * <p><b>Slice 69:</b> the top-level bare-alias case (e.g.
13851     * {@code ORDER BY <alias>}) is now consumed by
13852     * {@link #tryResolveOrderByProjectionAlias} BEFORE this helper runs.
13853     * Only the deep-scan branch remains here; the top-level fast-path
13854     * was removed because it became unreachable.
13855     *
13856     * <p>{@link TOrderByItem#doParse} only retypes the top-level operand
13857     * to {@link TObjectName#ttobjColumnAlias} → dbObjectType
13858     * {@link EDbObjectType#column_alias}; inside nested expressions the
13859     * alias may or may not be retyped by resolver2 depending on schema
13860     * heuristics. Slice 9's
13861     * {@code orderByNestedAliasReferenceIsHandledSafely} documents the
13862     * three acceptable outcomes for deep aliases (reject by binding
13863     * failure, reject by this deep scan, or accept with a real column
13864     * dependency captured).
13865     */
13866    private static void rejectOrderByAliasReference(TExpression sortKey) {
13867        // Deep scan: an alias node nested inside an expression
13868        // (e.g. ORDER BY UPPER(x) where x is an alias) would otherwise be
13869        // silently dropped by the column-only visitor. The top-level
13870        // bare-alias case is consumed earlier by
13871        // tryResolveOrderByProjectionAlias (slice 69 lift).
13872        final boolean[] foundAlias = {false};
13873        final String[] aliasName = {null};
13874        sortKey.acceptChildren(new TParseTreeVisitor() {
13875            @Override
13876            public void preVisit(TObjectName node) {
13877                if (foundAlias[0]) return;
13878                if (node.getDbObjectType() == EDbObjectType.column_alias) {
13879                    foundAlias[0] = true;
13880                    aliasName[0] = node.toString();
13881                }
13882            }
13883        });
13884        if (foundAlias[0]) {
13885            throw new SemanticIRBuildException(
13886                    Diagnostic.error(DiagnosticCode.ORDER_BY_UNSUPPORTED_SORT_KEY_SHAPE,
13887                    "ORDER BY sort key '" + sortKey
13888                            + "' contains a projection alias reference '"
13889                            + aliasName[0] + "'; not supported yet "
13890                            + "(reference the underlying column directly)", sortKey));
13891        }
13892    }
13893
13894    /**
13895     * Reject SELECT shapes outside current builder scope. The
13896     * {@code skipCteListCheck} flag is true only for the outer SELECT of a
13897     * WITH-bearing query whose CTEs were already extracted by
13898     * {@link #build}; nested WITH inside a CTE body is still rejected.
13899     */
13900    private static void rejectUnsupportedShape(TSelectSqlStatement select, boolean skipCteListCheck) {
13901        // Slice 12: top-level set-ops and CTE-body set-ops are dispatched
13902        // by build() to buildSetOpProgram BEFORE buildSelectStatement is
13903        // called. This rejection still fires when buildSelectStatement
13904        // is called from a recursive context (FROM-subquery / scalar-body
13905        // extraction) where the inner SELECT happens to be a set-op —
13906        // those nested cases remain out of scope.
13907        if (select.getSetOperatorType() != null && select.getSetOperatorType() != ESetOperatorType.none) {
13908            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));
13909        }
13910        if (!skipCteListCheck && select.getCteList() != null && select.getCteList().size() > 0) {
13911            throw new SemanticIRBuildException(
13912                    Diagnostic.error(DiagnosticCode.NESTED_WITH_NOT_SUPPORTED,
13913                    "nested WITH/CTE inside a CTE body or subquery is not supported yet", select));
13914        }
13915        // DISTINCT / UNIQUE / ALL handling is done in resolveDistinctFlag()
13916        // (called from buildSelectStatement). Only rejected row-filter shapes
13917        // bubble up as a SemanticIRBuildException; the rest become the
13918        // StatementGraph.distinct flag.
13919        // Slice 6 lifted GROUP BY; slice 10 lifted HAVING. The HAVING
13920        // expression itself (and the per-shape rejections for subqueries
13921        // and window functions inside it) are handled in
13922        // buildHavingColumnRefs so the rejection messages can mention the
13923        // specific shape.
13924        // Slices 70 and 71: all single-SELECT row-limit admit/reject
13925        // decisions live in buildRowLimit(select). rejectUnsupportedShape
13926        // no longer carries any row-limit logic. Set-op outer row-limits
13927        // remain handled by rejectSetOpRowLimit (slice 72 lifts).
13928        // Slice 125 lifted the slice-13 blanket QUALIFY reject. The
13929        // QUALIFY clause is now admitted and its filter refs collected in
13930        // buildQualifyColumnRefs (called from buildSelectStatementImpl,
13931        // which has the outputColumns needed for projection-alias
13932        // resolution). Subqueries in QUALIFY still reject there, reusing
13933        // the (repurposed) QUALIFY_NOT_SUPPORTED code.
13934        // ORDER BY itself is lifted in slice 9; see buildOrderByColumnRefs.
13935        // Vendor- and clause-level guards are checked there so the rejection
13936        // message can mention the specific sub-clause.
13937    }
13938
13939    /**
13940     * Walk the FROM clause: each top-level {@link TJoin} contributes its
13941     * base table; each chained {@link TJoinItem} contributes one more base
13942     * table plus the column refs found in its ON-condition expression.
13943     * Comma-separated FROM lists (multiple top-level TJoins) and
13944     * single-source SELECTs both reduce to the same loop.
13945     */
13946    private static List<RelationSource> buildRelations(TSelectSqlStatement select,
13947                                                       NameBindingProvider provider,
13948                                                       List<ColumnRef> joinRefsOut,
13949                                                       boolean allowFromSubqueries) {
13950        return buildRelations(select, provider, joinRefsOut, allowFromSubqueries,
13951                /*allowJoinOnPredicateSubqueries=*/ false,
13952                /*stmtsForExtraction=*/ null,
13953                /*lineageForExtraction=*/ null,
13954                /*cteMapForExtraction=*/ null);
13955    }
13956
13957    /**
13958     * Slice-23/24 overload of {@link #buildRelations}. When
13959     * {@code allowJoinOnPredicateSubqueries} is {@code true} (outer-SELECT
13960     * call site only), uncorrelated EXISTS subqueries inside JOIN ON
13961     * predicates are extracted as their own {@code <predicate_subquery_<i>>}
13962     * StatementGraphs appended to {@code stmtsForExtraction}. The extracted
13963     * subtrees are then skipped by the JOIN-ON window-function rejecter and
13964     * the JOIN-ON ref collector so their inner refs do not leak into outer
13965     * {@code joinColumnRefs}.
13966     *
13967     * <p>Slice 24: {@code cteMapForExtraction} carries outer's
13968     * CTE-name-to-statement-index map so the extracted predicate body can
13969     * emit STATEMENT_OUTPUT → STATEMENT_OUTPUT edges into outer-visible CTE
13970     * bodies via {@link #emitLineageForStatement}. Non-outer call sites
13971     * (where {@code allowJoinOnPredicateSubqueries=false}) pass {@code null}.
13972     */
13973    private static List<RelationSource> buildRelations(TSelectSqlStatement select,
13974                                                       NameBindingProvider provider,
13975                                                       List<ColumnRef> joinRefsOut,
13976                                                       boolean allowFromSubqueries,
13977                                                       boolean allowJoinOnPredicateSubqueries,
13978                                                       List<StatementGraph> stmtsForExtraction,
13979                                                       List<LineageEdge> lineageForExtraction,
13980                                                       Map<String, Integer> cteMapForExtraction) {
13981        if (select.joins == null || select.joins.size() == 0) {
13982            throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SELECT_NO_FROM_SOURCE, "SELECT must have at least one FROM source", select));
13983        }
13984        // Slice 62: comma-separated FROM lists (e.g. `FROM a, b`)
13985        // parse as multiple top-level TJoin elements and build as an
13986        // ordered cross-product relation graph with empty
13987        // {@code joinColumnRefs} (WHERE-side predicates feed
13988        // {@code filterColumnRefs} as usual).
13989        //
13990        // Slice 182 (GitHub #707 / MantisBT 4676) lifts the former
13991        // body-context reject. Slice 62 admitted comma-FROM only where
13992        // {@code allowFromSubqueries=true} (outer / CTE-body /
13993        // FROM-subquery-body) and rejected it in the synthetic body
13994        // contexts (scalar / set-op-branch / set-op-CTE / predicate) on
13995        // the theory that their shape contract "cannot host a
13996        // cross-product relation graph safely". That theory does not
13997        // hold: every one of those contracts constrains the body's
13998        // SELECT LIST (single column for scalar, column-count parity for
13999        // set-op branches, constant/single-column-ref for predicate
14000        // bodies), never its FROM relation count — which is why the same
14001        // bodies already accept the semantically identical
14002        // {@code FROM a JOIN b ON …} with two relations. The reject was
14003        // therefore purely syntactic and made Oracle's traditional
14004        // comma-FROM + WHERE-join style unanalyzable in any nested
14005        // position.
14006        //
14007        // {@code allowFromSubqueries} keeps its ONE genuine meaning:
14008        // a FROM-clause subquery operand is still rejected inside body
14009        // contexts by {@link #buildRelation}. The CROSS / USING / NATURAL
14010        // body rejects below are unchanged (separate slice contracts).
14011        // Slice 63: explicit CROSS JOIN admits at outer / CTE-body /
14012        // FROM-subquery-body call sites (allowFromSubqueries=true) but
14013        // stays rejected inside synthetic body contexts (scalar /
14014        // set-op-branch / set-op-CTE / predicate) because the body's
14015        // shape contract (single column for scalar; column-count parity
14016        // for set-op branches; constant or single column-ref for
14017        // predicates) cannot host a cross-product relation graph
14018        // safely. Predicate bodies also hit an earlier shape-specific
14019        // reject inside {@link #preflightPredicateSubqueryShape} so the
14020        // user-visible diagnostic mentions EXISTS / IN-SELECT context.
14021        if (!allowFromSubqueries) {
14022            for (TJoin join : select.joins) {
14023                TJoinItemList items = join.getJoinItems();
14024                if (items == null) continue;
14025                for (int i = 0; i < items.size(); i++) {
14026                    TJoinItem item = items.getJoinItem(i);
14027                    if (item == null) continue;
14028                    if (item.getJoinType() == EJoinType.cross) {
14029                        throw new SemanticIRBuildException(
14030                                Diagnostic.error(DiagnosticCode.CROSS_JOIN_IN_BODY_NOT_SUPPORTED,
14031                                "CROSS JOIN is not supported inside scalar / "
14032                                        + "set-op-branch / set-op-CTE / predicate "
14033                                        + "body contexts yet; rewrite as INNER "
14034                                        + "JOIN ... ON in the body", item));
14035                    }
14036                    // Slice 64: USING admitted at outer / CTE-body /
14037                    // FROM-subquery-body call sites but rejected inside
14038                    // synthetic body contexts. The body's shape contract
14039                    // (single column for scalar, column-count parity for
14040                    // set-op branches, constant/column-ref for predicate
14041                    // bodies) cannot host the merged-key semantics safely.
14042                    if (item.getUsingColumns() != null
14043                            && item.getUsingColumns().size() > 0) {
14044                        throw new SemanticIRBuildException(
14045                                Diagnostic.error(DiagnosticCode.USING_IN_BODY_NOT_SUPPORTED,
14046                                "JOIN ... USING (...) is not supported inside "
14047                                        + "scalar / set-op-branch / set-op-CTE / "
14048                                        + "predicate body contexts yet; rewrite "
14049                                        + "as JOIN ... ON in the body", item));
14050                    }
14051                    // Slice 66: NATURAL JOIN inside synthetic body
14052                    // contexts is rejected with a tuned diagnostic.
14053                    // Predicate bodies hit preflightPredicateSubqueryShape
14054                    // first, which emits a wrapper-aware message; this
14055                    // reject fires for scalar / set-op-branch /
14056                    // set-op-CTE bodies (and as defense-in-depth for
14057                    // predicate bodies if the preflight didn't catch
14058                    // a vendor-specific variant).
14059                    if (isNaturalJoinType(item.getJoinType())) {
14060                        throw new SemanticIRBuildException(
14061                                Diagnostic.error(DiagnosticCode.NATURAL_IN_BODY_NOT_SUPPORTED,
14062                                "NATURAL JOIN is not supported inside "
14063                                        + "scalar / set-op-branch / set-op-CTE / "
14064                                        + "predicate body contexts yet; rewrite "
14065                                        + "as JOIN ... ON in the body", item));
14066                    }
14067                }
14068            }
14069        }
14070        List<RelationSource> relations = new ArrayList<>();
14071        // Join-graph COLUMN_BINDING_NON_EXACT degrade (GSP R6) for the JOIN ON
14072        // path. An unqualified ON-predicate operand that cannot be bound to
14073        // exactly one side (e.g. {@code JOIN cities ON city = name}) is a
14074        // which-side ambiguity, not a genuine error, WHEN the FROM is
14075        // catalog-less for at least one endpoint — exactly the case the
14076        // post-buildRelations SELECT/WHERE anchor already tolerates. The ON
14077        // refs are collected DURING this method (before that anchor is
14078        // installed), so compute the catalog-less eligibility here and hand the
14079        // ON collector an anchored provider. Every {@code collectJoinOnRefs}
14080        // call runs inside the join-item loop, where ≥2 endpoints are already
14081        // bound (driver + right), so the structural {@code relations.size() >= 2}
14082        // gate is satisfied by construction; only the catalog-less check
14083        // remains. A fully-cataloged FROM stays fatal (the catalog adjudicates).
14084        //
14085        // Whole-FROM scope is intentional and mirrors the post-buildRelations
14086        // SELECT/WHERE anchor (which gates on the same
14087        // {@code !fromRelationColumnsFullyKnown(select, provider)} for the whole
14088        // statement): a partially-cataloged FROM (one endpoint catalog-less)
14089        // degrades an unqualified ON miss because that operand MIGHT live on the
14090        // unknown side — exactly as an unqualified SELECT/WHERE column does. A
14091        // genuine typo over a fully-cataloged FROM still stays fatal.
14092        boolean joinGraphAnchorEligible = !fromRelationColumnsFullyKnown(select, provider);
14093        // Slice 179 (R4): stable FROM-order instance ordinal, assigned in the
14094        // SAME driver-then-join-items traversal order as buildJoinGraph, so
14095        // RelationSource.instanceId and JoinEndpoint.instanceId align by
14096        // construction (not by alias). Held in a one-element array so the
14097        // recursive appendJoinRelations helper can advance it across a nested
14098        // parenthesized <joined_table> sub-tree.
14099        int[] relInstanceId = {0};
14100        for (TJoin join : select.joins) {
14101            // Slice 66: per top-level TJoin LeftOutputState. Seeded with the
14102            // top-left table's catalog; updated as JoinItems walk left-to-right.
14103            // NATURAL JoinItems consume this state (catalog intersection) and
14104            // update it. Reset between top-level TJoins so comma-FROM groups
14105            // stay independent.
14106            LeftOutputState leftState = new LeftOutputState();
14107            appendJoinRelations(join, provider, allowFromSubqueries,
14108                    allowJoinOnPredicateSubqueries, stmtsForExtraction,
14109                    lineageForExtraction, cteMapForExtraction,
14110                    relations, joinRefsOut, relInstanceId, leftState,
14111                    joinGraphAnchorEligible);
14112        }
14113        rejectDuplicateAliases(relations, select.dbvendor, select.joins.size() > 1);
14114        return relations;
14115    }
14116
14117    /**
14118     * Append the relations of one FROM-clause {@link TJoin} (its driver operand
14119     * plus each join item) to {@code relations}, populate {@code joinRefsOut}
14120     * with the join-condition column refs, advance {@code relInstanceId[0]} in
14121     * FROM order, and update {@code leftState} (the running row type used for
14122     * NATURAL key inference).
14123     *
14124     * <p>Recurses into a parenthesized / nested {@code <joined_table>} operand —
14125     * a {@link TJoin} reached via {@link TJoin#getJoin()} (driver) or
14126     * {@link TJoinItem#getJoin()} (right operand) when the corresponding
14127     * {@code getTable()} is null. So {@code FROM a JOIN (b JOIN c ON …) ON …}
14128     * collects all three relations, emits the inner join's refs, then attaches
14129     * the outer join's ON over the accumulated endpoints. The same
14130     * {@code leftState} is threaded through the recursion — both
14131     * {@code seedLeftOutput} and {@code appendRightToLeftOutput} only ADD
14132     * columns — so a NATURAL join that follows still sees the accumulated row
14133     * type. {@code FROM_SOURCE_NO_TABLE} / {@code JOIN_ITEM_NO_TABLE} stay fatal
14134     * only for an operand that is genuinely neither a table nor a nested join.
14135     */
14136    private static void appendJoinRelations(
14137            TJoin join,
14138            NameBindingProvider provider,
14139            boolean allowFromSubqueries,
14140            boolean allowJoinOnPredicateSubqueries,
14141            List<StatementGraph> stmtsForExtraction,
14142            List<LineageEdge> lineageForExtraction,
14143            Map<String, Integer> cteMapForExtraction,
14144            List<RelationSource> relations,
14145            List<ColumnRef> joinRefsOut,
14146            int[] relInstanceId,
14147            LeftOutputState leftState,
14148            boolean joinGraphAnchorEligible) {
14149        // ---- driver operand ----
14150        TTable leftTable = join.getTable();
14151        if (leftTable != null) {
14152            relations.add(buildRelation(leftTable, provider, allowFromSubqueries, relInstanceId[0]++));
14153            seedLeftOutput(leftState, leftTable, provider);
14154        } else if (join.getJoin() != null) {
14155            // Parenthesized joined-table as the driver operand — recurse,
14156            // threading the same leftState so it accumulates the sub-tree's
14157            // columns for any NATURAL join that follows at this level.
14158            appendJoinRelations(join.getJoin(), provider, allowFromSubqueries,
14159                    allowJoinOnPredicateSubqueries, stmtsForExtraction,
14160                    lineageForExtraction, cteMapForExtraction,
14161                    relations, joinRefsOut, relInstanceId, leftState,
14162                    joinGraphAnchorEligible);
14163        } else {
14164            throw new SemanticIRBuildException(Diagnostic.error(
14165                    DiagnosticCode.FROM_SOURCE_NO_TABLE, "FROM source has no table", join));
14166        }
14167
14168        TJoinItemList items = join.getJoinItems();
14169        if (items == null) return;
14170        for (int i = 0; i < items.size(); i++) {
14171            TJoinItem item = items.getJoinItem(i);
14172            rejectUnsupportedJoinShape(item);
14173            TTable rightTable = item.getTable();
14174            if (rightTable == null) {
14175                if (item.getJoin() != null) {
14176                    // NATURAL / USING merged-key inference needs a single right
14177                    // TTable to key against; over a parenthesized joined-table
14178                    // operand (a combined row type) it is not supported yet.
14179                    // Fail explicitly rather than silently dropping the merged-key
14180                    // refs in buildRelations while buildJoinGraph still emits a
14181                    // NATURAL/USING entity (codex P2). Plain ON / CROSS over a
14182                    // nested operand ARE supported.
14183                    if (isNaturalJoinType(item.getJoinType())
14184                            || (item.getUsingColumns() != null
14185                                    && item.getUsingColumns().size() > 0)) {
14186                        throw new SemanticIRBuildException(Diagnostic.error(
14187                                DiagnosticCode.NESTED_JOIN_MERGED_KEY_NOT_SUPPORTED,
14188                                "NATURAL / USING join over a parenthesized joined-table "
14189                                        + "operand is not supported yet; rewrite with an "
14190                                        + "explicit ON condition", item));
14191                    }
14192                    // Parenthesized joined-table as the right operand — recurse to
14193                    // collect its relations + inner join refs, then attach this
14194                    // outer join's ON over the accumulated endpoints. Use a FRESH
14195                    // row-state for the sub-tree so its inner NATURAL inference
14196                    // sees only its own columns (codex P1), then merge that row
14197                    // type into this level's leftState so a NATURAL join that
14198                    // FOLLOWS at this level still sees the accumulated columns.
14199                    LeftOutputState subState = new LeftOutputState();
14200                    appendJoinRelations(item.getJoin(), provider, allowFromSubqueries,
14201                            allowJoinOnPredicateSubqueries, stmtsForExtraction,
14202                            lineageForExtraction, cteMapForExtraction,
14203                            relations, joinRefsOut, relInstanceId, subState,
14204                            joinGraphAnchorEligible);
14205                    mergeLeftOutputState(leftState, subState);
14206                    collectJoinOnRefs(item.getOnCondition(), provider,
14207                            allowJoinOnPredicateSubqueries, stmtsForExtraction,
14208                            lineageForExtraction, cteMapForExtraction, joinRefsOut,
14209                            joinGraphAnchorEligible);
14210                    continue;
14211                }
14212                throw new SemanticIRBuildException(Diagnostic.error(
14213                        DiagnosticCode.JOIN_ITEM_NO_TABLE, "JOIN item has no table", item));
14214            }
14215            // Slice 17/18: subqueries on a JOIN side are extracted as their own
14216            // statements by extractFromSubqueriesAsStatements before
14217            // buildRelations runs (when allowFromSubqueries=true). Scalar /
14218            // set-op-branch / set-op-CTE-body builds pass allowFromSubqueries=
14219            // false; buildRelation rejects there.
14220            relations.add(buildRelation(rightTable, provider, allowFromSubqueries, relInstanceId[0]++));
14221            // Slice 66: NATURAL admission via catalog inference. When catalog
14222            // metadata is missing on either side we DEGRADE: skip the merged
14223            // refs, record a NATURAL_CATALOG_REQUIRED warning, and append the
14224            // right's columns to the running state.
14225            if (isNaturalJoinType(item.getJoinType())) {
14226                NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider);
14227                if (r.kind != NaturalKeyResult.Kind.SUCCESS) {
14228                    recordNaturalDegradeWarning(r, item);
14229                    appendRightToLeftOutput(leftState, rightTable, provider);
14230                    continue;
14231                }
14232                emitMergedJoinRefs(JoinKind.NATURAL, r.keys, join, items, i,
14233                        rightTable, provider, joinRefsOut);
14234                mergeRightIntoLeftOutput(leftState, rightTable, provider, r.keys);
14235                continue;
14236            }
14237            // Slice 64: USING and ON are mutually exclusive (enforced by
14238            // rejectUnsupportedJoinShape). Populate per-key joinColumnRefs from
14239            // USING here, then skip the onCond branch.
14240            TObjectNameList usingCols = item.getUsingColumns();
14241            if (usingCols != null && usingCols.size() > 0) {
14242                populateUsingJoinRefs(join, items, i, rightTable,
14243                        usingCols, provider, joinRefsOut);
14244                // Slice 66: USING JoinItems also merge right into the
14245                // LeftOutputState so subsequent NATURAL JoinItems see the
14246                // accumulated row type (merged USING keys at their slots).
14247                List<String> usingKeyNames = new ArrayList<>(usingCols.size());
14248                for (int k = 0; k < usingCols.size(); k++) {
14249                    TObjectName usingKey = usingCols.getObjectName(k);
14250                    if (usingKey == null) continue;
14251                    String keyName = usingKey.getColumnNameOnly();
14252                    if (keyName != null && !keyName.isEmpty()) {
14253                        usingKeyNames.add(keyName);
14254                    }
14255                }
14256                mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames);
14257                continue;
14258            }
14259            // Slice 66: ON / CROSS JoinItem — append right's catalog columns to
14260            // the running LeftOutputState; NATURAL JoinItems that follow see it.
14261            appendRightToLeftOutput(leftState, rightTable, provider);
14262            collectJoinOnRefs(item.getOnCondition(), provider,
14263                    allowJoinOnPredicateSubqueries, stmtsForExtraction,
14264                    lineageForExtraction, cteMapForExtraction, joinRefsOut,
14265                    joinGraphAnchorEligible);
14266        }
14267    }
14268
14269    /**
14270     * Collect the column refs of a JOIN item's {@code ON} expression into
14271     * {@code joinRefsOut}, applying the same predicate-subquery extraction and
14272     * window-function / subquery rejections as the inline ON path. No-op when
14273     * {@code onCond} is null (CROSS / APPLY items carry no ON). Shared by the
14274     * flat and nested ({@link #appendJoinRelations}) traversal paths.
14275     */
14276    private static void collectJoinOnRefs(TExpression onCond, NameBindingProvider provider,
14277            boolean allowJoinOnPredicateSubqueries,
14278            List<StatementGraph> stmtsForExtraction,
14279            List<LineageEdge> lineageForExtraction,
14280            Map<String, Integer> cteMapForExtraction,
14281            List<ColumnRef> joinRefsOut,
14282            boolean joinGraphAnchorEligible) {
14283        if (onCond == null) {
14284            return;
14285        }
14286        // Join-graph COLUMN_BINDING_NON_EXACT degrade (GSP R6): the anchor wraps
14287        // ONLY the direct ON-condition ref binding below. Predicate-subquery
14288        // EXTRACTION keeps the un-anchored {@code provider} — each extracted
14289        // body is built via a nested entry that resets the anchor anyway, and
14290        // its inner binding does not enjoy the ON path's ≥2-bound-endpoint
14291        // invariant. The anchor only changes rejectNonExactBindings: an
14292        // unqualified ON operand that can't be placed on one side (a which-side
14293        // ambiguity over a catalog-less join) degrades to a warning instead of
14294        // aborting the whole analysis.
14295        NameBindingProvider refProvider = joinGraphAnchorEligible
14296                ? provider.withJoinStructureAnchor(true) : provider;
14297        if (allowJoinOnPredicateSubqueries) {
14298            // Slice 23/24/25: outer-SELECT JOIN ON path — extract uncorrelated
14299            // predicate-subquery wrappers as their own <predicate_subquery_<i>>
14300            // StatementGraphs and skip their subtrees during the window guard and
14301            // ref collection. The leak guard rejects everything that is NOT an
14302            // extracted wrapper.
14303            Set<TExpression> extractedRoots =
14304                    extractUncorrelatedPredicateSubqueriesFromJoinOn(onCond, provider,
14305                            stmtsForExtraction, lineageForExtraction, cteMapForExtraction);
14306            rejectAnyRemainingSubqueriesInJoinOn(onCond, extractedRoots);
14307            rejectWindowFunctionInScopeSkipping(onCond, "JOIN ON condition", extractedRoots);
14308            joinRefsOut.addAll(collectColumnRefsSkipping(onCond, refProvider, extractedRoots));
14309        } else {
14310            // Slice 13/17: reject window functions and predicate subqueries in
14311            // JOIN ON before collectColumnRefs descends (every non-outer call
14312            // site: FROM-subquery body, CTE body, scalar body, set-op branch).
14313            rejectWindowFunctionInScope(onCond, "JOIN ON condition");
14314            rejectSubqueriesInJoinOn(onCond);
14315            joinRefsOut.addAll(collectColumnRefs(onCond, refProvider));
14316        }
14317    }
14318
14319    /**
14320     * Slice 17: reject predicate subqueries (EXISTS, IN-SELECT,
14321     * scalar-subquery comparisons, etc.) inside a JOIN ON expression.
14322     * Without this guard, slice-17's expanded JOIN surface (relation
14323     * subqueries on either side) would let predicate subqueries slip
14324     * past {@code collectColumnRefs} and produce incomplete IR. Applies
14325     * to every {@code buildRelations} call site; the slice-11
14326     * {@link #rejectSubqueriesInScalarBodyClauses} and slice-17
14327     * {@link #rejectSubqueriesInFromSubqueryBodyClauses} fire BEFORE
14328     * the recursive {@code buildSelectStatement}, so their context-
14329     * specific messages preempt this one.
14330     */
14331    private static void rejectSubqueriesInJoinOn(TExpression onCond) {
14332        if (containsAnySubqueryExpression(onCond)) {
14333            throw new SemanticIRBuildException(
14334                    Diagnostic.error(DiagnosticCode.JOIN_ON_TOP_LEVEL_SUBQUERY_NOT_SUPPORTED,
14335                    "subquery in a top-level JOIN ON predicate is not supported yet", onCond));
14336        }
14337    }
14338
14339    // ====================================================================
14340    // Slice 23: uncorrelated EXISTS subqueries in top-level outer-SELECT
14341    // JOIN ON.
14342    //
14343    // Approach: walk the JOIN-ON expression looking for `exists_t` nodes
14344    // (and `not_t(exists_t(...))` for NOT EXISTS); validate each as
14345    // uncorrelated with a constant-only inner projection; build the inner
14346    // SELECT as its own `<predicate_subquery_<i>>` StatementGraph; record
14347    // the extracted `exists_t` root in a Set so the JOIN-ON window guard
14348    // and ref collector can skip its subtree. Predicate bodies are
14349    // unreachable from outer (no relation, no lineage edge) so they
14350    // contribute zero canonical edges — matching dlineage's behaviour for
14351    // EXISTS-in-JOIN-ON shapes that project a constant (the inner-shape
14352    // preflight enforces constant-only projection so this invariant
14353    // holds).
14354    //
14355    // Process: codex round 1 + round 2 plan reviews; v3 plan locked.
14356    // See roadmap §14.25 (slice-23 entry).
14357    // ====================================================================
14358
14359    /**
14360     * True iff {@code e} is the root of an EXISTS predicate that slice 23
14361     * may extract: either an {@code exists_t} expression, or a
14362     * {@code logical_not_t} whose <b>right</b> operand is {@code exists_t}.
14363     * NOT EXISTS unwraps to its inner {@code exists_t}.
14364     *
14365     * <p>Note: the GSP parser puts the operand of {@code logical_not_t} in
14366     * {@link TExpression#getRightOperand()}, not {@code getLeftOperand()}
14367     * (verified across Oracle / PostgreSQL / MSSQL / MySQL / BigQuery).
14368     * The root fast-path for {@code NOT EXISTS} is therefore "dead" in the
14369     * sense that the descendant walker on the wrapping {@code logical_not_t}
14370     * already visits the child {@code exists_t} — we still keep it here so
14371     * the symmetry between root EXISTS and root NOT EXISTS is explicit.
14372     *
14373     * <p>Slice 25 (kept as a dedicated helper for slice-23/24 callers and
14374     * for clarity): the slice-25 generalisation lives in
14375     * {@link #unwrapToInnerExtractableSubquery(TExpression)} which
14376     * recognises four wrapper shapes — including the two EXISTS shapes
14377     * here.
14378     */
14379    private static boolean isExistsRoot(TExpression e) {
14380        if (e == null) return false;
14381        if (e.getExpressionType() == EExpressionType.exists_t) return true;
14382        if (e.getExpressionType() == EExpressionType.logical_not_t
14383                && e.getRightOperand() != null
14384                && e.getRightOperand().getExpressionType() == EExpressionType.exists_t) {
14385            return true;
14386        }
14387        return false;
14388    }
14389
14390    /** Return the actual {@code exists_t} node — unwrap a {@code logical_not_t} parent if present. */
14391    private static TExpression unwrapExistsRoot(TExpression e) {
14392        if (e.getExpressionType() == EExpressionType.exists_t) return e;
14393        return e.getRightOperand();
14394    }
14395
14396    /**
14397     * Slice 25 / Slice 26: pure shape-recogniser for the predicate-
14398     * subquery wrappers admitted in TOP-LEVEL JOIN ON. Returns the inner
14399     * extractable node ({@code subquery_t} or {@code exists_t}) for the
14400     * wrapper shapes; null otherwise. Pure — performs NO validation and
14401     * throws NO exceptions.
14402     *
14403     * <p>Recognised wrappers:
14404     * <ul>
14405     *   <li>{@code exists_t} (slice-23 EXISTS) — returns {@code e}.</li>
14406     *   <li>{@code logical_not_t} with rightOperand {@code exists_t}
14407     *       (slice-23 NOT EXISTS) — returns the inner exists_t.</li>
14408     *   <li>{@code in_t} with rightOperand {@code subquery_t}
14409     *       (slice 25 IN-SELECT / NOT IN-SELECT) — returns the
14410     *       rightOperand. LHS-subquery {@code in_t} returns null
14411     *       (slice 26 boundary: dlineage's {@code fdr clause="on"}
14412     *       sources omit the outer column for IN-LHS, so admitting on
14413     *       the IR side would manufacture canonical-model divergence).</li>
14414     *   <li>{@code simple_comparison_t} (slice 25 + slice 26 scalar
14415     *       comparison) — returns the operand on whichever side is a
14416     *       {@code subquery_t}. RHS-subquery (slice 25) and LHS-
14417     *       subquery (slice 26) are both admitted; both-sides subquery
14418     *       returns null and falls through to {@link #findSubqueryOnLeftWrapper}'s
14419     *       new "both subqueries" rejection branch.</li>
14420     *   <li>{@code group_comparison_t} with rightOperand
14421     *       {@code subquery_t} AND non-null {@code getQuantifier()}
14422     *       (slice 25 ANY/ALL/SOME) — returns the rightOperand.
14423     *       LHS-subquery {@code group_comparison_t} returns null
14424     *       (slice 26 boundary: borderline grammar; not probed).</li>
14425     * </ul>
14426     *
14427     * <p>For null returns, the wrapper either is not a recognised shape
14428     * (falls through to the slice-23 generic remaining-subquery rejection
14429     * in {@link #rejectAnyRemainingSubqueriesInJoinOn}) OR has the right
14430     * outer shape but the LHS / RHS positioning is unsupported (subquery
14431     * on left side of IN/quantifier, both sides subquery for cmp, tuple
14432     * LHS / RHS, expression LHS / RHS). The walker validates the
14433     * non-subquery side via {@link #isAdmittedOuterLhsShape} or
14434     * {@link #isAdmittedOuterRhsShape} and throws a slice-25 / slice-26
14435     * tuned message before calling this helper for extraction.
14436     *
14437     * <p>The slice-23/24 EXISTS callers ({@code isExistsRoot} and
14438     * {@code unwrapExistsRoot}) remain in place — both are simple
14439     * boolean / unwrap helpers; this method consolidates the slice-25 /
14440     * slice-26 shape decision in one place.
14441     */
14442    private static TExpression unwrapToInnerExtractableSubquery(TExpression e) {
14443        if (e == null) return null;
14444        EExpressionType t = e.getExpressionType();
14445        if (t == EExpressionType.exists_t) return e;
14446        if (t == EExpressionType.logical_not_t
14447                && e.getRightOperand() != null
14448                && e.getRightOperand().getExpressionType() == EExpressionType.exists_t) {
14449            return e.getRightOperand();
14450        }
14451        TExpression l = e.getLeftOperand();
14452        TExpression r = e.getRightOperand();
14453        boolean lhsIsSubq = l != null && l.getExpressionType() == EExpressionType.subquery_t;
14454        boolean rhsIsSubq = r != null && r.getExpressionType() == EExpressionType.subquery_t;
14455        if (t == EExpressionType.in_t) {
14456            return rhsIsSubq ? r : null;
14457        }
14458        if (t == EExpressionType.simple_comparison_t) {
14459            // Slice 26: admit subquery on either single side. Both sides
14460            // → null (rejected via findSubqueryOnLeftWrapper's new
14461            // "both subqueries" branch — see isSubqueryOnLeftOfWrapper).
14462            if (lhsIsSubq && rhsIsSubq) return null;
14463            if (rhsIsSubq) return r;
14464            if (lhsIsSubq) return l;
14465            return null;
14466        }
14467        if (t == EExpressionType.group_comparison_t
14468                && e.getQuantifier() != null) {
14469            return rhsIsSubq ? r : null;
14470        }
14471        return null;
14472    }
14473
14474    /**
14475     * Slice 25: admitted LHS shapes for non-EXISTS predicate-subquery
14476     * wrappers ({@code in_t} / {@code simple_comparison_t} /
14477     * {@code group_comparison_t}) when the subquery is on the RHS.
14478     *
14479     * <p>Admits ONLY {@link EExpressionType#simple_object_name_t} —
14480     * a single column reference, qualified or unqualified. Rejects:
14481     * tuple expressions ({@code (a, b) IN (...)}), parenthesized
14482     * wrapping ({@code (e.col) IN (...)}), arithmetic
14483     * ({@code e.col + 1 IN (...)}), function calls
14484     * ({@code UPPER(e.col) IN (...)}), scalar subqueries on LHS, and
14485     * any other non-column shape.
14486     *
14487     * <p>Slice-25 boundary; future slice may admit parenthesized
14488     * column refs (slice 26+).
14489     */
14490    private static boolean isAdmittedOuterLhsShape(TExpression lhs) {
14491        return lhs != null
14492                && lhs.getExpressionType() == EExpressionType.simple_object_name_t;
14493    }
14494
14495    /**
14496     * Slice 26: admitted RHS shapes for {@code simple_comparison_t}
14497     * with subquery on the LHS. Mirror of
14498     * {@link #isAdmittedOuterLhsShape}: admits ONLY
14499     * {@link EExpressionType#simple_object_name_t} — a single column
14500     * reference, qualified or unqualified. Rejects tuple, parenthesized,
14501     * arithmetic, function-call, and subquery (the "both subqueries"
14502     * shape is rejected separately via
14503     * {@link #isSubqueryOnLeftOfWrapper}'s new
14504     * {@code simple_comparison_t} both-sides branch).
14505     *
14506     * <p>Slice-26 boundary: only {@code simple_comparison_t} reaches
14507     * this helper (the walker dispatches on which side is the
14508     * subquery). {@code in_t} / {@code group_comparison_t} with LHS
14509     * subquery return null from
14510     * {@link #unwrapToInnerExtractableSubquery} so they never reach
14511     * here.
14512     */
14513    private static boolean isAdmittedOuterRhsShape(TExpression rhs) {
14514        return rhs != null
14515                && rhs.getExpressionType() == EExpressionType.simple_object_name_t;
14516    }
14517
14518    /**
14519     * Slice 25 (impl-review M1-fix): true iff {@code e} is a
14520     * {@code logical_not_t} wrapping a slice-25 IN / scalar-cmp /
14521     * ANY-ALL-SOME wrapper (i.e. NOT applied to an admitted slice-25
14522     * shape that ISN'T an EXISTS). The descendant walker would
14523     * otherwise traverse INTO this {@code logical_not_t} and find the
14524     * child wrapper, accidentally admitting
14525     * {@code NOT (e.col IN (SELECT ...))} which is NOT a slice-25
14526     * recognised shape ({@code unwrapToInnerExtractableSubquery}
14527     * matches {@code logical_not_t} only when the inner is
14528     * {@code exists_t}).
14529     *
14530     * <p>This helper is consulted by the extraction walker BEFORE it
14531     * descends into the children of a {@code logical_not_t}, so the
14532     * rejection happens at the wrapper level with a tuned message
14533     * pointing at the slice-25 boundary.
14534     */
14535    private static boolean isLogicalNotOverNonExistsWrapper(TExpression e) {
14536        if (e == null) return false;
14537        if (e.getExpressionType() != EExpressionType.logical_not_t) return false;
14538        TExpression r = e.getRightOperand();
14539        if (r == null) return false;
14540        // Strip parenthesis_t chain. The Oracle parser wraps
14541        // `NOT (e.col IN (SELECT...))` as
14542        // logical_not_t → parenthesis_t → in_t, so the immediate
14543        // right child is parenthesis_t. Descend through any chain of
14544        // parens to find the actual subject. Note:
14545        // {@code parenthesis_t} stores its child on
14546        // {@link TExpression#getLeftOperand()} (mirroring
14547        // {@link #isConstantExpression}'s descent).
14548        TExpression subject = r;
14549        while (subject != null
14550                && subject.getExpressionType() == EExpressionType.parenthesis_t) {
14551            subject = subject.getLeftOperand();
14552        }
14553        if (subject == null) return false;
14554        if (subject.getExpressionType() == EExpressionType.exists_t) return false;
14555        // Either an in_t / simple_comparison_t / group_comparison_t
14556        // with subquery RHS, or any of those types directly.
14557        return unwrapToInnerExtractableSubquery(subject) != null;
14558    }
14559
14560    /**
14561     * Slice 25 / Slice 26: build a tuned outer-shape rejection message
14562     * for a non-EXISTS predicate-subquery wrapper. Called from the
14563     * extraction walker when {@link #unwrapToInnerExtractableSubquery}
14564     * returns non-null for an in_t / simple_comparison_t /
14565     * group_comparison_t but the non-subquery side is not admitted by
14566     * {@link #isAdmittedOuterLhsShape} (slice 25 — subquery on RHS) or
14567     * {@link #isAdmittedOuterRhsShape} (slice 26 — subquery on LHS).
14568     *
14569     * <p>{@code isLhsSubquery} indicates which side of the wrapper
14570     * carries the subquery: {@code true} = subquery on LHS (slice 26
14571     * path; we validate the wrapper's RHS), {@code false} = subquery
14572     * on RHS (slice 25 path; we validate the wrapper's LHS).
14573     *
14574     * <p>Returns only the reason text. The caller combines it with the
14575     * strongly typed predicate-subquery context so the rendered construct
14576     * and clause always describe the actual source wrapper.
14577     */
14578    private static String buildOuterShapeRejectionReason(TExpression wrapper,
14579                                                         boolean isLhsSubquery) {
14580        EExpressionType t = wrapper.getExpressionType();
14581        String shapeLabel;
14582        if (t == EExpressionType.in_t) shapeLabel = "IN";
14583        else if (t == EExpressionType.simple_comparison_t) shapeLabel = "comparison";
14584        else if (t == EExpressionType.group_comparison_t) shapeLabel = "ANY/ALL/SOME";
14585        else shapeLabel = String.valueOf(t);
14586        // Validate the side that does NOT carry the subquery.
14587        TExpression nonSubquerySide = isLhsSubquery
14588                ? wrapper.getRightOperand()
14589                : wrapper.getLeftOperand();
14590        String sideLabel = isLhsSubquery ? "RHS" : "LHS";
14591        EExpressionType sideType = nonSubquerySide == null
14592                ? null : nonSubquerySide.getExpressionType();
14593        String detail;
14594        if (nonSubquerySide == null) {
14595            detail = "missing " + sideLabel;
14596        } else if (sideType == EExpressionType.list_t) {
14597            detail = "tuple " + sideLabel;
14598        } else if (sideType == EExpressionType.parenthesis_t) {
14599            detail = "parenthesized " + sideLabel;
14600        } else if (sideType == EExpressionType.simple_object_name_t) {
14601            // Defensive: should not be reached when the corresponding
14602            // admitted-shape helper returns true.
14603            detail = "unexpected admitted " + sideLabel + " shape";
14604        } else {
14605            detail = "expression " + sideLabel + " (" + sideType + ")";
14606        }
14607        String boundary = isLhsSubquery ? "slice 26 boundary" : "slice 25 boundary";
14608        return shapeLabel + " wrapper has unsupported " + sideLabel + " shape ("
14609                + detail + "); only a single column reference "
14610                + "(simple_object_name_t) is admitted on the "
14611                + sideLabel
14612                + " of a comparison / IN / ANY-ALL-SOME "
14613                + "predicate subquery when the other side is a "
14614                + "subquery (" + boundary + ")";
14615    }
14616
14617    /**
14618     * Slice 25 (rename of slice-23
14619     * {@code extractUncorrelatedExistsFromJoinOn}): walk the JOIN-ON
14620     * expression, extract every uncorrelated predicate-subquery wrapper
14621     * (EXISTS / NOT EXISTS / IN-SELECT / NOT IN-SELECT / scalar
14622     * comparison subquery / ANY-ALL-SOME) as its own
14623     * {@code <predicate_subquery_<i>>} StatementGraph, and return the
14624     * set of extracted inner nodes (the {@code exists_t} or
14625     * {@code subquery_t}, NOT the wrapping {@code in_t} /
14626     * {@code simple_comparison_t} / {@code group_comparison_t} /
14627     * {@code logical_not_t}) keyed on identity.
14628     *
14629     * <p>The set is consumed by the JOIN-ON window-function guard, the
14630     * JOIN-ON ref collector, and the slice-17 remaining-subquery
14631     * rejecter — each of those skips INTO / PAST these subtrees so
14632     * inner refs do not leak into outer joinColumnRefs. Critically,
14633     * the wrapper itself (e.g. an {@code in_t} whose RHS is the
14634     * {@code subquery_t}) is NOT in the set — this lets the LHS column
14635     * reference (e.g. {@code e.dept_id} in
14636     * {@code e.dept_id IN (SELECT ...)}) be collected normally into
14637     * outer's {@code joinColumnRefs}.
14638     *
14639     * <p>The walker handles BOTH the root-position case (the entire ON
14640     * IS one of the four wrappers, which {@code acceptChildren} would
14641     * not visit as a node) AND descendant positions (e.g.
14642     * {@code e.id = d.id AND e.dept_id IN (SELECT ...)}). Multiple
14643     * wrappers in one ON, multiple ON across multiple JOINs, and mixed
14644     * EXISTS / IN / cmp / ANY-ALL combinations are all handled.
14645     *
14646     * <p>Slice 25 / Slice 26 outer-shape validation: for non-EXISTS
14647     * wrappers, the side opposite the subquery must be a single
14648     * {@code simple_object_name_t} column ref. Slice 25 admits subquery
14649     * on RHS only and validates LHS via
14650     * {@link #isAdmittedOuterLhsShape}. Slice 26 lifts {@code
14651     * simple_comparison_t} to also admit subquery on LHS and validates
14652     * RHS via {@link #isAdmittedOuterRhsShape}. Tuple / parenthesized /
14653     * expression / function-call shapes on the validated side throw
14654     * {@link SemanticIRBuildException} with a tuned message via
14655     * {@link #buildOuterShapeRejectionReason}. The EXISTS branch has
14656     * no outer-shape gate (slice-23 carryover).
14657     *
14658     * <p>Snapshot/rollback wrapper at the outer-SELECT call site
14659     * ({@link #build}) catches a partial extraction (e.g. third
14660     * wrapper rejected after first two extracted) and truncates
14661     * {@code stmts}/{@code lineage} back to the snapshot.
14662     */
14663    /**
14664     * Slice 110 — context bag threading clause-specific
14665     * {@link DiagnosticCode}s and a clause-label into the slice-23+
14666     * predicate-subquery extraction pipeline so the same walker code can
14667     * power JOIN-ON (slice 23–33+) and UPDATE WHERE (slice 110) without
14668     * code duplication.
14669     *
14670     * <p>Two static instances exist:
14671     * <ul>
14672     *   <li>{@link #JOIN_ON} — preserves slice-23+ JOIN-ON semantic
14673     *       behavior and stable codes while diagnostics name the actual
14674     *       predicate wrapper.</li>
14675     *   <li>{@link #UPDATE_WHERE} — slice 110 UPDATE WHERE call site
14676     *       (parallel {@code UPDATE_WHERE_*} codes; "UPDATE WHERE clause"
14677     *       label).</li>
14678     * </ul>
14679     *
14680     * <p>Codes per clause are intentionally parallel (slice-80
14681     * granular-codes contract: each semantic reject reason gets its own
14682     * stable API code rather than an umbrella code with discriminating
14683     * message text).
14684     */
14685    private static final class PredicateClauseContext {
14686        /** Used as the "in &lt;label&gt;" piece of every diagnostic message. */
14687        final String clauseLabel;
14688        final DiagnosticCode existsBodyMissing;
14689        final DiagnosticCode existsInnerRelationUnknown;
14690        final DiagnosticCode existsCorrelatedUnknownOuterAlias;
14691        final DiagnosticCode predicateNotNot;
14692        final DiagnosticCode outerShapeRejected;
14693        final DiagnosticCode scalarComparisonBothSides;
14694        final DiagnosticCode predicateSubqueryOnLeft;
14695        final DiagnosticCode genericSubqueryNotSupported;
14696
14697        private PredicateClauseContext(String clauseLabel,
14698                                       DiagnosticCode existsBodyMissing,
14699                                       DiagnosticCode existsInnerRelationUnknown,
14700                                       DiagnosticCode existsCorrelatedUnknownOuterAlias,
14701                                       DiagnosticCode predicateNotNot,
14702                                       DiagnosticCode outerShapeRejected,
14703                                       DiagnosticCode scalarComparisonBothSides,
14704                                       DiagnosticCode predicateSubqueryOnLeft,
14705                                       DiagnosticCode genericSubqueryNotSupported) {
14706            this.clauseLabel = clauseLabel;
14707            this.existsBodyMissing = existsBodyMissing;
14708            this.existsInnerRelationUnknown = existsInnerRelationUnknown;
14709            this.existsCorrelatedUnknownOuterAlias = existsCorrelatedUnknownOuterAlias;
14710            this.predicateNotNot = predicateNotNot;
14711            this.outerShapeRejected = outerShapeRejected;
14712            this.scalarComparisonBothSides = scalarComparisonBothSides;
14713            this.predicateSubqueryOnLeft = predicateSubqueryOnLeft;
14714            this.genericSubqueryNotSupported = genericSubqueryNotSupported;
14715        }
14716
14717        static final PredicateClauseContext JOIN_ON = new PredicateClauseContext(
14718                "JOIN ON",
14719                DiagnosticCode.JOIN_ON_EXISTS_BODY_MISSING,
14720                DiagnosticCode.JOIN_ON_EXISTS_INNER_RELATION_UNKNOWN,
14721                DiagnosticCode.JOIN_ON_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14722                DiagnosticCode.JOIN_ON_PREDICATE_NOT_NOT_SUPPORTED,
14723                DiagnosticCode.JOIN_ON_OUTER_SHAPE_REJECTED,
14724                DiagnosticCode.JOIN_ON_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14725                DiagnosticCode.JOIN_ON_PREDICATE_NOT_LIFTABLE,
14726                DiagnosticCode.JOIN_ON_PREDICATE_GENERIC_NOT_SUPPORTED);
14727
14728        static final PredicateClauseContext UPDATE_WHERE = new PredicateClauseContext(
14729                "UPDATE WHERE clause",
14730                DiagnosticCode.UPDATE_WHERE_EXISTS_BODY_MISSING,
14731                DiagnosticCode.UPDATE_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14732                DiagnosticCode.UPDATE_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14733                DiagnosticCode.UPDATE_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14734                DiagnosticCode.UPDATE_WHERE_OUTER_SHAPE_REJECTED,
14735                DiagnosticCode.UPDATE_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14736                DiagnosticCode.UPDATE_WHERE_PREDICATE_NOT_LIFTABLE,
14737                DiagnosticCode.UPDATE_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14738
14739        static final PredicateClauseContext DELETE_WHERE = new PredicateClauseContext(
14740                "DELETE WHERE clause",
14741                DiagnosticCode.DELETE_WHERE_EXISTS_BODY_MISSING,
14742                DiagnosticCode.DELETE_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14743                DiagnosticCode.DELETE_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14744                DiagnosticCode.DELETE_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14745                DiagnosticCode.DELETE_WHERE_OUTER_SHAPE_REJECTED,
14746                DiagnosticCode.DELETE_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14747                DiagnosticCode.DELETE_WHERE_PREDICATE_NOT_LIFTABLE,
14748                DiagnosticCode.DELETE_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14749
14750        static final PredicateClauseContext SELECT_WHERE = new PredicateClauseContext(
14751                "SELECT WHERE clause",
14752                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14753                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14754                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14755                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14756                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14757                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14758                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14759                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14760
14761        /**
14762         * Slice 113 — uncorrelated WHERE-side predicate subqueries on
14763         * set-op branches (UNION / INTERSECT / EXCEPT / MINUS branches).
14764         * Reuses every {@link DiagnosticCode} from {@link #SELECT_WHERE}
14765         * because a branch IS a SELECT — the shape rejects are
14766         * semantically identical to top-level SELECT WHERE. Only the
14767         * {@code clauseLabel} differs so diagnostic messages distinguish
14768         * the nested context (helpful when a multi-branch query reports
14769         * a reject and the user needs to know which branch). Keeping the
14770         * codes shared frees consumers from a new code-family migration
14771         * and preserves the enum count at 279.
14772         */
14773        static final PredicateClauseContext SET_OP_BRANCH_WHERE = new PredicateClauseContext(
14774                "set-op branch WHERE clause",
14775                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14776                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14777                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14778                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14779                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14780                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14781                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14782                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14783
14784        /**
14785         * Slice 114 — uncorrelated WHERE-side predicate subqueries
14786         * inside a non-set-op CTE body (the SELECT body of a single CTE
14787         * in a WITH list on SELECT / MERGE / UPDATE / DELETE). Reuses
14788         * every {@link DiagnosticCode} from {@link #SELECT_WHERE}
14789         * because a CTE body IS a SELECT — the shape rejects are
14790         * semantically identical to top-level SELECT WHERE. Only the
14791         * {@code clauseLabel} differs so a reject in a
14792         * {@code WITH cte AS (SELECT ... WHERE NOT (...))} shape can
14793         * identify the CTE-body host context in the diagnostic message.
14794         * Keeping the codes shared (slice 113 precedent) preserves the
14795         * enum count at 279 and frees consumers from another
14796         * code-family migration.
14797         */
14798        static final PredicateClauseContext CTE_BODY_WHERE = new PredicateClauseContext(
14799                "CTE body WHERE clause",
14800                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14801                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14802                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14803                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14804                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14805                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14806                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14807                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14808
14809        /**
14810         * Slice 120 — uncorrelated WHERE-side predicate subqueries inside
14811         * a FROM-subquery body (the inner SELECT of a {@code FROM (...)}
14812         * derived table). Reuses every {@link DiagnosticCode} from
14813         * {@link #SELECT_WHERE} (slice 113/114/116 precedent) because a
14814         * FROM-subquery body IS a SELECT — the shape rejects are
14815         * semantically identical to top-level SELECT WHERE. Only the
14816         * {@code clauseLabel} differs so a reject inside a
14817         * {@code FROM (SELECT ... WHERE NOT (...)) sub} shape can identify
14818         * the FROM-subquery host context in the diagnostic message. The
14819         * FROM-subquery body builder {@code processDirectSubqueryTable} is
14820         * shared by the SELECT, UPDATE (slice 83), and DELETE (slice 84)
14821         * FROM-subquery extractors, so this single context lifts all three.
14822         * Keeping the codes shared preserves the enum count at 279 and
14823         * frees consumers from another code-family migration.
14824         */
14825        static final PredicateClauseContext FROM_SUBQUERY_BODY_WHERE = new PredicateClauseContext(
14826                "FROM-subquery body WHERE clause",
14827                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14828                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14829                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14830                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14831                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14832                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14833                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14834                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14835
14836        /**
14837         * Slice 121 — uncorrelated WHERE-side predicate subqueries inside
14838         * a scalar projection subquery body (the inner SELECT of a
14839         * {@code SELECT (SELECT ...) AS m FROM ...} scalar projection).
14840         * Reuses every {@link DiagnosticCode} from {@link #SELECT_WHERE}
14841         * (slice 113/114/120 precedent) because a scalar body IS a SELECT —
14842         * the shape rejects are semantically identical to top-level SELECT
14843         * WHERE. Only the {@code clauseLabel} differs so a reject inside a
14844         * {@code (SELECT ... WHERE NOT (...)) AS m} shape can identify the
14845         * scalar-body host context in the diagnostic message. The lift is
14846         * applied at the shared projection scalar-body build in
14847         * {@code extractScalarSubqueriesAsStatementsInternal}, covering both
14848         * the recursive (outer / CTE-body) and non-recursive (set-op-branch)
14849         * scalar contexts. The UPDATE SET-RHS scalar path keeps the slice-11
14850         * WHERE reject ({@code SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE}).
14851         * Keeping the codes shared preserves the enum count at 279 and
14852         * frees consumers from another code-family migration.
14853         */
14854        static final PredicateClauseContext SCALAR_BODY_WHERE = new PredicateClauseContext(
14855                "scalar subquery body WHERE clause",
14856                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14857                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14858                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14859                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14860                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14861                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14862                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14863                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14864
14865        /**
14866         * Slice 116 — uncorrelated WHERE-side predicate subqueries on
14867         * MERGE per-WHEN action WHEREs ({@code TMergeUpdateClause.updateWhereClause},
14868         * {@code TMergeUpdateClause.deleteWhereClause},
14869         * {@code TMergeInsertClause.insertWhereClause}). Reuses every
14870         * {@link DiagnosticCode} from {@link #SELECT_WHERE} (slice 113/114
14871         * precedent) because a MERGE-action WHERE predicate IS a SELECT
14872         * WHERE in shape — the shape rejects are semantically identical to
14873         * top-level SELECT WHERE. Only the {@code clauseLabel} differs so a
14874         * reject inside a MERGE WHEN can identify the host context in the
14875         * diagnostic message. Keeping the codes shared preserves the enum
14876         * count at 279 and frees consumers from another code-family
14877         * migration.
14878         */
14879        static final PredicateClauseContext MERGE_WHEN_WHERE = new PredicateClauseContext(
14880                "MERGE WHEN action WHERE clause",
14881                DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING,
14882                DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN,
14883                DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS,
14884                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED,
14885                DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED,
14886                DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE,
14887                DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE,
14888                DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED);
14889    }
14890
14891    /** Syntactic wrapper kind captured before predicate-subquery lowering. */
14892    private enum PredicateSubqueryKind {
14893        EXISTS,
14894        IN,
14895        SCALAR_COMPARISON,
14896        QUANTIFIED_COMPARISON
14897    }
14898
14899    /**
14900     * Immutable provenance for one recognised predicate-subquery wrapper.
14901     *
14902     * <p>The context is constructed from AST enums and tokens at wrapper
14903     * recognition time, then threaded through every later validation stage.
14904     * Diagnostics therefore never have to infer the original construct from
14905     * a partially lowered inner statement or re-scan SQL text.
14906     */
14907    private static final class PredicateSubqueryContext {
14908        final PredicateSubqueryKind kind;
14909        final boolean negated;
14910        final String comparisonOperator;
14911        final EQuantifierType quantifier;
14912        final PredicateClauseContext clause;
14913        final SourceSpan wrapperSpan;
14914        final SourceSpan innerSpan;
14915
14916        private PredicateSubqueryContext(PredicateSubqueryKind kind,
14917                                         boolean negated,
14918                                         String comparisonOperator,
14919                                         EQuantifierType quantifier,
14920                                         PredicateClauseContext clause,
14921                                         SourceSpan wrapperSpan,
14922                                         SourceSpan innerSpan) {
14923            this.kind = kind;
14924            this.negated = negated;
14925            this.comparisonOperator = comparisonOperator;
14926            this.quantifier = quantifier;
14927            this.clause = clause;
14928            this.wrapperSpan = wrapperSpan;
14929            this.innerSpan = innerSpan;
14930        }
14931
14932        static PredicateSubqueryContext from(TExpression wrapper,
14933                                             TExpression extractableNode,
14934                                             PredicateClauseContext clause) {
14935            EExpressionType type = wrapper == null
14936                    ? null : wrapper.getExpressionType();
14937            PredicateSubqueryKind kind;
14938            boolean negated = false;
14939            String operator = null;
14940            EQuantifierType quantifier = EQuantifierType.none;
14941
14942            if (type == EExpressionType.exists_t) {
14943                kind = PredicateSubqueryKind.EXISTS;
14944            } else if (type == EExpressionType.logical_not_t
14945                    && extractableNode != null
14946                    && extractableNode.getExpressionType() == EExpressionType.exists_t) {
14947                kind = PredicateSubqueryKind.EXISTS;
14948                negated = true;
14949            } else if (type == EExpressionType.in_t) {
14950                kind = PredicateSubqueryKind.IN;
14951                negated = wrapper.getNotToken() != null;
14952            } else if (type == EExpressionType.simple_comparison_t) {
14953                kind = PredicateSubqueryKind.SCALAR_COMPARISON;
14954                operator = comparisonOperatorOf(wrapper);
14955            } else if (type == EExpressionType.group_comparison_t) {
14956                kind = PredicateSubqueryKind.QUANTIFIED_COMPARISON;
14957                operator = comparisonOperatorOf(wrapper);
14958                quantifier = wrapper.getQuantifierType();
14959            } else {
14960                throw invalidPredicateSubqueryContext(wrapper, clause,
14961                        "recognised predicate-subquery wrapper has unexpected type " + type);
14962            }
14963
14964            if ((kind == PredicateSubqueryKind.SCALAR_COMPARISON
14965                    || kind == PredicateSubqueryKind.QUANTIFIED_COMPARISON)
14966                    && (operator == null || operator.isEmpty())) {
14967                throw invalidPredicateSubqueryContext(wrapper, clause,
14968                        "comparison predicate-subquery wrapper has no operator token");
14969            }
14970            if (kind == PredicateSubqueryKind.QUANTIFIED_COMPARISON
14971                    && (quantifier == null || quantifier == EQuantifierType.none)) {
14972                throw invalidPredicateSubqueryContext(wrapper, clause,
14973                        "quantified predicate-subquery wrapper has no quantifier");
14974            }
14975
14976            TSelectSqlStatement inner = extractableNode == null
14977                    ? null : extractableNode.getSubQuery();
14978            return new PredicateSubqueryContext(kind, negated, operator,
14979                    quantifier, clause, SourceSpan.of(wrapper), SourceSpan.of(inner));
14980        }
14981
14982        private static String comparisonOperatorOf(TExpression wrapper) {
14983            return wrapper.getOperatorToken() == null
14984                    ? null : wrapper.getOperatorToken().toString().trim();
14985        }
14986
14987        String constructDisplayName() {
14988            switch (kind) {
14989                case EXISTS:
14990                    return negated ? "NOT EXISTS subquery" : "EXISTS subquery";
14991                case IN:
14992                    return negated ? "NOT IN subquery" : "IN subquery";
14993                case SCALAR_COMPARISON:
14994                    return comparisonOperator + " scalar subquery comparison";
14995                case QUANTIFIED_COMPARISON:
14996                    return comparisonOperator + " "
14997                            + quantifier.name().toUpperCase(Locale.ROOT)
14998                            + " subquery comparison";
14999                default:
15000                    throw new IllegalStateException("unhandled predicate-subquery kind " + kind);
15001            }
15002        }
15003    }
15004
15005    /**
15006     * Central renderer for unsupported predicate-subquery diagnostics.
15007     * Stable codes continue to identify the failed semantic capability;
15008     * message text and provenance identify the actual wrapper and clause.
15009     */
15010    private static final class PredicateSubqueryDiagnostics {
15011        private static final String WRAPPER_ROLE = "predicate-subquery-wrapper";
15012
15013        static Diagnostic unsupported(DiagnosticCode code,
15014                                      PredicateSubqueryContext context,
15015                                      String reason,
15016                                      TParseTreeNode failureAnchor) {
15017            return unsupportedWithSpan(code, context, reason,
15018                    SourceSpan.of(failureAnchor));
15019        }
15020
15021        static Diagnostic unsupportedWithSpan(DiagnosticCode code,
15022                                              PredicateSubqueryContext context,
15023                                              String reason,
15024                                              SourceSpan failureSpan) {
15025            SourceSpan primary = failureSpan != null
15026                    ? failureSpan
15027                    : (context.innerSpan != null
15028                            ? context.innerSpan : context.wrapperSpan);
15029            Diagnostic.Builder builder = Diagnostic.builder(code, Severity.ERROR,
15030                    context.constructDisplayName() + " in "
15031                            + context.clause.clauseLabel + ": " + reason)
15032                    .span(primary);
15033            if (context.wrapperSpan != null
15034                    && !context.wrapperSpan.equals(primary)) {
15035                builder.addRelatedLocation(RelatedLocation.of(
15036                        WRAPPER_ROLE,
15037                        "predicate subquery wrapper is here",
15038                        context.wrapperSpan));
15039            }
15040            return builder.build();
15041        }
15042    }
15043
15044    private static SemanticIRBuildException invalidPredicateSubqueryContext(
15045            TExpression wrapper,
15046            PredicateClauseContext clause,
15047            String reason) {
15048        return new SemanticIRBuildException(
15049                Diagnostic.error(clause.genericSubqueryNotSupported,
15050                        "predicate subquery in " + clause.clauseLabel + ": " + reason,
15051                        wrapper));
15052    }
15053
15054    /**
15055     * Slice 110 — preserved entry-point alias for the JOIN-ON walker.
15056     * Delegates to {@link #extractUncorrelatedPredicateSubqueriesFromClause}
15057     * with {@link PredicateClauseContext#JOIN_ON} so existing JOIN-ON
15058     * callers (single site in {@code buildRelations}) need no change and
15059     * the slice-23+ diagnostic byte-shape is preserved exactly.
15060     */
15061    private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromJoinOn(
15062            TExpression onCond,
15063            final NameBindingProvider provider,
15064            final List<StatementGraph> stmts,
15065            final List<LineageEdge> lineage,
15066            final Map<String, Integer> cteMapForExtraction) {
15067        return extractUncorrelatedPredicateSubqueriesFromClause(
15068                onCond, provider, stmts, lineage, cteMapForExtraction,
15069                PredicateClauseContext.JOIN_ON,
15070                /*correlationScope=*/ null);
15071    }
15072
15073    /**
15074     * Slice 118 — overload preserved for the slice-110 / 111 / 112 / 113 /
15075     * 114 / 116 call sites that don't admit correlation. Delegates to the
15076     * 8-arg form with {@code correlationScope=null}.
15077     */
15078    private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause(
15079            TExpression onCond,
15080            final NameBindingProvider provider,
15081            final List<StatementGraph> stmts,
15082            final List<LineageEdge> lineage,
15083            final Map<String, Integer> cteMapForExtraction,
15084            final PredicateClauseContext ctx) {
15085        return extractUncorrelatedPredicateSubqueriesFromClause(onCond,
15086                provider, stmts, lineage, cteMapForExtraction, ctx,
15087                /*correlationScope=*/ null, /*semiFactsOut=*/ null,
15088                /*outerRelationAliases=*/ Collections.<String>emptySet());
15089    }
15090
15091    /**
15092     * R8 — delegating overload preserving the pre-R8 7-arg signature
15093     * (correlationScope, no semi-join collector). MERGE / UPDATE / DELETE
15094     * predicate-WHERE callers route here; semi-join facts are collected
15095     * only on the SELECT WHERE path (which calls the 8-arg overload with a
15096     * non-null {@code semiFactsOut}).
15097     */
15098    private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause(
15099            TExpression onCond,
15100            final NameBindingProvider provider,
15101            final List<StatementGraph> stmts,
15102            final List<LineageEdge> lineage,
15103            final Map<String, Integer> cteMapForExtraction,
15104            final PredicateClauseContext ctx,
15105            final EnclosingScope correlationScope) {
15106        return extractUncorrelatedPredicateSubqueriesFromClause(onCond,
15107                provider, stmts, lineage, cteMapForExtraction, ctx,
15108                correlationScope, /*semiFactsOut=*/ null,
15109                /*outerRelationAliases=*/ Collections.<String>emptySet());
15110    }
15111
15112    /**
15113     * Slice 118 — same as the 7-arg overload but threads an optional
15114     * {@code correlationScope} (target + USING source + outer CTEs) into
15115     * {@link #extractOnePredicateSubqueryBody}. When non-null, the inner
15116     * predicate-body build uses tolerant outer binding and the post-build
15117     * correlation walk PROMOTES outer-aliased refs into synthesised
15118     * OUTER_REFERENCE relations instead of rejecting them. The FILTER and
15119     * WITHIN GROUP correlation walks remain active (codex round-1 Q2
15120     * BLOCKING fix) so refs hidden inside FILTER subtrees or PG
15121     * {@code fn.withinGroup.orderBy} continue to reject.
15122     *
15123     * <p>All non-MERGE callers pass {@code correlationScope=null} and
15124     * therefore see the same semantic behaviour. Only
15125     * {@link #collectMergeActionWhere} passes a non-null scope (built once
15126     * per MERGE in {@code buildMerge} via
15127     * {@link #buildMergeEnclosingScope}).
15128     */
15129    private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause(
15130            TExpression onCond,
15131            final NameBindingProvider provider,
15132            final List<StatementGraph> stmts,
15133            final List<LineageEdge> lineage,
15134            final Map<String, Integer> cteMapForExtraction,
15135            final PredicateClauseContext ctx,
15136            final EnclosingScope correlationScope,
15137            final List<SemiJoinFact> semiFactsOut,
15138            final Set<String> outerRelationAliases) {
15139        // Defensive null assertions — the extraction path can only be
15140        // reached from buildRelations when
15141        // allowJoinOnPredicateSubqueries=true (outer-SELECT only), which
15142        // guarantees stmts/lineage/cteMap are non-null. Failing here means
15143        // a future refactor wired a non-outer call site through the slice-25
15144        // path without supplying the required state.
15145        if (onCond == null) {
15146            return Collections.newSetFromMap(new java.util.IdentityHashMap<TExpression, Boolean>());
15147        }
15148        if (stmts == null || lineage == null || cteMapForExtraction == null) {
15149            throw new IllegalStateException(
15150                    "extractUncorrelatedPredicateSubqueriesFromClause("
15151                            + ctx.clauseLabel + ") activated without required state — "
15152                            + "stmts=" + (stmts == null ? "null" : "ok")
15153                            + " lineage=" + (lineage == null ? "null" : "ok")
15154                            + " cteMap=" + (cteMapForExtraction == null ? "null" : "ok")
15155                            + "; caller misconfiguration");
15156        }
15157        final Set<TExpression> extractedRoots =
15158                Collections.newSetFromMap(new java.util.IdentityHashMap<TExpression, Boolean>());
15159        // Slice 25 (impl-review M1-fix): explicit reject for
15160        // {@code logical_not_t} over a slice-25 IN / scalar-cmp /
15161        // ANY-ALL-SOME wrapper at the root. The slice-23/24
15162        // {@code logical_not_t} over {@code exists_t} (NOT EXISTS)
15163        // remains admitted by unwrapToInnerExtractableSubquery.
15164        if (isLogicalNotOverNonExistsWrapper(onCond)) {
15165            throw new SemanticIRBuildException(
15166                    Diagnostic.error(ctx.predicateNotNot,
15167                    "predicate subquery in " + ctx.clauseLabel + ": NOT applied to "
15168                            + "a non-EXISTS predicate subquery wrapper "
15169                            + "(" + onCond.getRightOperand().getExpressionType()
15170                            + ") is not supported yet — the slice-25 boundary "
15171                            + "admits NOT only over EXISTS; "
15172                            + "rewrite e.g. NOT (a IN (SELECT ...)) as "
15173                            + "a NOT IN (SELECT ...)", onCond));
15174        }
15175        // Root fast path: acceptChildren never visits the root node, so
15176        // a clause whose entire expression IS a wrapper would be missed
15177        // by the descendant walker.
15178        TExpression rootExtractable = unwrapToInnerExtractableSubquery(onCond);
15179        if (rootExtractable != null) {
15180            PredicateSubqueryContext subqueryContext =
15181                    PredicateSubqueryContext.from(onCond, rootExtractable, ctx);
15182            // M1-fix + slice-26 dual-side: validate the non-subquery
15183            // side of non-EXISTS wrappers BEFORE extracting (so partial
15184            // extraction never lands). Slice 25 carryover: subquery on
15185            // RHS → validate LHS via isAdmittedOuterLhsShape.
15186            // Slice 26 NEW: subquery on LHS (simple_comparison_t only)
15187            // → validate RHS via isAdmittedOuterRhsShape.
15188            if (onCond.getExpressionType() != EExpressionType.exists_t
15189                    && onCond.getExpressionType() != EExpressionType.logical_not_t) {
15190                boolean isLhsSubquery = (rootExtractable == onCond.getLeftOperand());
15191                boolean nonSubquerySideOk = isLhsSubquery
15192                        ? isAdmittedOuterRhsShape(onCond.getRightOperand())
15193                        : isAdmittedOuterLhsShape(onCond.getLeftOperand());
15194                if (!nonSubquerySideOk) {
15195                    throw new SemanticIRBuildException(
15196                            PredicateSubqueryDiagnostics.unsupported(
15197                                    ctx.outerShapeRejected,
15198                                    subqueryContext,
15199                                    buildOuterShapeRejectionReason(
15200                                            onCond, isLhsSubquery),
15201                                    isLhsSubquery
15202                                            ? onCond.getRightOperand()
15203                                            : onCond.getLeftOperand()));
15204                }
15205            }
15206            extractOnePredicateSubqueryBody(onCond, rootExtractable, provider, stmts, lineage,
15207                    cteMapForExtraction, subqueryContext, correlationScope, semiFactsOut,
15208                    outerRelationAliases);
15209            extractedRoots.add(rootExtractable);
15210        }
15211        // Descendant walk: find every wrapper at any depth. Skip into
15212        // already-extracted subtrees (so we don't re-enter the body
15213        // looking for nested wrappers — covered by the inner-shape
15214        // preflight's "no nested predicate subqueries in body"
15215        // rejection).
15216        onCond.acceptChildren(new TParseTreeVisitor() {
15217            // Track depth into already-extracted roots and into wrapper
15218            // subtrees we've extracted. preVisit increments on the
15219            // wrapper (the parent that contained the inner extractable);
15220            // postVisit decrements on either the inner extractable
15221            // (extractedRoots.contains) or the wrapper
15222            // (unwrapToInnerExtractableSubquery != null). The
15223            // {@code skipDepth > 0} guard prevents the second
15224            // decrement from going negative when both apply (e.g. NOT
15225            // EXISTS — both the logical_not_t wrapper and the inner
15226            // exists_t fire).
15227            int skipDepth = 0;
15228
15229            @Override
15230            public void preVisit(TExpression e) {
15231                if (skipDepth > 0) return;
15232                if (extractedRoots.contains(e)) {
15233                    // Already-extracted inner being re-visited shouldn't
15234                    // happen in normal traversal but defensive guard
15235                    // avoids double-extraction if it ever did.
15236                    skipDepth++;
15237                    return;
15238                }
15239                // Slice 25 (impl-review M1-fix): explicit reject for
15240                // {@code logical_not_t} over a slice-25 wrapper at any
15241                // depth. Without this, the visitor would descend into
15242                // the wrapper child and silently extract — admitting
15243                // a shape (`NOT (a IN (SELECT ...))`) that the
15244                // slice-25 boundary does NOT admit.
15245                if (isLogicalNotOverNonExistsWrapper(e)) {
15246                    throw new SemanticIRBuildException(
15247                            Diagnostic.error(ctx.predicateNotNot,
15248                            "predicate subquery in " + ctx.clauseLabel + ": NOT applied to "
15249                                    + "a non-EXISTS predicate subquery wrapper "
15250                                    + "(" + e.getRightOperand().getExpressionType()
15251                                    + ") is not supported yet — the slice-25 "
15252                                    + "boundary admits NOT only over EXISTS; "
15253                                    + "rewrite e.g. NOT (a IN (SELECT ...)) as "
15254                                    + "a NOT IN (SELECT ...)", e));
15255                }
15256                TExpression toExtract = unwrapToInnerExtractableSubquery(e);
15257                if (toExtract != null) {
15258                    PredicateSubqueryContext subqueryContext =
15259                            PredicateSubqueryContext.from(e, toExtract, ctx);
15260                    // M1-fix + slice-26 dual-side: validate the non-
15261                    // subquery side BEFORE extracting (so partial
15262                    // extraction never lands). The slice-23/24
15263                    // NOT-EXISTS path uses logical_not_t, which has no
15264                    // outer-shape gate.
15265                    if (e.getExpressionType() != EExpressionType.exists_t
15266                            && e.getExpressionType() != EExpressionType.logical_not_t) {
15267                        boolean isLhsSubquery = (toExtract == e.getLeftOperand());
15268                        boolean nonSubquerySideOk = isLhsSubquery
15269                                ? isAdmittedOuterRhsShape(e.getRightOperand())
15270                                : isAdmittedOuterLhsShape(e.getLeftOperand());
15271                        if (!nonSubquerySideOk) {
15272                            throw new SemanticIRBuildException(
15273                                    PredicateSubqueryDiagnostics.unsupported(
15274                                            ctx.outerShapeRejected,
15275                                            subqueryContext,
15276                                            buildOuterShapeRejectionReason(
15277                                                    e, isLhsSubquery),
15278                                            isLhsSubquery
15279                                                    ? e.getRightOperand()
15280                                                    : e.getLeftOperand()));
15281                        }
15282                    }
15283                    if (extractedRoots.contains(toExtract)) return;
15284                    extractOnePredicateSubqueryBody(e, toExtract, provider, stmts, lineage,
15285                            cteMapForExtraction, subqueryContext, correlationScope, semiFactsOut,
15286                            outerRelationAliases);
15287                    extractedRoots.add(toExtract);
15288                    skipDepth++;
15289                }
15290            }
15291
15292            @Override
15293            public void postVisit(TExpression e) {
15294                // M2-fix: decrement on EITHER the extracted inner
15295                // (extractedRoots.contains) OR the wrapper
15296                // (unwrapToInnerExtractableSubquery != null). The
15297                // {@code skipDepth > 0} guard prevents going negative
15298                // when both apply.
15299                if (skipDepth > 0
15300                        && (extractedRoots.contains(e)
15301                                || unwrapToInnerExtractableSubquery(e) != null)) {
15302                    skipDepth--;
15303                }
15304            }
15305        });
15306        return extractedRoots;
15307    }
15308
15309    /**
15310     * R8 — a predicate-derived semi-join discovered during WHERE
15311     * predicate-subquery extraction. Carries the polarity (semi vs
15312     * anti-semi), the lifted inner block's statement index + label, the
15313     * optional outer relation alias (for the left endpoint), the
15314     * correlated outer↔inner column conditions (may be empty), and the
15315     * wrapper's source span / verbatim text. Converted to a
15316     * {@link JoinEntity} at JoinGraph-assembly time (which has the outer
15317     * relation list to resolve the left endpoint's qualified name).
15318     */
15319    private static final class SemiJoinFact {
15320        final SemanticJoinType polarity;
15321        final int innerStatementIndex;
15322        final String innerLabel;
15323        // Distinct outer relation aliases referenced by the correlation
15324        // (insertion order). Empty for an uncorrelated subquery. When a
15325        // single alias, the left endpoint is that relation; when several,
15326        // the left endpoint is the accumulated outer rowset (codex R8).
15327        final List<String> outerAliases;
15328        final List<Predicate> conditions; // never null; may be empty
15329        final SourceSpan span;            // nullable
15330        final String conditionText;       // nullable
15331        /**
15332         * Degrade only (GitHub #708): the body was never analyzed, so WHICH
15333         * outer relation the subquery correlates to is unknown — as opposed to
15334         * known-to-be-none, which is what an empty {@link #outerAliases} means
15335         * on the normal path. Anchors the left endpoint to the whole outer
15336         * rowset instead of letting the single-relation fallback name one.
15337         */
15338        final boolean outerAnchorUnknown;
15339
15340        SemiJoinFact(SemanticJoinType polarity, int innerStatementIndex,
15341                     String innerLabel, List<String> outerAliases,
15342                     List<Predicate> conditions, SourceSpan span,
15343                     String conditionText) {
15344            this(polarity, innerStatementIndex, innerLabel, outerAliases,
15345                    conditions, span, conditionText, /*outerAnchorUnknown=*/ false);
15346        }
15347
15348        SemiJoinFact(SemanticJoinType polarity, int innerStatementIndex,
15349                     String innerLabel, List<String> outerAliases,
15350                     List<Predicate> conditions, SourceSpan span,
15351                     String conditionText, boolean outerAnchorUnknown) {
15352            this.outerAnchorUnknown = outerAnchorUnknown;
15353            this.polarity = polarity;
15354            this.innerStatementIndex = innerStatementIndex;
15355            this.innerLabel = innerLabel;
15356            this.outerAliases = outerAliases == null
15357                    ? Collections.<String>emptyList() : outerAliases;
15358            this.conditions = conditions == null
15359                    ? Collections.<Predicate>emptyList() : conditions;
15360            this.span = span;
15361            this.conditionText = conditionText;
15362        }
15363    }
15364
15365    /**
15366     * R8 — semi-join polarity for a predicate wrapper. {@code EXISTS} /
15367     * {@code IN} → {@link SemanticJoinType#SEMI}; {@code NOT EXISTS}
15368     * (logical_not over exists) / {@code NOT IN} ({@code in_t} with a NOT
15369     * token) → {@link SemanticJoinType#ANTI_SEMI}. Returns {@code null}
15370     * for wrappers that are not modelled as semi-joins (scalar comparison,
15371     * ANY/ALL/SOME) so the caller emits no semi-join fact for them.
15372     */
15373    private static SemanticJoinType semiJoinPolarity(TExpression wrapper) {
15374        if (wrapper == null) return null;
15375        EExpressionType t = wrapper.getExpressionType();
15376        if (t == EExpressionType.logical_not_t) {
15377            // NOT EXISTS — the slice-25 boundary admits NOT only over EXISTS.
15378            TExpression inner = wrapper.getRightOperand();
15379            if (inner != null && inner.getExpressionType() == EExpressionType.exists_t) {
15380                return SemanticJoinType.ANTI_SEMI;
15381            }
15382            return null;
15383        }
15384        if (t == EExpressionType.exists_t) {
15385            return SemanticJoinType.SEMI;
15386        }
15387        if (t == EExpressionType.in_t) {
15388            return wrapper.getNotToken() != null
15389                    ? SemanticJoinType.ANTI_SEMI : SemanticJoinType.SEMI;
15390        }
15391        return null;
15392    }
15393
15394    /**
15395     * R8 — build the {@link SemiJoinFact} for an extracted predicate
15396     * subquery, or {@code null} when the wrapper is not a semi-join shape
15397     * ({@link #semiJoinPolarity} null) or no collector is requested.
15398     *
15399     * <p>Correlation pairing:
15400     * <ul>
15401     *   <li>{@code IN}: outer column = the non-subquery side of the
15402     *       {@code in_t}; inner column = the subquery's first output
15403     *       column source.</li>
15404     *   <li>{@code EXISTS} / {@code NOT EXISTS}: outer↔inner pairs are
15405     *       lifted from equi-conjuncts in the inner WHERE that compare an
15406     *       inner-local column to an outer (non-local) column.</li>
15407     * </ul>
15408     */
15409    private static SemiJoinFact buildSemiJoinFact(TExpression wrapper,
15410                                                  TExpression extractableNode,
15411                                                  TSelectSqlStatement inner,
15412                                                  StatementGraph innerStmt,
15413                                                  int innerIndex, String innerLabel,
15414                                                  Set<String> innerLocalAliases,
15415                                                  NameBindingProvider provider) {
15416        SemanticJoinType polarity = semiJoinPolarity(wrapper);
15417        if (polarity == null) return null;
15418        List<Predicate> conditions = new ArrayList<>();
15419        // Distinct outer aliases, insertion-ordered (codex R8: drives the
15420        // single-relation-vs-joined-rowset left-endpoint choice).
15421        java.util.LinkedHashSet<String> outerAliases = new java.util.LinkedHashSet<>();
15422        if (wrapper.getExpressionType() == EExpressionType.in_t) {
15423            // Membership equality: outer side (the non-subquery operand) =
15424            // the subquery's first output column. Both must be bare columns.
15425            boolean lhsIsSubquery = (extractableNode == wrapper.getLeftOperand());
15426            TExpression outerSide = lhsIsSubquery
15427                    ? wrapper.getRightOperand() : wrapper.getLeftOperand();
15428            ColumnRef outerCol = bareColumnRefOf(outerSide, provider);
15429            // codex R8 [P2 round 2]: the inner membership column must ALSO
15430            // be a bare column. `a.id IN (SELECT b.id + 1 FROM b)` projects
15431            // a compound expression — emitting `a.id = b.id` would be a
15432            // fabricated equality, so omit the membership pair there.
15433            ColumnRef innerCol = isFirstProjectionBareColumn(inner)
15434                    ? firstOutputColumnRefOf(innerStmt) : null;
15435            // codex R8 [P2 round 3]: the inner membership column must be a
15436            // genuine inner-local column. `a.id IN (SELECT a.id FROM t2 b)`
15437            // projects an OUTER column — emitting `a.id = a.id` would
15438            // misrepresent the predicate and not connect the right endpoint.
15439            if (outerCol != null && innerCol != null
15440                    && isLocalAlias(innerCol, innerLocalAliases)) {
15441                addOuterAlias(outerAliases, outerCol);
15442                conditions.add(equiPredicate(outerCol, innerCol,
15443                        SourceSpan.of(wrapper)));
15444            }
15445            // codex R8 [P1]: a correlated IN (e.g. `a.id IN (SELECT b.id
15446            // FROM b WHERE b.k = a.k)`) also carries correlation in the
15447            // inner WHERE — fold those pairs in too so they are not lost.
15448            for (ColumnRef[] pair : extractCorrelationPairs(inner,
15449                    innerLocalAliases, provider)) {
15450                addOuterAlias(outerAliases, pair[0]);
15451                conditions.add(equiPredicate(pair[0], pair[1], null));
15452            }
15453        } else {
15454            // EXISTS / NOT EXISTS — correlation lives in the inner WHERE.
15455            for (ColumnRef[] pair : extractCorrelationPairs(inner,
15456                    innerLocalAliases, provider)) {
15457                addOuterAlias(outerAliases, pair[0]);
15458                conditions.add(equiPredicate(pair[0], pair[1], null));
15459            }
15460        }
15461        return new SemiJoinFact(polarity, innerIndex, innerLabel,
15462                new ArrayList<>(outerAliases),
15463                conditions, SourceSpan.of(wrapper), verbatimText(wrapper));
15464    }
15465
15466    private static void addOuterAlias(java.util.LinkedHashSet<String> aliases,
15467                                      ColumnRef outerCol) {
15468        if (outerCol != null && outerCol.getRelationAlias() != null
15469                && !outerCol.getRelationAlias().isEmpty()) {
15470            aliases.add(outerCol.getRelationAlias());
15471        }
15472    }
15473
15474    /**
15475     * R8 — the resolved {@link ColumnRef} for an expression that is a
15476     * <em>bare, qualified column reference</em> ({@code simple_object_name_t}
15477     * with a relation alias), or {@code null} otherwise. Strict by design
15478     * (codex R8 review): a compound operand such as {@code b.k + 1} or
15479     * {@code lower(a.n)} returns {@code null} so we never synthesise a
15480     * misleading EQUI column predicate from a side that is not a plain
15481     * column (the pair is omitted instead).
15482     */
15483    private static ColumnRef bareColumnRefOf(TExpression expr,
15484                                             NameBindingProvider provider) {
15485        if (expr == null
15486                || expr.getExpressionType() != EExpressionType.simple_object_name_t) {
15487            return null;
15488        }
15489        try {
15490            List<ColumnRef> refs = collectColumnRefs(expr, provider);
15491            for (ColumnRef r : refs) {
15492                if (r != null && r.getRelationAlias() != null
15493                        && !r.getRelationAlias().isEmpty()) {
15494                    return r;
15495                }
15496            }
15497        } catch (RuntimeException ignore) {
15498            // Tolerant: an un-extractable column ref just yields no column.
15499        }
15500        return null;
15501    }
15502
15503    /**
15504     * R8 — true iff the inner SELECT's first projection is a bare column
15505     * reference ({@code simple_object_name_t}). Guards the IN membership
15506     * equality so a compound projection ({@code SELECT b.id + 1 ...}) does
15507     * not produce a fabricated bare-column equality.
15508     */
15509    private static boolean isFirstProjectionBareColumn(TSelectSqlStatement inner) {
15510        if (inner == null || inner.getResultColumnList() == null
15511                || inner.getResultColumnList().size() == 0) {
15512            return false;
15513        }
15514        TResultColumn rc = inner.getResultColumnList().getResultColumn(0);
15515        if (rc == null || rc.getExpr() == null) {
15516            return false;
15517        }
15518        return rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t;
15519    }
15520
15521    /** R8 — the first source {@link ColumnRef} of a block's first output column. */
15522    private static ColumnRef firstOutputColumnRefOf(StatementGraph innerStmt) {
15523        if (innerStmt == null) return null;
15524        List<OutputColumn> outs = innerStmt.getOutputColumns();
15525        if (outs == null || outs.isEmpty()) return null;
15526        List<ColumnRef> sources = outs.get(0).getSources();
15527        if (sources == null || sources.isEmpty()) return null;
15528        return sources.get(0);
15529    }
15530
15531    /** R8 — synthesise an EQUI {@link Predicate} from two column refs. */
15532    private static Predicate equiPredicate(ColumnRef left, ColumnRef right,
15533                                           SourceSpan span) {
15534        return new Predicate(PredicateKind.EQUI, "=",
15535                PredicateOperand.column(left, null),
15536                PredicateOperand.column(right, null), span);
15537    }
15538
15539    /**
15540     * R8 — lift outer↔inner correlation pairs from a correlated EXISTS
15541     * inner WHERE. Walks the top-level AND conjuncts iteratively (the
15542     * left-leaning binary tree of {@code logical_and_t} — never recurse,
15543     * per the project StackOverflow guard) and, for each {@code =}
15544     * comparison whose two sides are one inner-local column and one
15545     * outer (non-local) column, emits the pair {@code [outerCol, innerCol]}.
15546     * Returns an empty list for an uncorrelated EXISTS.
15547     */
15548    private static List<ColumnRef[]> extractCorrelationPairs(
15549            TSelectSqlStatement inner, Set<String> innerLocalAliases,
15550            NameBindingProvider provider) {
15551        List<ColumnRef[]> out = new ArrayList<>();
15552        if (inner == null || inner.getWhereClause() == null
15553                || inner.getWhereClause().getCondition() == null) {
15554            return out;
15555        }
15556        // Iterative DFS over AND-conjuncts (left-leaning tree).
15557        Deque<TExpression> stack = new ArrayDeque<>();
15558        stack.push(inner.getWhereClause().getCondition());
15559        int guard = 0;
15560        while (!stack.isEmpty() && guard++ < 100000) {
15561            TExpression e = stack.pop();
15562            if (e == null) continue;
15563            if (e.getExpressionType() == EExpressionType.logical_and_t) {
15564                if (e.getRightOperand() != null) stack.push(e.getRightOperand());
15565                if (e.getLeftOperand() != null) stack.push(e.getLeftOperand());
15566                continue;
15567            }
15568            if (e.getExpressionType() != EExpressionType.simple_comparison_t) {
15569                continue;
15570            }
15571            String op = e.getOperatorToken() != null
15572                    ? e.getOperatorToken().toString() : null;
15573            if (op == null || !"=".equals(op)) continue;
15574            ColumnRef l = bareColumnRefOf(e.getLeftOperand(), provider);
15575            ColumnRef r = bareColumnRefOf(e.getRightOperand(), provider);
15576            if (l == null || r == null) continue;
15577            boolean lLocal = isLocalAlias(l, innerLocalAliases);
15578            boolean rLocal = isLocalAlias(r, innerLocalAliases);
15579            // Exactly one side local → the other is the outer correlation.
15580            if (lLocal && !rLocal) {
15581                out.add(new ColumnRef[]{ r, l }); // [outer, inner]
15582            } else if (rLocal && !lLocal) {
15583                out.add(new ColumnRef[]{ l, r });
15584            }
15585        }
15586        return out;
15587    }
15588
15589    private static boolean isLocalAlias(ColumnRef ref, Set<String> innerLocalAliases) {
15590        return ref.getRelationAlias() != null
15591                && innerLocalAliases.contains(
15592                        ref.getRelationAlias().toLowerCase(Locale.ROOT));
15593    }
15594
15595    /**
15596     * R8 — true iff {@code alias} names a relation in the host SELECT's FROM
15597     * list ({@code outerRelationAliases}, lower-cased). Gates the
15598     * correlated-EXISTS degrade so only a genuine outer-relation correlation
15599     * degrades; an alias that is neither inner-local nor a known outer
15600     * relation (a typo) still rejects.
15601     */
15602    private static boolean isKnownOuterAlias(String alias,
15603                                             Set<String> outerRelationAliases) {
15604        return alias != null && outerRelationAliases != null
15605                && outerRelationAliases.contains(alias.toLowerCase(Locale.ROOT));
15606    }
15607
15608    /**
15609     * Slice 25 (rename of slice-23 {@code extractOneExistsBody}):
15610     * extract a single predicate-subquery body's inner SELECT as its
15611     * own {@code <predicate_subquery_<i>>} StatementGraph. Runs the
15612     * inner-shape preflight before recursive build, then post-build
15613     * correlation check.
15614     *
15615     * <p>{@code extractableNode} is either an {@code exists_t}
15616     * (slice-23 EXISTS / slice-24 column-bearing EXISTS) or a
15617     * {@code subquery_t} (slice-25 IN-SELECT / scalar comparison /
15618     * ANY-ALL-SOME). Both expose the inner SELECT via
15619     * {@link TExpression#getSubQuery()}.
15620     *
15621     * <p>R8 — {@code wrapper} is the predicate wrapper containing
15622     * {@code extractableNode} ({@code exists_t} / {@code logical_not_t}
15623     * over exists / {@code in_t} / comparison). When {@code semiFactsOut}
15624     * is non-null and the wrapper is an EXISTS/IN shape, a
15625     * {@link SemiJoinFact} is appended for the JoinGraph. When
15626     * {@code correlationScope} is null a correlated EXISTS now DEGRADES
15627     * (records the correlation, does not throw) instead of failing with
15628     * {@code *_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS} (R8 degrade).
15629     */
15630    private static int extractOnePredicateSubqueryBody(TExpression wrapper,
15631                                                        TExpression extractableNode,
15632                                                        NameBindingProvider provider,
15633                                                        List<StatementGraph> stmts,
15634                                                        List<LineageEdge> lineage,
15635                                                        Map<String, Integer> cteMapForExtraction,
15636                                                        PredicateSubqueryContext context,
15637                                                        EnclosingScope correlationScope,
15638                                                        List<SemiJoinFact> semiFactsOut,
15639                                                        Set<String> outerRelationAliases) {
15640        if (!activeOptions().isDegradeUnsupportedNestedBlocks()) {
15641            return extractOnePredicateSubqueryBodyStrict(wrapper, extractableNode, provider,
15642                    stmts, lineage, cteMapForExtraction, context, correlationScope,
15643                    semiFactsOut, outerRelationAliases);
15644        }
15645        // Degrade mode. Mark every list the strict extractor appends to, so a
15646        // reject thrown midway (e.g. inside emitLineageForStatement, after the
15647        // body was already appended) cannot leave a half-built body behind.
15648        // `cteMapForExtraction` is not marked: the predicate body is built with
15649        // null extraction maps and registers no CTE of its own.
15650        final int stmtsMark = stmts.size();
15651        final int lineageMark = lineage.size();
15652        final int semiFactsMark = (semiFactsOut == null) ? 0 : semiFactsOut.size();
15653        try {
15654            return extractOnePredicateSubqueryBodyStrict(wrapper, extractableNode, provider,
15655                    stmts, lineage, cteMapForExtraction, context, correlationScope,
15656                    semiFactsOut, outerRelationAliases);
15657        } catch (SemanticIRBuildException e) {
15658            // Recovery is a capability policy, not an error-suppression
15659            // policy. Invalid syntax/dialect semantics must reach the caller
15660            // as the original ERROR instead of becoming a successful opaque
15661            // block with only NESTED_BLOCK_UNANALYZED.
15662            if (!DiagnosticDescriptorCatalog
15663                    .isUnsupportedNestedBlockRecoverable(e.getDiagnostics())) {
15664                throw e;
15665            }
15666            truncateTo(stmts, stmtsMark);
15667            truncateTo(lineage, lineageMark);
15668            if (semiFactsOut != null) {
15669                truncateTo(semiFactsOut, semiFactsMark);
15670            }
15671            return emitUnanalyzedPredicateBody(wrapper, extractableNode,
15672                    e.getDiagnostic(), stmts, semiFactsOut);
15673        }
15674    }
15675
15676    /** Drop everything appended to {@code list} past {@code mark}. */
15677    private static void truncateTo(List<?> list, int mark) {
15678        while (list.size() > mark) {
15679            list.remove(list.size() - 1);
15680        }
15681    }
15682
15683    /**
15684     * Degrade path — stand a {@link StatementGraph#KIND_UNANALYZED}
15685     * placeholder in for a predicate-subquery body the builder rejected, and
15686     * record the event as a {@link DiagnosticCode#NESTED_BLOCK_UNANALYZED}
15687     * warning.
15688     *
15689     * <p>This is sound precisely because a predicate body is unreachable from
15690     * its host block: no host relation names it and no lineage edge targets
15691     * it (see the "UNREACHABLE from outer" note in
15692     * {@link #extractOnePredicateSubqueryBodyStrict}). Dropping the body's own
15693     * facts therefore removes nothing the host block publishes — every fact
15694     * still returned was proved the same way it is proved today.
15695     *
15696     * <p>What IS lost is the body's own analysis plus the semi-join fact that
15697     * would have linked it to the host. That loss is explicit, not silent:
15698     * the placeholder occupies the body's slot with its synthetic name, its
15699     * kind reads {@code UNANALYZED} rather than {@code SELECT}, and it carries
15700     * the original ERROR diagnostic — anchored, when the reject site had no
15701     * span of its own, at the rejected inner block.
15702     *
15703     * @return the index of the placeholder in {@code stmts}
15704     */
15705    private static int emitUnanalyzedPredicateBody(TExpression wrapper,
15706                                                   TExpression extractableNode,
15707                                                   Diagnostic originalReason,
15708                                                   List<StatementGraph> stmts,
15709                                                   List<SemiJoinFact> semiFactsOut) {
15710        Diagnostic reason = originalReason;
15711        SourceSpan span = reason.getSpan();
15712        if (span == null) {
15713            // The reject site had no anchor. Fall back to the inner block
15714            // itself, then to the predicate wrapper node, so the caller can
15715            // still point at the offending text (the reporter's ask on
15716            // MantisBT 4676 / GitHub #708).
15717            TSelectSqlStatement inner = extractableNode.getSubQuery();
15718            span = SourceSpan.of(inner);
15719            if (span == null) {
15720                span = SourceSpan.of(extractableNode);
15721            }
15722            // Re-stamp the reason with the fallback anchor so
15723            // getUnanalyzedReason().getSpan() points at the skipped block even
15724            // when the reject site itself had nothing to anchor to. Code,
15725            // message and ERROR severity are preserved verbatim.
15726            if (span != null) {
15727                reason = reason.withSpan(span);
15728            }
15729        }
15730        int idx = stmts.size();
15731        String name = PREDICATE_BODY_PREFIX + idx + ">";
15732        recordBuildWarning(Diagnostic.warnWithSpan(
15733                DiagnosticCode.NESTED_BLOCK_UNANALYZED,
15734                "predicate subquery body '" + name + "' was not analyzed: "
15735                        + reason.getMessage(), span));
15736        stmts.add(StatementGraph.unanalyzed(name, reason, span));
15737        // Keep the semi-join. Its POLARITY is a property of the wrapper alone
15738        // ({@code EXISTS}/{@code IN} -> SEMI, {@code NOT EXISTS}/{@code NOT IN}
15739        // -> ANTI_SEMI): {@link #semiJoinPolarity} reads only `wrapper` and
15740        // never touches the body, so "the host has a semi-join against block
15741        // N" stays provable even though the body did not analyze. Dropping it
15742        // would delete a fact we can prove — the one thing degrade mode must
15743        // never do — and would leave the host JoinGraph claiming the EXISTS
15744        // simply is not there.
15745        //
15746        // The entity is deliberately OPAQUE: empty conditions and empty
15747        // outerAliases. Both are unknowable without the body, and empty
15748        // outerAliases routes {@link #outerEndpointFor} through the same
15749        // first-FROM-relation fallback an uncorrelated EXISTS already uses.
15750        // No predicate and no correlation pair is invented; the right endpoint
15751        // is the placeholder block, whose UNANALYZED kind tells the consumer
15752        // the other side was not analyzed.
15753        if (semiFactsOut != null) {
15754            SemanticJoinType polarity = semiJoinPolarity(wrapper);
15755            if (polarity != null) {
15756                semiFactsOut.add(new SemiJoinFact(polarity, idx, name,
15757                        Collections.<String>emptyList(),
15758                        Collections.<Predicate>emptyList(),
15759                        SourceSpan.of(wrapper),
15760                        // The wrapper is fully parsed, so its lexical text is
15761                        // provable even though its structure is not. Keep it —
15762                        // the supported path records the same thing.
15763                        verbatimText(wrapper),
15764                        /*outerAnchorUnknown=*/ true));
15765            }
15766        }
15767        return idx;
15768    }
15769
15770    private static int extractOnePredicateSubqueryBodyStrict(TExpression wrapper,
15771                                                        TExpression extractableNode,
15772                                                        NameBindingProvider provider,
15773                                                        List<StatementGraph> stmts,
15774                                                        List<LineageEdge> lineage,
15775                                                        Map<String, Integer> cteMapForExtraction,
15776                                                        PredicateSubqueryContext context,
15777                                                        EnclosingScope correlationScope,
15778                                                        List<SemiJoinFact> semiFactsOut,
15779                                                        Set<String> outerRelationAliases) {
15780        PredicateClauseContext ctx = context.clause;
15781        TSelectSqlStatement inner = extractableNode.getSubQuery();
15782        if (inner == null) {
15783            // Degenerate node with no subquery; defensive.
15784            throw new SemanticIRBuildException(
15785                    PredicateSubqueryDiagnostics.unsupported(
15786                            ctx.existsBodyMissing, context,
15787                            "subquery body is missing", wrapper));
15788        }
15789        // (a–g) Inner-shape preflight (slice-23 boundary; slice 24 widens
15790        // (e) to admit single column-ref projection in addition to constant).
15791        preflightPredicateSubqueryShape(inner, context);
15792
15793        // Slice 118 — when an enclosing correlation scope is supplied
15794        // (MERGE per-WHEN action WHERE only), decorate `provider` with
15795        // tolerant outer binding so the inner build admits qualified refs
15796        // to outer aliases (target / USING source / outer CTEs) as
15797        // synthetic EXACT_MATCH bindings instead of rejecting them as
15798        // COLUMN_BINDING_NON_EXACT. Mirrors the slice-117 pattern for
15799        // UPDATE SET-RHS correlated scalars. Computed BEFORE
15800        // buildSelectStatementImpl so the inner build's bindColumn calls
15801        // see the tolerant fallback already populated (codex round-5
15802        // ordering fix from slice 117). Qualifiers IN the inner's local
15803        // FROM aliases still strict-reject so real typos (`o.bad_col`
15804        // where `o` IS the inner FROM alias) still surface as
15805        // COLUMN_BINDING_NON_EXACT.
15806        final NameBindingProvider effectiveProvider;
15807        if (correlationScope != null) {
15808            Set<String> innerLocalAliasesForTolerant =
15809                    precomputeInnerLocalAliases(inner);
15810            effectiveProvider = innerLocalAliasesForTolerant.isEmpty()
15811                    ? provider
15812                    : provider.withTolerantOuterBinding(
15813                            innerLocalAliasesForTolerant);
15814        } else {
15815            effectiveProvider = provider;
15816        }
15817
15818        // Build the inner SELECT as its own StatementGraph. SAME provider
15819        // as outer (codex round-1 MUST 3 — outer CTEs remain visible).
15820        // Slice 118: tolerant-decorated provider when correlationScope
15821        // != null (MERGE per-WHEN action WHERE only); same provider as
15822        // before otherwise.
15823        // hasOuterCteListAlreadyProcessed=false (codex round-2 SHOULD 1 —
15824        // generic nested-WITH guard remains active as belt-and-braces).
15825        // allowFromSubqueries=false (no FROM-subqueries in inner body for
15826        // slice 23). isPredicateBody=true: for constant-only inner emits one
15827        // synthetic OutputColumn (slice-23 path); for column-ref inner the
15828        // §4.1.2 short-circuit falls through to the normal column-ref path
15829        // (slice-24 widening).
15830        String predName = PREDICATE_BODY_PREFIX + stmts.size() + ">";
15831        StatementGraph innerStmt;
15832        if (isPivotSelect(inner)) {
15833            // Slice 141: a nested PIVOT / UNPIVOT in a predicate subquery body
15834            // (`WHERE k IN (SELECT ... PIVOT(...))` / EXISTS / scalar-comparison
15835            // / ANY-ALL-SOME). The slice-129 pivot router in
15836            // {@link #buildSelectStatementImpl} is gated to the OUTER SELECT
15837            // context (`name == null && !isPredicateBody`); the predicate body
15838            // carries `isPredicateBody=true` and the synthetic
15839            // `<predicate_subquery_N>` name, so the gate misses and the body
15840            // would otherwise fall to the normal path which can't bind a
15841            // `pivoted_table` (rejects with the misleading
15842            // TABLE_BINDING_UNRESOLVED "null(piviot_table)"). Route it to
15843            // {@link #buildPivotSelect} with the synthetic predicate-body name
15844            // so the body becomes a proper pivot StatementGraph.
15845            //
15846            // PIVOT extra-clause (WHERE/GROUP BY/etc.) / chained / malformed-
15847            // UNPIVOT / bare-`*`-without-catalog rejects stay deferred via
15848            // {@code buildPivotSelect}'s own guards. A bare `SELECT *` over a
15849            // pivot in a predicate body is rejected EARLIER by
15850            // {@link #preflightPredicateSubqueryShape}'s slice-23 / 24 single-column
15851            // contract — the preflight {@code "*"} check fires before this
15852            // routing site is reached. PIVOT-then-JOIN stays on the normal
15853            // path because {@code isPivotSelect} requires
15854            // `items.size() == 0`. A subquery-source pivot in a predicate body
15855            // is rejected by the slice-24 belt-and-braces relation-kind walk
15856            // BELOW (admits TABLE / CTE only) via the existing
15857            // {@code ctx.existsInnerRelationUnknown} code.
15858            //
15859            // Direct analogue of slice 138 ({@link #processDirectSubqueryTable}
15860            // FROM-subquery body), slice 139 (the four CTE walkers), and
15861            // slice 140 ({@link #buildSetOpProgramInternal} set-op branch).
15862            // This is the SIXTH (and final) SemanticIRBuilder routing site
15863            // that previously missed the pivot router.
15864            //
15865            // Slice-23 contract preserved: the predicate body remains
15866            // UNREACHABLE from outer (no outer relation points at it; no
15867            // STATEMENT_OUTPUT edge from outer to the body). The body's own
15868            // {@link #emitLineageForStatement} call BELOW emits the pivot
15869            // output -> source edges (TABLE_COLUMN for base-table source,
15870            // STATEMENT_OUTPUT for CTE source when the body sits inside an
15871            // outer-WITH). For correlation purposes (MERGE per-WHEN action
15872            // WHERE — slice 118), the pivot body's outputs / consumed-refs are
15873            // built directly against the LOCAL source alias by
15874            // {@code buildPivotSelect}; any outer-alias ref inside the inner
15875            // would surface via the post-build correlation walk below (the
15876            // walk traverses `innerStmt`'s clause-ref slots, which for a pivot
15877            // body are exactly the pivot's local TABLE / CTE relation).
15878            innerStmt = buildPivotSelect(inner, effectiveProvider, predName);
15879        } else {
15880            innerStmt = buildSelectStatementImpl(inner, effectiveProvider, predName,
15881                /*hasOuterCteListAlreadyProcessed=*/ false,
15882                /*allowFromSubqueries=*/ false,
15883                /*allowScalarProjectionSubqueries=*/ false,
15884                /*allowWindowProjection=*/ false,
15885                /*allowJoinOnPredicateSubqueries=*/ false,
15886                /*stmtsForExtraction=*/ null,
15887                /*lineageForExtraction=*/ null,
15888                /*cteMapForExtraction=*/ null,
15889                /*isPredicateBody=*/ true,
15890                /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE,
15891                /*allowWherePredicateSubqueries=*/ false);
15892        }
15893
15894        // Slice 24 (codex impl-review SHOULD 1): defensive relation-kind
15895        // walk. The preflight rejects FROM-subqueries; the post-build
15896        // correlation check below rejects OUTER_REFERENCE relations
15897        // (synthesised by promoteCorrelatedRefsToOuterReference for
15898        // outer refs we don't see). Belt-and-braces: the predicate body
15899        // must contain ONLY TABLE or CTE-bound relations. SUBQUERY /
15900        // OUTER_REFERENCE / UNION leaking through here would mean the
15901        // emitLineageForStatement call below routes through code paths
15902        // (e.g. the SUBQUERY-alias map) that we deliberately pass empty,
15903        // producing a SemanticIRBuildException about an unregistered
15904        // alias. Failing fast here surfaces the architectural violation
15905        // with a slice-24-tuned message instead.
15906        for (RelationSource r : innerStmt.getRelations()) {
15907            RelationKind kind = r.getBinding().getKind();
15908            if (kind != RelationKind.TABLE && kind != RelationKind.CTE) {
15909                throw new SemanticIRBuildException(
15910                        PredicateSubqueryDiagnostics.unsupported(
15911                                ctx.existsInnerRelationUnknown,
15912                                context,
15913                                "inner SELECT relation '"
15914                                + r.getAlias() + "' has unsupported binding kind "
15915                                + kind + "; only TABLE or CTE relations are admitted "
15916                                + "(slice 24 boundary)",
15917                                inner));
15918            }
15919        }
15920        // Post-build correlation check (codex round-1 MUST 2 + round-2 SHOULD 2).
15921        // Use the existing collectAllInnerRefs helper so clause coverage
15922        // stays in sync with promoteCorrelatedRefsToOuterReference.
15923        // Slice 24: collectAllInnerRefs includes OutputColumn.sources, so
15924        // a column-ref projection like `EXISTS (SELECT e.id FROM x)` where
15925        // `e` is the OUTER's alias trips the same correlation rejection —
15926        // no extra slice-24 code needed.
15927        //
15928        // Slice 118 — when correlationScope != null (MERGE per-WHEN action
15929        // WHERE only), instead of REJECTING outer-aliased refs we PROMOTE
15930        // them into synthesised OUTER_REFERENCE relations via
15931        // promoteCorrelatedRefsToOuterReference. Mirrors the slice-14 /
15932        // slice-117 pattern. Unknown outer aliases (not in target / USING
15933        // source / outer CTEs) still throw SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS
15934        // (the promoter's existing boundary; diagnostic message says
15935        // "scalar subquery" — acceptable cosmetic limitation, slice 117
15936        // precedent). The slice-118 lift covers refs landing in
15937        // collectAllInnerRefs clauses (output sources, filter, join,
15938        // groupBy, having, orderBy, distinctOn); the FILTER and WITHIN
15939        // GROUP walks BELOW remain active so outer-aliased refs hidden
15940        // inside FILTER subtrees or PG fn.withinGroup.orderBy still
15941        // reject with SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS
15942        // (codex round-1 Q2 BLOCKING preserved this boundary).
15943        Set<String> innerLocalAliases = new HashSet<>();
15944        for (RelationSource r : innerStmt.getRelations()) {
15945            innerLocalAliases.add(r.getAlias().toLowerCase(Locale.ROOT));
15946        }
15947        // R8 — DEGRADE GATE. A catalog-less correlated EXISTS / IN in a
15948        // top-level SELECT WHERE now analyzes (the correlation is surfaced
15949        // on the SEMI / ANTI_SEMI join entity) instead of failing with
15950        // *_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS. The degrade is narrowly
15951        // scoped: only SELECT_WHERE, only non-MERGE (correlationScope ==
15952        // null), only genuine EXISTS/IN semi-join shapes. JOIN-ON, scalar
15953        // comparison, ANY/ALL, FROM-subquery / scalar / set-op-branch /
15954        // CTE bodies, UPDATE/DELETE WHERE, and MERGE all keep the prior
15955        // correlation reject.
15956        boolean degradeCorrelated = correlationScope == null
15957                && ctx == PredicateClauseContext.SELECT_WHERE
15958                && semiJoinPolarity(wrapper) != null;
15959        if (correlationScope != null) {
15960            // Pass a descriptive "outerAlias" so the promoter's diagnostic
15961            // messages identify the MERGE predicate-body host context if
15962            // promotion fails on an unknown alias.
15963            innerStmt = promoteCorrelatedRefsToOuterReference(
15964                    innerStmt,
15965                    "<merge predicate subquery " + (stmts.size()) + ">",
15966                    correlationScope);
15967        } else {
15968            for (ColumnRef ref : collectAllInnerRefs(innerStmt)) {
15969                if (!innerLocalAliases.contains(ref.getRelationAlias().toLowerCase(Locale.ROOT))) {
15970                    if (degradeCorrelated
15971                            && isKnownOuterAlias(ref.getRelationAlias(), outerRelationAliases)) {
15972                        // A genuine correlation to an outer FROM relation:
15973                        // the ref stays in the inner block's filterColumnRefs
15974                        // and the outer↔inner pairing is surfaced on the
15975                        // semi-join entity via buildSemiJoinFact. No throw.
15976                        // codex R8 [P1 round 2]: an alias that is NEITHER
15977                        // inner-local NOR a known outer relation (a typo like
15978                        // `z`) still rejects below.
15979                        continue;
15980                    }
15981                    throw new SemanticIRBuildException(
15982                            PredicateSubqueryDiagnostics.unsupportedWithSpan(
15983                                    ctx.existsCorrelatedUnknownOuterAlias,
15984                                    context,
15985                                    "correlated reference to outer alias '"
15986                                    + ref.getRelationAlias()
15987                                    + "' is not supported yet (this context accepts "
15988                                    + "uncorrelated predicate subqueries only)",
15989                                    ref.getSourceSpan()));
15990                }
15991            }
15992        }
15993        // Slice 28: projection-only FILTER-aware correlation walk. The
15994        // slice-28 source-skip in buildOutputColumns removes column refs
15995        // inside FILTER (WHERE ...) subtrees from OutputColumn.sources, so
15996        // a correlated FILTER ref in the inner projection (e.g.
15997        // `EXISTS (SELECT SUM(x.s) FILTER (WHERE e.region='EU') FROM x)`
15998        // where `e` is the outer alias) would slip past the loop above.
15999        // Existing collectAllInnerRefs continues to cover correlated
16000        // FILTER refs landing in inner WHERE / HAVING / GROUP BY / ORDER BY
16001        // / JOIN-ON because those clauses still collect via plain
16002        // collectColumnRefs which descends into FILTER subtrees.
16003        TResultColumnList rclForFilterWalk = inner.getResultColumnList();
16004        if (rclForFilterWalk != null) {
16005            for (int rci = 0; rci < rclForFilterWalk.size(); rci++) {
16006                TResultColumn rc = rclForFilterWalk.getResultColumn(rci);
16007                Set<TExpression> filterClauses = collectFilterClauses(rc);
16008                for (TExpression fclause : filterClauses) {
16009                    // Slice 118 — use effectiveProvider so under
16010                    // correlationScope != null, outer-aliased refs come
16011                    // back as synthetic EXACT_MATCH ColumnRefs (rather
16012                    // than throwing on tolerant fallback being absent).
16013                    // The alias-membership rejection below then fires
16014                    // for outer-aliased FILTER-inner refs, preserving
16015                    // the slice-118 boundary (codex round-1 Q2 BLOCKING
16016                    // fix: FILTER-inner correlation still rejects).
16017                    for (ColumnRef ref : collectColumnRefs(fclause, effectiveProvider)) {
16018                        boolean nonLocal = !innerLocalAliases.contains(
16019                                ref.getRelationAlias().toLowerCase(Locale.ROOT));
16020                        boolean degradeThisRef = degradeCorrelated
16021                                && isKnownOuterAlias(ref.getRelationAlias(),
16022                                        outerRelationAliases);
16023                        if (nonLocal && !degradeThisRef) {
16024                            // R8: the SELECT_WHERE semi-join degrade skips a
16025                            // genuine outer-relation correlation here; every
16026                            // other context (and unknown aliases) still reject.
16027                            throw new SemanticIRBuildException(
16028                                    PredicateSubqueryDiagnostics.unsupportedWithSpan(
16029                                            ctx.existsCorrelatedUnknownOuterAlias,
16030                                            context,
16031                                            "correlated reference to outer alias '"
16032                                            + ref.getRelationAlias()
16033                                            + "' inside FILTER (WHERE ...) is not supported yet",
16034                                            ref.getSourceSpan() != null
16035                                                    ? ref.getSourceSpan()
16036                                                    : SourceSpan.of(fclause)));
16037                        }
16038                    }
16039                }
16040            }
16041        }
16042        // Slice 30: projection-only direct WITHIN GROUP ORDER BY correlation
16043        // walk. PostgreSQL attaches WITHIN GROUP to fn.withinGroup directly,
16044        // and TFunctionCall.acceptChildren does NOT descend into that field —
16045        // so collectColumnRefs (and therefore collectAllInnerRefs above) is
16046        // blind to outer references inside `fn.withinGroup.orderBy`. A
16047        // correlated reference like
16048        // `mode() WITHIN GROUP (ORDER BY e.region)` (where `e` is the
16049        // outer alias) would slip past the slice-23 correlation loop above.
16050        // Catch it explicitly with a per-result-column WG ORDER BY scan.
16051        // Mirrors the slice-28 FILTER walk pattern. Also closes the same
16052        // correlation gap retroactively for slice-29-admitted aggregates
16053        // (LISTAGG / STRING_AGG / GROUP_CONCAT / ARRAY_AGG WITHIN GROUP),
16054        // see Slice30Test.pgCorrelatedListaggWithinGroupOrderByNowAlsoRejected.
16055        //
16056        // IMPORTANT: this walk uses a qualifier-only collector
16057        // ({@link #collectQualifierAliases}) instead of
16058        // {@link #collectColumnRefs}. Resolver2 also doesn't attach
16059        // ResolutionResult to TObjectName nodes inside PG's direct
16060        // fn.withinGroup field (it shares the AST asymmetry that lets
16061        // slice 29 admit these without a source-skip). Going through
16062        // {@code collectColumnRefs} → {@code provider.bindColumn} would
16063        // throw {@code non-exact column bindings} on legitimate
16064        // non-correlated refs (status=NOT_FOUND because Resolver2 skipped
16065        // them). The qualifier-only collector reads the qualifier alias
16066        // straight off the TObjectName, which matches slice-23's
16067        // correlation invariant: only qualified refs that name an outer
16068        // alias are caught — unqualified refs remain a documented
16069        // schema-less limitation.
16070        TResultColumnList rclForWgWalk = inner.getResultColumnList();
16071        if (rclForWgWalk != null) {
16072            for (int rci = 0; rci < rclForWgWalk.size(); rci++) {
16073                TResultColumn rc = rclForWgWalk.getResultColumn(rci);
16074                Set<TOrderBy> wgOrderBys = collectDirectWithinGroupOrderBys(rc);
16075                for (TOrderBy wgOrderBy : wgOrderBys) {
16076                    for (String alias : collectQualifierAliases(wgOrderBy)) {
16077                        boolean nonLocal = !innerLocalAliases.contains(
16078                                alias.toLowerCase(Locale.ROOT));
16079                        boolean degradeThisRef = degradeCorrelated
16080                                && isKnownOuterAlias(alias, outerRelationAliases);
16081                        if (nonLocal && !degradeThisRef) {
16082                            // R8: the SELECT_WHERE semi-join degrade skips a
16083                            // genuine outer-relation correlation here.
16084                            throw new SemanticIRBuildException(
16085                                    PredicateSubqueryDiagnostics.unsupported(
16086                                            ctx.existsCorrelatedUnknownOuterAlias,
16087                                            context,
16088                                            "correlated reference to outer alias '"
16089                                            + alias
16090                                            + "' inside WITHIN GROUP (ORDER BY ...) is not supported yet",
16091                                            wgOrderBy));
16092                        }
16093                    }
16094                }
16095            }
16096        }
16097        int idx = stmts.size();
16098        stmts.add(innerStmt);
16099        // Slice 24: emit lineage edges for the predicate body. For
16100        // constant-only inner (slice-23 carryover), the synthetic
16101        // OutputColumn has empty sources and emitLineageForStatement
16102        // emits zero edges — no shape change for slice 23. For
16103        // column-ref inner (slice 24), the real OutputColumn carries
16104        // one ColumnRef source pointing at the inner's local relation;
16105        // emitLineageForStatement emits a STATEMENT_OUTPUT → TABLE_COLUMN
16106        // edge (TABLE-bound inner) or STATEMENT_OUTPUT → STATEMENT_OUTPUT
16107        // edge (CTE-bound inner) that the projector's slice-24 pass uses
16108        // to resolve the JOIN canonical edge.
16109        //
16110        // SUBQUERY map is empty: inner-shape preflight rejects FROM-subqueries.
16111        // ScalarInfo map is empty: inner-shape preflight rejects scalar
16112        // projections (and column-ref projection is single-source, not a
16113        // scalar-subquery extraction).
16114        // Slice 118 — pass the enclosing scope's flattened SUBQUERY-alias
16115        // map under correlation mode so OUTER_REFERENCE-of-SUBQUERY refs
16116        // resolve to the enclosing MERGE's USING-subquery statement index
16117        // for cross-stmt lineage emission (mirrors slice 117 UPDATE-side
16118        // emit dispatch).
16119        Map<String, Integer> subqueryAliasMap = (correlationScope != null)
16120                ? correlationScope.flattenSubqueryAliasToIndex()
16121                : Collections.<String, Integer>emptyMap();
16122        emitLineageForStatement(innerStmt, idx, lineage,
16123                cteMapForExtraction,
16124                subqueryAliasMap,
16125                Collections.<Integer, ScalarInfo>emptyMap());
16126        // The predicate body remains UNREACHABLE from outer: no relation
16127        // in outer points at it, and no STATEMENT_OUTPUT lineage edge has
16128        // it as its `to`. Inner WHERE / inner JOIN refs of the predicate
16129        // body therefore cannot enter outer's row-influence walker. The
16130        // slice-24 projector pass iterates predicate bodies directly via
16131        // `isPredicateSubquerySyntheticName` to emit JOIN canonical edges
16132        // from their OutputColumn sources only (slice-24 §4.2.1).
16133        //
16134        // R8 — record the semi-join fact (EXISTS/IN → SEMI, NOT EXISTS/NOT
16135        // IN → ANTI_SEMI) so JoinGraph assembly can emit a JoinEntity
16136        // linking the outer relation to this lifted subquery block.
16137        if (semiFactsOut != null) {
16138            SemiJoinFact fact = buildSemiJoinFact(wrapper, extractableNode, inner,
16139                    innerStmt, idx, innerStmt.getName(), innerLocalAliases,
16140                    effectiveProvider);
16141            if (fact != null) {
16142                semiFactsOut.add(fact);
16143            }
16144        }
16145        return idx;
16146    }
16147
16148    /**
16149     * Shared inner-shape preflight for every recognised predicate-subquery
16150     * wrapper. The context preserves the original wrapper and host clause
16151     * while this method validates the common lowered body shape.
16152     */
16153    private static void preflightPredicateSubqueryShape(
16154            TSelectSqlStatement inner,
16155            PredicateSubqueryContext context) {
16156        // (a) No set-op
16157        if (inner.getSetOperatorType() != null
16158                && inner.getSetOperatorType() != ESetOperatorType.none) {
16159            throw new SemanticIRBuildException(
16160                    PredicateSubqueryDiagnostics.unsupported(
16161                            DiagnosticCode.JOIN_ON_EXISTS_INNER_IS_SET_OP,
16162                            context,
16163                            "inner SELECT may not be a set operation",
16164                            inner));
16165        }
16166        // (b) No nested CTE list
16167        if (inner.getCteList() != null && inner.getCteList().size() > 0) {
16168            throw new SemanticIRBuildException(
16169                    PredicateSubqueryDiagnostics.unsupported(
16170                            DiagnosticCode.JOIN_ON_EXISTS_INNER_WITH,
16171                            context,
16172                            "inner SELECT may not have its own WITH clause",
16173                            inner.getCteList()));
16174        }
16175        // (c) No row-limit (delegated to rejectUnsupportedShape's row-limit
16176        //     guards which fire during buildSelectStatement). For an early,
16177        //     specific message we also fast-fail here.
16178        if (inner.getLimitClause() != null
16179                || inner.getTopClause() != null
16180                || inner.getFetchFirstClause() != null
16181                || inner.getOffsetClause() != null) {
16182            throw new SemanticIRBuildException(
16183                    PredicateSubqueryDiagnostics.unsupported(
16184                            DiagnosticCode.JOIN_ON_EXISTS_INNER_ROW_LIMIT,
16185                            context,
16186                            "inner SELECT may not have a row-limit clause",
16187                            predicateRowLimitAnchor(inner)));
16188        }
16189        // (d) Inner FROM is required (codex round-2 MUST 2).
16190        if (inner.joins == null || inner.joins.size() == 0) {
16191            throw new SemanticIRBuildException(
16192                    PredicateSubqueryDiagnostics.unsupported(
16193                            DiagnosticCode.JOIN_ON_EXISTS_INNER_MISSING_FROM,
16194                            context,
16195                            "inner SELECT must have a FROM clause "
16196                                    + "(degenerate predicate subquery SELECT is not in scope)",
16197                            inner));
16198        }
16199        // Slice 182 (GitHub #707 / MantisBT 4676): a comma-separated FROM
16200        // list in the predicate body is now built as an ordered
16201        // cross-product relation graph, exactly as the semantically
16202        // identical `FROM a JOIN b ON …` already was. The slice-62
16203        // predicate-body reject that used to live here (and its
16204        // {@link DiagnosticCode#JOIN_ON_EXISTS_INNER_COMMA_FROM} code) is
16205        // gone; the body's shape contract is enforced by the
16206        // single-result-column check in (e) below, which is independent
16207        // of the FROM relation count.
16208        // Slice 63: predicate body must not contain explicit CROSS JOIN
16209        // either. Surfaces a predicate-body-tuned diagnostic before the
16210        // gated reject inside buildRelations would fire with the
16211        // generic "scalar / set-op-branch / set-op-CTE / predicate"
16212        // message. The same shared preflight is used by EXISTS /
16213        // IN-SELECT / cmp-subquery / ANY-ALL-SOME wrappers.
16214        // Slice 64: same treatment for JOIN ... USING.
16215        for (TJoin j : inner.joins) {
16216            TJoinItemList items = j.getJoinItems();
16217            if (items == null) continue;
16218            for (int i = 0; i < items.size(); i++) {
16219                TJoinItem item = items.getJoinItem(i);
16220                if (item == null) continue;
16221                if (item.getJoinType() == EJoinType.cross) {
16222                    throw new SemanticIRBuildException(
16223                            PredicateSubqueryDiagnostics.unsupported(
16224                                    DiagnosticCode.JOIN_ON_EXISTS_INNER_CROSS_JOIN,
16225                                    context,
16226                                    "CROSS JOIN in inner SELECT is not supported yet",
16227                                    item));
16228                }
16229                if (item.getUsingColumns() != null
16230                        && item.getUsingColumns().size() > 0) {
16231                    throw new SemanticIRBuildException(
16232                            PredicateSubqueryDiagnostics.unsupported(
16233                                    DiagnosticCode.JOIN_ON_EXISTS_INNER_USING,
16234                                    context,
16235                                    "JOIN ... USING (...) in inner SELECT is not supported yet",
16236                                    item));
16237                }
16238                // Slice 66: NATURAL JOIN inside a predicate-subquery body
16239                // is rejected with a wrapper-aware diagnostic. The
16240                // gated reject inside buildRelations would fire later
16241                // with the generic body-context message; surfacing here
16242                // gives users an EXISTS / IN-SELECT / cmp-subquery
16243                // friendly error.
16244                if (isNaturalJoinType(item.getJoinType())) {
16245                    throw new SemanticIRBuildException(
16246                            PredicateSubqueryDiagnostics.unsupported(
16247                                    DiagnosticCode.JOIN_ON_EXISTS_INNER_NATURAL,
16248                                    context,
16249                                    "NATURAL JOIN in inner SELECT is not supported yet",
16250                                    item));
16251                }
16252            }
16253        }
16254        // (d') No FROM-subquery on inner FROM/JOIN list. The recursive build
16255        //     passes allowFromSubqueries=false, so buildRelation would also
16256        //     reject; we surface a slice-23 specific message here.
16257        for (TJoin j : inner.joins) {
16258            if (j.getTable() != null
16259                    && j.getTable().getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
16260                throw new SemanticIRBuildException(
16261                        PredicateSubqueryDiagnostics.unsupported(
16262                                DiagnosticCode.JOIN_ON_EXISTS_INNER_FROM_SUBQUERY,
16263                                context,
16264                                "FROM-clause subquery in inner SELECT is not supported yet",
16265                                j.getTable()));
16266            }
16267            TJoinItemList items = j.getJoinItems();
16268            if (items == null) continue;
16269            for (int i = 0; i < items.size(); i++) {
16270                TTable r = items.getJoinItem(i).getTable();
16271                if (r != null && r.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
16272                    throw new SemanticIRBuildException(
16273                            PredicateSubqueryDiagnostics.unsupported(
16274                                    DiagnosticCode.JOIN_ON_EXISTS_INNER_FROM_SUBQUERY_ON_JOIN,
16275                                    context,
16276                                    "FROM-clause subquery on JOIN side in inner SELECT is not supported yet",
16277                                    r));
16278                }
16279            }
16280        }
16281        // (e) Result-column list: exactly one column projecting either a
16282        //     constant expression (slice 23), a single column reference
16283        //     (slice 24), an expression / function call / CASE /
16284        //     aggregate over inner columns (slice 27), an aggregate with
16285        //     FILTER (WHERE ...) over inner columns (slice 28), or — on
16286        //     PostgreSQL only — a whitelisted WITHIN GROUP aggregate
16287        //     (slice 29 admits LISTAGG / STRING_AGG / GROUP_CONCAT /
16288        //     ARRAY_AGG / count / sum / avg / min / max / stddev /
16289        //     variance family; slice 30 extends with `mode`).
16290        //     Multi-column / star / window function / scalar subquery /
16291        //     non-whitelisted WITHIN GROUP aggregate projections are
16292        //     rejected with shape-specific tuned messages — see
16293        //     {@link #findUnsupportedWithinGroupFunctionName} for the
16294        //     vendor + name gate.
16295        TResultColumnList rcl = inner.getResultColumnList();
16296        if (rcl == null || rcl.size() != 1) {
16297            throw new SemanticIRBuildException(
16298                    PredicateSubqueryDiagnostics.unsupported(
16299                            DiagnosticCode.JOIN_ON_EXISTS_INNER_COLUMN_COUNT,
16300                            context,
16301                            "inner SELECT must project exactly one column, got "
16302                                    + (rcl == null ? 0 : rcl.size()),
16303                            rcl == null ? inner : rcl));
16304        }
16305        TResultColumn rc0 = rcl.getResultColumn(0);
16306        if ("*".equals(rc0.getColumnNameOnly())) {
16307            throw new SemanticIRBuildException(
16308                    PredicateSubqueryDiagnostics.unsupported(
16309                            DiagnosticCode.JOIN_ON_EXISTS_INNER_NON_CONSTANT_PROJECTION,
16310                            context,
16311                            "inner SELECT must project a constant expression "
16312                                    + "or a single column reference, got SELECT *",
16313                            rc0));
16314        }
16315        TExpression projExpr = rc0.getExpr();
16316        if (projExpr == null || !isAdmittedPredicateProjection(projExpr)) {
16317            throw new SemanticIRBuildException(
16318                    PredicateSubqueryDiagnostics.unsupported(
16319                            DiagnosticCode.JOIN_ON_EXISTS_INNER_NON_CONSTANT_PROJECTION,
16320                            context,
16321                            "inner SELECT must project a constant expression "
16322                            + "(e.g. SELECT 1), a single column reference "
16323                            + "(e.g. SELECT x.id), an expression / function call / "
16324                            + "CASE / aggregate over inner columns (e.g. SELECT x.id + 1, "
16325                            + "UPPER(x.region), MAX(x.id), CASE WHEN ...), an aggregate "
16326                            + "with FILTER (WHERE ...) over inner columns "
16327                            + "(e.g. SUM(x.id) FILTER (WHERE x.region = 'EU')), or a "
16328                            + "WITHIN GROUP (ORDER BY ...) aggregate over inner columns "
16329                            + "(PostgreSQL admits the direct fn.withinGroup attachment; "
16330                            + "Oracle and SQL Server admit the windowDef.withinGroup "
16331                            + "attachment via slice 31 when no OVER clause is present "
16332                            + "— see TWindowDef.isIncludingOverClause(); slice 44 also "
16333                            + "admits Snowflake hypothetical-set ordered-set aggregates "
16334                            + "(rank / dense_rank / percent_rank / cume_dist) via direct "
16335                            + "fn.withinGroup attachment); DB2 / Snowflake LISTAGG / "
16336                            + "STRING_AGG WITHIN GROUP remain rejected pending a probe "
16337                            + "of their parser-specific argument storage; window "
16338                            + "functions (any OVER-bearing form) and scalar subqueries "
16339                            + "are not supported yet (slice 31 boundary)",
16340                            projExpr == null ? rc0 : projExpr));
16341        }
16342        // Slice 29 / Slice 31: vendor-gated WITHIN GROUP rejecter.
16343        //
16344        // Two attachment styles, gated by vendor:
16345        //   * Direct attachment ({@code fn.getWithinGroup()}): PG admits
16346        //     because its visitor descent (TFunctionCall.acceptChildren)
16347        //     does NOT walk fn.withinGroup, leaving OutputColumn.sources
16348        //     populated exactly with the function's column-bearing args.
16349        //     Snowflake and DB2 use the same field but their parser-
16350        //     specific arg storage (DB2's stringExpr / separatorExpr for
16351        //     LISTAGG) may not be visitor-visible — silently-empty
16352        //     sources while dlineage walks fdd to the base column =
16353        //     manufactured IR_MISSING_DEPENDENCY divergence; rejected.
16354        //   * WindowDef attachment ({@code fn.getWindowDef().getWithinGroup()}
16355        //     with WITHIN-GROUP-only windowDef): Oracle / MSSQL admit via
16356        //     slice 31. The visitor DOES descend through
16357        //     {@code windowDef.withinGroup.orderBy}, so the slice-31
16358        //     source-skip in
16359        //     {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}
16360        //     keeps OutputColumn.sources from leaking the WITHIN GROUP
16361        //     ORDER BY column refs (probe Q1 / Q3 / Q4 / Q5 in
16362        //     {@code /tmp/probe31}).
16363        //
16364        // Probed: PG (Q1, Q5, Q6, Q9, Q10), Oracle (Q1-Q5 in
16365        // {@code /tmp/probe31}), MSSQL (Q11-Q12), SparkSQL (parser drops
16366        // WITHIN GROUP attachment, so containsAggregateWithWithinGroup
16367        // returns false and the lift applies).
16368        if (containsAggregateWithWithinGroup(projExpr)) {
16369            EDbVendor v = inner.dbvendor;
16370            // Slice 44 / 45: Snowflake admitted at this gate ONLY when
16371            // every WITHIN GROUP-bearing call in the inner projection
16372            // is an admitted Snowflake direct-attachment shape:
16373            //   * hypothetical-set (rank / dense_rank / percent_rank /
16374            //     cume_dist with fn.getWithinGroup()!=null and
16375            //     fn.getWindowDef()==null) — slice 44; or
16376            //   * mode() with the same direct-attachment shape — slice 45.
16377            // Snowflake LISTAGG / STRING_AGG / percentile_cont /
16378            // percentile_disc share the direct-attachment shape but
16379            // their parser-specific argument storage (stringExpr /
16380            // separatorExpr) and / or name-whitelist exclusion keep
16381            // the slice-31/44 rejection (see Slice44Test §C and
16382            // Slice45Test §C boundary tests).
16383            boolean snowflakeAdmittedShape = (v == EDbVendor.dbvsnowflake)
16384                    && allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment(projExpr);
16385            if (v != EDbVendor.dbvpostgresql
16386                    && v != EDbVendor.dbvoracle
16387                    && v != EDbVendor.dbvmssql
16388                    && !snowflakeAdmittedShape) {
16389                throw new SemanticIRBuildException(
16390                        PredicateSubqueryDiagnostics.unsupported(
16391                                DiagnosticCode.JOIN_ON_EXISTS_WITHIN_GROUP_AGGREGATE,
16392                                context,
16393                                "WITHIN GROUP aggregate inner projection on vendor=" + v
16394                                + " is not supported yet — slice 31 admits PostgreSQL "
16395                                + "(direct fn.withinGroup attachment), Oracle, and "
16396                                + "SQL Server (windowDef.withinGroup attachment with "
16397                                + "isIncludingOverClause=false); slice 44 additionally "
16398                                + "admits Snowflake hypothetical-set ordered-set "
16399                                + "aggregates (rank / dense_rank / percent_rank / "
16400                                + "cume_dist) via direct fn.withinGroup attachment; "
16401                                + "slice 45 additionally admits Snowflake mode() via "
16402                                + "the same direct attachment; "
16403                                + "DB2 / Snowflake LISTAGG / STRING_AGG / "
16404                                + "percentile_cont / other direct-attachment vendors "
16405                                + "remain rejected pending a probe of their parser-"
16406                                + "specific argument storage",
16407                                projExpr));
16408            }
16409            // Codex impl-review round-3 MUST: name-whitelist guard. The PG
16410            // parser attaches WITHIN GROUP to generic `func_application`,
16411            // not only to whitelisted aggregate names. Without this check,
16412            // a non-whitelisted call like `foo(x.id) WITHIN GROUP (...)`
16413            // would slip through. Slice 31: same protection applies on
16414            // Oracle / MSSQL — the windowDef-attachment grammar admits
16415            // any function name (PERCENTILE_CONT, RANK, user-defined
16416            // foo); the name guard rejects them so the IR never sees a
16417            // shape whose canonical model is unverified.
16418            String unsupportedName = findUnsupportedWithinGroupFunctionName(
16419                    projExpr, inner.dbvendor);
16420            if (unsupportedName != null) {
16421                throw new SemanticIRBuildException(
16422                        PredicateSubqueryDiagnostics.unsupported(
16423                                DiagnosticCode.JOIN_ON_EXISTS_WITHIN_GROUP_NON_WHITELISTED,
16424                                context,
16425                                "WITHIN GROUP attached to non-whitelisted "
16426                                + "function '" + unsupportedName + "' is not supported yet — "
16427                                + "slice 31 admits whitelisted aggregates only "
16428                                + "(see SemanticIRBuilder.AGGREGATE_FUNCTION_NAMES); "
16429                                + "slice 43 additionally admits PostgreSQL hypothetical-set "
16430                                + "ordered-set aggregates (rank / dense_rank / percent_rank / "
16431                                + "cume_dist) via direct fn.getWithinGroup attachment",
16432                                projExpr));
16433            }
16434        }
16435        // (f) No subqueries in inner WHERE / inner JOIN ON / inner GROUP BY /
16436        //     inner HAVING / inner ORDER BY. Reuses the slice-11 helper
16437        //     style with a slice-23-specific message prefix.
16438        rejectSubqueriesInPredicateBodyClauses(inner, context);
16439        // (g) Window functions are caught by the rejecters that fire inside
16440        //     buildSelectStatement (rejectWindowFunctionInScope on WHERE /
16441        //     GROUP BY / HAVING / ORDER BY); the inner projection itself is
16442        //     a constant expression and cannot contain a window call.
16443    }
16444
16445    private static TParseTreeNode predicateRowLimitAnchor(TSelectSqlStatement inner) {
16446        if (inner.getLimitClause() != null) return inner.getLimitClause();
16447        if (inner.getTopClause() != null) return inner.getTopClause();
16448        if (inner.getFetchFirstClause() != null) return inner.getFetchFirstClause();
16449        return inner.getOffsetClause();
16450    }
16451
16452    /**
16453     * Slice 27: true iff {@code e} is an admitted predicate-subquery inner
16454     * projection shape. Admits (in priority order):
16455     * <ul>
16456     *   <li>{@link EExpressionType#simple_object_name_t} — single column
16457     *       ref (slice 24 carryover); one JOIN canonical edge per inner-
16458     *       column lineage terminal.</li>
16459     *   <li>{@link #isConstantExpression}-shaped constant (slice-23
16460     *       carryover); zero canonical contribution.</li>
16461     *   <li>Slice 27 widenings via {@link #isAdmittedSlice27ShapeRoot}:
16462     *       expression / function call / CASE / aggregate over inner
16463     *       columns. Probes 27 / 27b confirmed dlineage's
16464     *       {@code fdr clause="on"} canonical model walks fdd to the
16465     *       underlying base columns identically to the IR's
16466     *       slice-24 predicate-body sweep — so canonical equivalence
16467     *       holds. Aggregate-over-constants (e.g. {@code COUNT(*)},
16468     *       {@code SUM(1)}) produce empty {@code OutputColumn.sources}
16469     *       and zero predicate-body JOIN edges; canonical-equivalent
16470     *       to the slice-23 constant projection.</li>
16471     * </ul>
16472     * Hard rejecters fire BEFORE the {@link #isAdmittedSlice27ShapeRoot}
16473     * admit-list to keep the surface tight:
16474     * <ul>
16475     *   <li>{@link #containsAnySubqueryExpression} — slice-23 invariant.</li>
16476     *   <li>{@link #containsWindowFunction} — slice-13 invariant.
16477     *       Slice 31 narrowed the rejecter via {@link #isWindowDefBearingFunction}
16478     *       so a WITHIN-GROUP-only windowDef (Oracle / MSSQL plain
16479     *       WITHIN GROUP attachment) is NOT classified as a window
16480     *       function. Real OVER-bearing windowDef shapes ({@code OVER ()},
16481     *       {@code OVER (PARTITION BY ...)}, KEEP DENSE_RANK) continue to
16482     *       fire the rejecter. The complementary slice-31 source-skip in
16483     *       {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}
16484     *       removes the WITHIN GROUP ORDER BY column refs from
16485     *       {@code OutputColumn.sources} on Oracle / MSSQL so the visitor's
16486     *       descent through {@code windowDef.withinGroup.orderBy} doesn't
16487     *       leak into projection sources.</li>
16488     *   <li>Slice 29 / 31's vendor-gated WITHIN GROUP rejecter at the
16489     *       {@link #preflightPredicateSubqueryShape} call site — see that
16490     *       method's vendor gate. Slice 31 admits Oracle and MSSQL
16491     *       (windowDef.withinGroup attachment with
16492     *       {@code !isIncludingOverClause()}) alongside PostgreSQL.
16493     *       Snowflake and DB2 both attach {@code WITHIN GROUP} to the
16494     *       direct {@code fn.getWithinGroup()} field (same as PG), but
16495     *       their parser-specific argument storage may not be
16496     *       visitor-visible (DB2 stores LISTAGG args in
16497     *       {@code stringExpr} / {@code separatorExpr}, which
16498     *       {@code TFunctionCall.acceptChildren} does NOT walk); they
16499     *       remain rejected. SparkSQL silently drops the WITHIN GROUP
16500     *       attachment at parse time (both {@code fn.withinGroup} and
16501     *       {@code fn.windowDef} are null); after slice 29 SparkSQL
16502     *       admits the same shape as PG, parity-friendly per probe Q1
16503     *       SparkSQL.</li>
16504     * </ul>
16505     *
16506     * <p>Slice 28 lifted the prior {@code containsAggregateWithFilter}
16507     * rejecter; FILTER aggregates are now admitted, with the FILTER
16508     * predicate column refs excluded from {@code OutputColumn.sources}
16509     * globally via the FILTER-aware variant of {@link #collectColumnRefs}
16510     * used in {@link #buildOutputColumns}. See the slice-28 entry in
16511     * §14.5 of the unified roadmap and §B / §C of the slice history
16512     * archive for the load-bearing decision.
16513     *
16514     * <p>Slice 29 lifted the prior unconditional
16515     * {@code containsAggregateWithWithinGroup} rejecter and replaced it
16516     * with a vendor-gated rejecter at the
16517     * {@link #preflightPredicateSubqueryShape} call site (Snowflake / DB2 /
16518     * other non-PostgreSQL vendors that use the direct
16519     * {@code fn.getWithinGroup()} attachment remain rejected). PG
16520     * attaches {@code WITHIN GROUP} to the direct
16521     * {@code fn.getWithinGroup()} field, and
16522     * {@code TFunctionCall.acceptChildren} does NOT descend into that
16523     * field, so {@link #collectColumnRefs} never picks up the ORDER BY
16524     * column refs — no source-skip is needed. dlineage probes Q1–Q10
16525     * confirmed canonical-model JOIN-on edges include only the aggregate's
16526     * primary argument across all four vendors (the WITHIN GROUP ORDER
16527     * BY ref appears as {@code fdr clauseType="orderby"} on PG only, and
16528     * {@code DlineageXmlProjector.projectColumn} follows fdd not fdr).
16529     * Slice 29 is restricted to whitelisted aggregates whose names
16530     * appear in {@link #AGGREGATE_FUNCTION_NAMES}. As of slice 30 the
16531     * whitelist is: {@code count}, {@code sum}, {@code avg}, {@code min},
16532     * {@code max}, {@code stddev}, {@code variance}, {@code var_samp},
16533     * {@code var_pop}, {@code stddev_samp}, {@code stddev_pop},
16534     * {@code listagg}, {@code string_agg}, {@code group_concat},
16535     * {@code array_agg}, {@code mode} (slice-30 addition — PG
16536     * ordered-set aggregate, gated for the WITHIN GROUP path only;
16537     * see {@code DlineageXmlProjector.ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES}). The predicate-body short-circuit's
16538     * {@code aggregate=true} branch fires for these regardless of
16539     * {@code OutputColumn.sources} content — column-bearing args
16540     * (e.g. {@code LISTAGG(x.id, ',')}) produce a synthesized
16541     * {@code OutputColumn} with {@code sources=[x.id]} that the slice-24
16542     * sweep walks to base-column terminals, while literal-only args
16543     * (e.g. {@code LISTAGG('hello', ',')}) produce {@code sources=[]}
16544     * with zero JOIN canonical edges — canonically equivalent to
16545     * slice-23's constant projection.
16546     *
16547     * <p>Functions NOT in the whitelist (which on PG includes
16548     * {@code percentile_cont}, {@code percentile_disc}, {@code rank},
16549     * {@code dense_rank}, {@code percent_rank}, {@code cume_dist}, plus
16550     * any user-defined function with a direct {@code fn.withinGroup}
16551     * attachment) remain rejected by the
16552     * {@link #findUnsupportedWithinGroupFunctionName} guard at the
16553     * {@link #preflightPredicateSubqueryShape} call site. Slice 30 lifted
16554     * {@code mode} only — the one PG ordered-set aggregate with no
16555     * documented window form in any GSP-supported vendor. Lifting
16556     * {@code percentile_cont} / {@code percentile_disc} requires either
16557     * a vendor-scoped projector OR a structural discriminator strong
16558     * enough to distinguish the cross-vendor windowed forms (Redshift /
16559     * Vertica / BigQuery / Oracle / SQL Server emit
16560     * {@code PERCENTILE_CONT WITHIN GROUP OVER (...)} variants). Lifting
16561     * {@code rank}/{@code dense_rank}/{@code percent_rank}/{@code cume_dist}
16562     * requires distinguishing window form {@code RANK() OVER (ORDER BY)}
16563     * from hypothetical-set form {@code rank(0.5) WITHIN GROUP (ORDER BY)}
16564     * — dlineage XML for the two is structurally identical on PG. See
16565     * §14.6 of the unified roadmap.
16566     */
16567    private static boolean isAdmittedPredicateProjection(TExpression e) {
16568        if (e == null) return false;
16569        if (e.getExpressionType() == EExpressionType.simple_object_name_t) {
16570            return true;                                          // slice 24 (column ref)
16571        }
16572        if (isConstantExpression(e)) return true;                 // slice 23 (constant)
16573        // Slice 27: hard rejecters before admit-list. Slice 28 lifted the
16574        // FILTER rejecter; slice 29 replaced the unconditional WITHIN
16575        // GROUP rejecter with a vendor-gated rejecter at the
16576        // preflightPredicateSubqueryShape call site (see slice-29 §3.2).
16577        if (containsAnySubqueryExpression(e)) return false;       // slice 23 invariant
16578        if (containsWindowFunction(e)) return false;              // slice 13 invariant
16579        return isAdmittedSlice27ShapeRoot(e);
16580    }
16581
16582    /**
16583     * Slice 29: detect a {@code WITHIN GROUP (ORDER BY ...)} attachment
16584     * on the direct {@code fn.getWithinGroup()} field anywhere in the
16585     * subtree. This is the PG / Snowflake / DB2 attachment style. Used
16586     * as a vendor-gated rejecter in {@link #preflightPredicateSubqueryShape}:
16587     * non-admitted vendors with this attachment remain rejected because
16588     * their parser-specific argument storage may not be visitor-visible
16589     * (DB2's {@code LISTAGG} stores args in {@code stringExpr} /
16590     * {@code separatorExpr}, which the default
16591     * {@code TFunctionCall.acceptChildren} does NOT walk —
16592     * {@code OutputColumn.sources} would be silently empty while dlineage
16593     * walks fdd to the base column, manufacturing
16594     * {@code IR_MISSING_DEPENDENCY} divergence).
16595     *
16596     * <p>Slice 31: also detects WITHIN GROUP attached to
16597     * {@code fn.getWindowDef().getWithinGroup()} when the windowDef is
16598     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} — the
16599     * Oracle / MSSQL attachment style. Both attachments are routed
16600     * through {@link #hasWithinGroupAnyAttachment}.
16601     */
16602    private static boolean containsAggregateWithWithinGroup(TExpression e) {
16603        if (e == null) return false;
16604        final boolean[] found = {false};
16605        e.acceptChildren(new TParseTreeVisitor() {
16606            @Override
16607            public void preVisit(TFunctionCall fn) {
16608                if (found[0]) return;
16609                if (hasWithinGroupAnyAttachment(fn)) found[0] = true;
16610            }
16611        });
16612        if (!found[0] && e.getExpressionType() == EExpressionType.function_t) {
16613            TFunctionCall fn = e.getFunctionCall();
16614            if (hasWithinGroupAnyAttachment(fn)) found[0] = true;
16615        }
16616        return found[0];
16617    }
16618
16619    /**
16620     * Slice 31: shared predicate used by {@link #containsAggregateWithWithinGroup}
16621     * and {@link #findUnsupportedWithinGroupFunctionName}. Returns
16622     * {@code true} iff {@code fn} carries {@code WITHIN GROUP} via
16623     * either:
16624     * <ul>
16625     *   <li>direct {@code fn.getWithinGroup()} field (PG / Snowflake /
16626     *       DB2 / SparkSQL parser style);</li>
16627     *   <li>{@code fn.getWindowDef().getWithinGroup()} when the
16628     *       windowDef is {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}
16629     *       (Oracle / MSSQL parser style).</li>
16630     * </ul>
16631     */
16632    private static boolean hasWithinGroupAnyAttachment(TFunctionCall fn) {
16633        if (fn == null) return false;
16634        if (fn.getWithinGroup() != null) return true;
16635        return isWithinGroupOnlyWindowDef(fn.getWindowDef());
16636    }
16637
16638    /**
16639     * Slice 29 (codex impl-review round-3 MUST): walk the expression
16640     * subtree and return the (lower-cased) function name of any
16641     * {@code TFunctionCall} that carries WITHIN GROUP — via direct
16642     * {@code fn.getWithinGroup()} (PG style) or via
16643     * {@code fn.getWindowDef().getWithinGroup()} when the windowDef is
16644     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} (Oracle /
16645     * MSSQL style; slice 31) — whose name is NOT in
16646     * {@link #AGGREGATE_FUNCTION_NAMES}. Returns {@code null} if every
16647     * WITHIN GROUP-bearing call uses a whitelisted aggregate name.
16648     * Used at the {@code preflightPredicateSubqueryShape} call site to reject
16649     * {@code foo(x.id) WITHIN GROUP (...)}-shaped projections where
16650     * {@code foo} isn't an aggregate the IR knows how to model.
16651     *
16652     * <p>Slice 43: now takes the inner {@link EDbVendor} so the
16653     * {@link #isAdmittedWithinGroupName} delegate can apply the
16654     * PG-only hypothetical-set carve-out
16655     * ({@link #isDirectAttachmentHypotheticalSetCall}; widened to
16656     * Snowflake by slice 44).
16657     */
16658    private static String findUnsupportedWithinGroupFunctionName(
16659            TExpression e, final EDbVendor vendor) {
16660        if (e == null) return null;
16661        final String[] firstUnsupported = {null};
16662        e.acceptChildren(new TParseTreeVisitor() {
16663            @Override
16664            public void preVisit(TFunctionCall fn) {
16665                if (firstUnsupported[0] != null) return;
16666                if (!hasWithinGroupAnyAttachment(fn)) return;
16667                String name = fn.getFunctionName() == null
16668                        ? null : fn.getFunctionName().toString();
16669                if (isAdmittedWithinGroupName(fn, name, vendor)) return;
16670                firstUnsupported[0] = name == null ? "<unnamed>" : name;
16671            }
16672        });
16673        if (firstUnsupported[0] == null
16674                && e.getExpressionType() == EExpressionType.function_t) {
16675            TFunctionCall fn = e.getFunctionCall();
16676            if (hasWithinGroupAnyAttachment(fn)) {
16677                String name = fn.getFunctionName() == null
16678                        ? null : fn.getFunctionName().toString();
16679                if (!isAdmittedWithinGroupName(fn, name, vendor)) {
16680                    firstUnsupported[0] = name == null ? "<unnamed>" : name;
16681                }
16682            }
16683        }
16684        return firstUnsupported[0];
16685    }
16686
16687    /**
16688     * Slice 42 helper used by {@link #findUnsupportedWithinGroupFunctionName}.
16689     * Returns {@code true} iff {@code name} is in the regular
16690     * {@link #AGGREGATE_FUNCTION_NAMES} whitelist, OR — under the
16691     * AST-shape constraint
16692     * {@link #isHypotheticalSetWithinGroupCall} — in the slice-42
16693     * {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES} whitelist (Oracle /
16694     * MSSQL windowDef-bearing attachment), OR — under the slice-43
16695     * AST-shape constraint
16696     * {@link #isDirectAttachmentHypotheticalSetCall} — in the same
16697     * hypothetical-set whitelist on PostgreSQL (slice 43) or Snowflake
16698     * (slice 44) via direct {@code fn.getWithinGroup()} attachment.
16699     *
16700     * <p>The shape constraints pin the carve-outs by parser flavor:
16701     * Oracle / MSSQL produce {@code fn.getWindowDef()!=null} with
16702     * {@code wd.getWithinGroup()!=null} and {@code !wd.isIncludingOverClause()};
16703     * PG produces {@code fn.getWithinGroup()!=null} with
16704     * {@code fn.getWindowDef()==null}. Slice 43 admits that direct-
16705     * attachment hypothetical-set carve-out for PostgreSQL; slice 44
16706     * widens the same probe-confirmed shape to Snowflake. DB2 and other
16707     * direct-attachment vendors remain outside this helper until their
16708     * AST / dlineage parity is explicitly probed and covered.
16709     */
16710    private static boolean isAdmittedWithinGroupName(
16711            TFunctionCall fn, String name, EDbVendor vendor) {
16712        if (name == null || name.isEmpty()) return false;
16713        String lower = name.toLowerCase(Locale.ROOT);
16714        if (AGGREGATE_FUNCTION_NAMES.contains(lower)) return true;
16715        if (isHypotheticalSetWithinGroupCall(fn)) return true;
16716        return isDirectAttachmentHypotheticalSetCall(fn, vendor);
16717    }
16718
16719    /**
16720     * Slice 43 / 44: true iff {@code fn} is a direct-attachment
16721     * hypothetical-set ordered-set aggregate call shape — {@code rank} /
16722     * {@code dense_rank} / {@code percent_rank} / {@code cume_dist} with
16723     * {@code fn.getWithinGroup()!=null} AND {@code fn.getWindowDef()==null},
16724     * AND {@code vendor} is in {PostgreSQL, Snowflake}.
16725     *
16726     * <p>Used as a name-whitelist exception inside
16727     * {@link #isAdmittedWithinGroupName} for predicate-body inner
16728     * projections only. Top-level admission is deliberately not granted:
16729     * top-level lifting requires a vendor-scoped projector override
16730     * (slice 43 introduces the API but defers the override to a future
16731     * slice because PG / Snowflake dlineage XML is structurally
16732     * indistinguishable between the WG and OVER forms — naive override
16733     * breaks {@code rank() OVER (ORDER BY x)} classification).
16734     *
16735     * <p>Vendor gate: PG (slice 43) and Snowflake (slice 44 — probe-
16736     * confirmed AST + dlineage XML byte-identical to PG for all
16737     * four hypothetical-set names). DB2 / Greenplum / Redshift parse-fail
16738     * on the syntax. Other direct-attachment vendors (e.g. SparkSQL drops
16739     * WITHIN GROUP attachment at parse time) remain rejected pending a
16740     * fresh probe.
16741     *
16742     * <p>Probe: {@code /tmp/probe43/Probe43.java} (slice 43) and
16743     * {@code probe44.Probe44Test} (slice 44, captured during slice-44
16744     * implementation) confirmed the AST predicate matches PG / Snowflake
16745     * hypothetical-set forms (and not the OVER form), and confirmed the
16746     * dlineage XML for {@code EXISTS (SELECT rank(0.5) WITHIN GROUP
16747     * (ORDER BY x.salary) FROM locations x)} contributes zero base-table
16748     * edges from the inner predicate body (literal arg + WG ORDER BY ref
16749     * via {@code clauseType="orderby"} fdr that the projector's
16750     * {@code clauseTypeToRole} does not map to FILTER/JOIN). Both
16751     * projectors therefore agree on zero predicate-body lineage edges
16752     * for the slice-43 / slice-44 shape — no projector change required.
16753     */
16754    private static boolean isDirectAttachmentHypotheticalSetCall(
16755            TFunctionCall fn, EDbVendor vendor) {
16756        if (fn == null) return false;
16757        if (vendor != EDbVendor.dbvpostgresql
16758                && vendor != EDbVendor.dbvsnowflake) return false;
16759        if (!isDirectAttachmentHypotheticalSetCallShape(fn)) return false;
16760        return true;
16761    }
16762
16763    /**
16764     * Slice 44: vendor-agnostic shape predicate for the direct-attachment
16765     * hypothetical-set call form ({@code fn.getWithinGroup()!=null} AND
16766     * {@code fn.getWindowDef()==null} AND function name in
16767     * {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES}). Used together with
16768     * {@link #isDirectAttachmentModeCallShape} (slice 45) by
16769     * {@link #allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment} to
16770     * gate Snowflake admission. Snowflake LISTAGG / STRING_AGG /
16771     * percentile_cont WITHIN GROUP share this attachment style but their
16772     * parser-specific argument storage ({@code stringExpr} /
16773     * {@code separatorExpr}) and dlineage XML parity remain unprobed
16774     * (slice-31 boundary preserved).
16775     */
16776    private static boolean isDirectAttachmentHypotheticalSetCallShape(
16777            TFunctionCall fn) {
16778        if (fn == null) return false;
16779        if (fn.getWithinGroup() == null) return false;
16780        if (fn.getWindowDef() != null) return false;
16781        if (fn.getFunctionName() == null) return false;
16782        String name = fn.getFunctionName().toString();
16783        if (name == null || name.isEmpty()) return false;
16784        return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains(
16785                name.toLowerCase(Locale.ROOT));
16786    }
16787
16788    /**
16789     * Slice 45: vendor-agnostic shape predicate for the direct-attachment
16790     * {@code mode()} ordered-set aggregate call form
16791     * ({@code fn.getWithinGroup()!=null} AND
16792     * {@code fn.getWindowDef()==null} AND function name equals
16793     * {@code mode}). Parallel to
16794     * {@link #isDirectAttachmentHypotheticalSetCallShape}; used by
16795     * {@link #allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment}
16796     * to admit Snowflake {@code mode() WITHIN GROUP (ORDER BY ...)}
16797     * predicate-body inner projections.
16798     *
16799     * <p>Probe-confirmed (see {@code /tmp/Probe45c.java} captured during
16800     * slice-45 implementation): Snowflake parses {@code mode() WITHIN
16801     * GROUP (ORDER BY x.salary)} with {@code fn.getWithinGroup() != null}
16802     * and {@code fn.getWindowDef() == null}, identical to PG. The
16803     * Snowflake dlineage XML for the predicate-body wrapper shape is
16804     * byte-equivalent to PG (same {@code resultset name="mode"
16805     * type="function"} wrapper, same {@code orderby} fdr that
16806     * {@code clauseTypeToRole} does not map to FILTER/JOIN); the
16807     * canonical model has zero predicate-body lineage edges, matching
16808     * the IR side (mode has no args, default visitor descent does not
16809     * walk direct {@code fn.withinGroup}).
16810     *
16811     * <p>Why mode is admitted but Snowflake LISTAGG / STRING_AGG /
16812     * percentile_cont aren't (slice-44/45 boundaries): mode has no
16813     * positional argument, so the OutputColumn.sources collection is
16814     * trivially empty and matches the dlineage zero-edge canonical model.
16815     * LISTAGG / STRING_AGG store args in parser-specific
16816     * {@code stringExpr} / {@code separatorExpr} fields whose visitor
16817     * descent has not been probed; admitting them risks
16818     * silently-empty IR sources against a non-empty dlineage column-arg
16819     * fdd. percentile_cont / percentile_disc use a literal arg
16820     * (slice-44 §C boundary preserved) but are not in
16821     * {@link #AGGREGATE_FUNCTION_NAMES}, so the slice-29 name-whitelist
16822     * guard fires inside {@link #findUnsupportedWithinGroupFunctionName}
16823     * and rejects regardless of vendor gate.
16824     */
16825    private static boolean isDirectAttachmentModeCallShape(
16826            TFunctionCall fn) {
16827        if (fn == null) return false;
16828        if (fn.getWithinGroup() == null) return false;
16829        if (fn.getWindowDef() != null) return false;
16830        if (fn.getFunctionName() == null) return false;
16831        String name = fn.getFunctionName().toString();
16832        if (name == null || name.isEmpty()) return false;
16833        return "mode".equals(name.toLowerCase(Locale.ROOT))
16834                && hasNoFunctionArgs(fn);
16835    }
16836
16837    private static boolean hasNoFunctionArgs(TFunctionCall fn) {
16838        return fn != null && (fn.getArgs() == null || fn.getArgs().size() == 0);
16839    }
16840
16841    /**
16842     * Slice 45 (renamed and widened from the slice-44 helper
16843     * {@code allWithinGroupCallsAreDirectAttachmentHypotheticalSet}):
16844     * returns {@code true} iff {@code e} contains at least one WITHIN
16845     * GROUP-bearing function call AND every such call uses an
16846     * <i>admitted</i> Snowflake direct-attachment shape — either
16847     * hypothetical-set ({@link #isDirectAttachmentHypotheticalSetCallShape},
16848     * slice 44) or mode ({@link #isDirectAttachmentModeCallShape},
16849     * slice 45). Used to gate the predicate-body vendor whitelist widen
16850     * at the {@code preflightPredicateSubqueryShape} call site so Snowflake is
16851     * admitted only on these probe-confirmed shapes — Snowflake LISTAGG /
16852     * STRING_AGG / percentile_cont / percentile_disc / other names
16853     * remain rejected (their parser-specific argument storage and
16854     * dlineage XML parity are unprobed; slice-31/44 boundary
16855     * preserved).
16856     *
16857     * <p>Mixed expressions (e.g. {@code mode() WG (...) || rank(0.5)
16858     * WG (...)} in a single predicate-body inner projection) are
16859     * admitted when every WG-bearing call is admitted-shape;
16860     * one non-admitted-shape call blocks the whole expression
16861     * (Slice45Test §D).
16862     */
16863    private static boolean allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment(
16864            TExpression e) {
16865        if (e == null) return false;
16866        final boolean[] sawAny = {false};
16867        final boolean[] sawNonAdmitted = {false};
16868        e.acceptChildren(new TParseTreeVisitor() {
16869            @Override
16870            public void preVisit(TFunctionCall fn) {
16871                if (!hasWithinGroupAnyAttachment(fn)) return;
16872                sawAny[0] = true;
16873                if (!isDirectAttachmentHypotheticalSetCallShape(fn)
16874                        && !isDirectAttachmentModeCallShape(fn)) {
16875                    sawNonAdmitted[0] = true;
16876                }
16877            }
16878        });
16879        if (e.getExpressionType() == EExpressionType.function_t) {
16880            TFunctionCall fn = e.getFunctionCall();
16881            if (fn != null && hasWithinGroupAnyAttachment(fn)) {
16882                sawAny[0] = true;
16883                if (!isDirectAttachmentHypotheticalSetCallShape(fn)
16884                        && !isDirectAttachmentModeCallShape(fn)) {
16885                    sawNonAdmitted[0] = true;
16886                }
16887            }
16888        }
16889        return sawAny[0] && !sawNonAdmitted[0];
16890    }
16891
16892    /**
16893     * Slice 27: fail-closed enumeration of admitted projection root shapes
16894     * after the slice-23/24 fast paths and the hard-rejecter guards have
16895     * been considered by {@link #isAdmittedPredicateProjection}.
16896     * Open-ended type checks are intentionally avoided
16897     * (slice-history §C / codex round-1 SHOULD 5).
16898     *
16899     * <p>Admits:
16900     * <ul>
16901     *   <li>{@code function_t} — any function call (aggregate or scalar).
16902     *       OVER-bearing window functions are rejected by the caller's
16903     *       {@code containsWindowFunction} guard (slice 31 narrowed via
16904     *       {@link #isWindowDefBearingFunction} so WITHIN-GROUP-only
16905     *       windowDef passes; OVER-bearing forms still rejected).
16906     *       {@code FILTER (WHERE ...)} was admitted in slice 28 (with
16907     *       FILTER predicate refs excluded from {@code OutputColumn.sources}
16908     *       via {@link #collectColumnRefsExcludingFilterClauses} —
16909     *       slice 31 widens to
16910     *       {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}).
16911     *       PG-style direct {@code fn.withinGroup} attachment was admitted
16912     *       in slice 29 via the vendor-gated rejecter at the
16913     *       {@link #preflightPredicateSubqueryShape} call site; slice 31 extends
16914     *       admission to Oracle / MSSQL windowDef-bearing WITHIN GROUP
16915     *       (Snowflake / DB2 / other direct-attachment vendors remain
16916     *       rejected pending probe).</li>
16917     *   <li>{@code case_t} — simple or searched CASE.</li>
16918     *   <li>Pure binary ({@link TExpression#isPureBinaryForDoParse}) —
16919     *       arithmetic, concat, comparison.</li>
16920     *   <li>{@code parenthesis_t} — descend.</li>
16921     *   <li>{@code typecast_t} — PostgreSQL / Snowflake / Redshift
16922     *       {@code expr::TYPE} (slice 37; cross-vendor probe slice 38).
16923     *       Admit unconditionally; the slice-13 invariant rejecters
16924     *       ({@link #containsAnySubqueryExpression} /
16925     *       {@link #containsWindowFunction}) fire BEFORE this admit check
16926     *       inside {@link #isAdmittedPredicateProjection}, so
16927     *       {@code (SELECT 1)::INT} and {@code (ROW_NUMBER() OVER ())::INT}
16928     *       are still rejected. The default visitor descent walks
16929     *       {@code typecast_t.getLeftOperand()} so
16930     *       {@code OutputColumn.sources} populates with the underlying
16931     *       column refs (probe-verified for PG —
16932     *       {@code /tmp/probe37/Probe37.java}; slice 38 extended the probe
16933     *       to Snowflake and Redshift —
16934     *       {@code /tmp/probe38/Probe38.java}, {@code CheckCurrent.java} —
16935     *       and confirmed byte-identical AST + dlineage XML to PG for both
16936     *       {@code x.id::VARCHAR [AS lst]} and {@code LOWER(x.id)::VARCHAR}
16937     *       composed forms with zero divergence; slice 39 extended the probe
16938     *       to Greenplum, Vertica, GaussDB, Netezza —
16939     *       {@code /tmp/probe39/Probe39.java}, {@code Probe39b.java} — and
16940     *       confirmed AST + dlineage XML byte-identical to the PG / Snowflake /
16941     *       Redshift contract for both aliased and unaliased forms with zero
16942     *       divergence; slice 40 extended the probe to BigQuery, Trino, Presto,
16943     *       EDB, DuckDB, Databricks —
16944     *       {@code /tmp/probe40/Probe40.java}, {@code Probe40b.java},
16945     *       {@code Probe40c.java}, {@code Probe40d.java}, {@code Probe40e.java}
16946     *       — and confirmed AST + dlineage XML byte-identical to the
16947     *       PG / Snowflake / Redshift contract for aliased, unaliased, and
16948     *       {@code LOWER(x.id)::VARCHAR} composed forms with zero divergence;
16949     *       slice 41 closes out the residual vendor matrix —
16950     *       {@code /tmp/probe41/Probe41.java}, {@code Probe41b.java} —
16951     *       confirming Informix native {@code typecast_t} (AST + dlineage XML
16952     *       byte-identical to the PG / Snowflake / Redshift contract);
16953     *       ClickHouse parser auto-lowers {@code expr::TYPE} to
16954     *       {@code function_t} so the slice-27 admission applies;
16955     *       Sybase / Flink / Dameng parse-fail on {@code ::TYPE} but accept
16956     *       {@code CAST(x AS TYPE)} via {@code function_t} (slice-27
16957     *       carryover); Exasol / AzureSQL parse {@code expr::TYPE} as
16958     *       {@code simple_object_name_t} (vendor-quirk — the {@code ::} is
16959     *       interpreted as a qualified-name separator, mirroring T-SQL's
16960     *       {@code tablename::method()} schema-qualified syntax) so the
16961     *       slice-32 exclusion routes via normal column handling;
16962     *       OceanBase / Impala / StarRocks parse-fail boundary locked in).
16963     *       Oracle uses {@code CAST(x AS TYPE)} which parses as
16964     *       {@code function_t} (already admitted above), so no Oracle-specific
16965     *       {@code typecast_t} admission is needed; Hive / SparkSQL parse-fail
16966     *       on the {@code ::TYPE} syntax — slice 39 pins this boundary; slice
16967     *       40 extends the parse-fail boundary lock-in to DB2, Teradata, MySQL,
16968     *       and HANA so a future grammar lift fires loudly and re-probe is
16969     *       required before relying on zero-divergence for those dialects.
16970     *       The slice-37 admission remains structural (no vendor gate); future
16971     *       vendors that surface {@code typecast_t} will be admitted
16972     *       automatically — re-probe before relying on zero-divergence
16973     *       guarantees.</li>
16974     * </ul>
16975     * Implicitly rejects {@code list_t}, {@code subquery_t} (caught by the
16976     * caller), and any unknown expression type.
16977     */
16978    private static boolean isAdmittedSlice27ShapeRoot(TExpression e) {
16979        if (e == null) return false;
16980        EExpressionType t = e.getExpressionType();
16981        if (t == EExpressionType.function_t) return true;
16982        if (t == EExpressionType.case_t) return true;
16983        if (t == EExpressionType.parenthesis_t) {
16984            return e.getLeftOperand() != null
16985                    && isAdmittedSlice27ShapeRoot(e.getLeftOperand());
16986        }
16987        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)
16988        if (TExpression.isPureBinaryForDoParse(t)) return true;
16989        return false;
16990    }
16991
16992    /**
16993     * Slice 27: visitor-based deep window-function detector. Mirrors
16994     * {@link #rejectWindowFunctions} (line ~4530) but boolean-returning so
16995     * it can be used as a guard inside
16996     * {@link #isAdmittedPredicateProjection}.
16997     *
16998     * <p>Slice 31: discriminates WITHIN-GROUP-only windowDef shapes
16999     * (Oracle / MSSQL plain {@code WITHIN GROUP (ORDER BY ...)} attachment
17000     * without OVER) from OVER-bearing ones via
17001     * {@link #isWindowDefBearingFunction}. Plain WITHIN GROUP no longer
17002     * counts as a window function for predicate-body inner-projection
17003     * admission. NOTE: only this helper and {@link #isAggregateFunction}
17004     * are lifted — every other slice-13 invariant rejecter
17005     * ({@link #rejectHavingWindowFunction},
17006     * {@link #rejectOrderByWindowFunction},
17007     * {@link #rejectWindowFunctionInScope},
17008     * {@link #rejectWindowFunctions},
17009     * {@link #rejectEmbeddedWindowFunction},
17010     * {@link #isTopLevelWindowProjection}, and the OVER ORDER BY
17011     * window check inside {@code buildWindowOrderRefs}) keeps the
17012     * strict {@code wd != null} check unchanged so HAVING / ORDER BY /
17013     * WHERE / GROUP BY / JOIN ON / top-level projection contexts still
17014     * reject WITHIN-GROUP-only attachments — slice 31 boundary.
17015     */
17016    private static boolean containsWindowFunction(TExpression e) {
17017        if (e == null) return false;
17018        final boolean[] found = {false};
17019        e.acceptChildren(new TParseTreeVisitor() {
17020            @Override
17021            public void preVisit(TFunctionCall fn) {
17022                if (found[0]) return;
17023                if (isWindowDefBearingFunction(fn)) found[0] = true;
17024            }
17025        });
17026        if (!found[0] && e.getExpressionType() == EExpressionType.function_t) {
17027            TFunctionCall fn = e.getFunctionCall();
17028            if (isWindowDefBearingFunction(fn)) found[0] = true;
17029        }
17030        return found[0];
17031    }
17032
17033    /**
17034     * Slice 31: discriminate WITHIN-GROUP-only {@link TWindowDef} shapes
17035     * from OVER-bearing ones. Returns {@code true} iff:
17036     * <ul>
17037     *   <li>{@code wd.getWithinGroup() != null} — the discriminating
17038     *       attachment;</li>
17039     *   <li>{@code !wd.isIncludingOverClause()} — no OVER syntax of any
17040     *       kind (including empty {@code OVER ()});</li>
17041     *   <li>{@code wd.getKeepDenseRankClause() == null} — Oracle KEEP
17042     *       DENSE_RANK FIRST/LAST is a slice-22 deferred shape and must
17043     *       remain windowed.</li>
17044     * </ul>
17045     *
17046     * <p>Probe-validated: {@code TWindowDef.isIncludingOverClause()} is
17047     * {@code false} for plain {@code WITHIN GROUP} and {@code true} for
17048     * any OVER-bearing form (probe Q8 / Q9 / Q11 in
17049     * {@code /tmp/probe31}).
17050     *
17051     * <p>Used by {@link #isWindowDefBearingFunction} (the slice-13
17052     * invariant lift's discriminator).
17053     */
17054    private static boolean isWithinGroupOnlyWindowDef(TWindowDef wd) {
17055        if (wd == null) return false;
17056        if (wd.isIncludingOverClause()) return false;
17057        if (wd.getWithinGroup() == null) return false;
17058        if (wd.getKeepDenseRankClause() != null) return false;
17059        return true;
17060    }
17061
17062    /**
17063     * Slice 31: a {@link TFunctionCall} is an OVER-bearing window-def
17064     * function iff its {@code windowDef} is non-null AND not
17065     * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}. Replaces
17066     * the historical {@code fn.getWindowDef() != null} check inside
17067     * {@link #containsWindowFunction} and {@link #isAggregateFunction};
17068     * every other rejecter retains the strict {@code wd != null} check
17069     * unchanged (slice 31 narrow lift).
17070     */
17071    private static boolean isWindowDefBearingFunction(TFunctionCall fn) {
17072        if (fn == null) return false;
17073        TWindowDef wd = fn.getWindowDef();
17074        if (wd == null) return false;
17075        return !isWithinGroupOnlyWindowDef(wd);
17076    }
17077
17078    /**
17079     * Slice 33: a {@link TFunctionCall} is an admitted top-level
17080     * WITHIN-GROUP-only aggregate iff:
17081     *
17082     * <ul>
17083     *   <li>{@link #isWithinGroupOnlyWindowDef} returns true on its
17084     *       windowDef (Oracle / MSSQL plain {@code WITHIN GROUP}, no
17085     *       OVER, no KEEP DENSE_RANK);</li>
17086     *   <li>{@code vendor} is Oracle or MSSQL — explicit gate mirroring
17087     *       the slice-31 predicate-body gate at line ~3860. PG /
17088     *       Snowflake / DB2 / SparkSQL produce direct
17089     *       {@code fn.withinGroup} attachment with {@code windowDef=null}
17090     *       and don't reach this helper today, but the explicit gate
17091     *       keeps the contract narrow against future parser changes;</li>
17092     *   <li>The function name is in {@link #AGGREGATE_FUNCTION_NAMES}
17093     *       (LISTAGG / STRING_AGG / SUM / MIN / MAX / MODE / etc.).</li>
17094     * </ul>
17095     *
17096     * <p>Used only by {@link #buildOutputColumns} to fall through to the
17097     * normal aggregate path. The slice-13 invariant rejecters
17098     * ({@link #isTopLevelWindowProjection},
17099     * {@link #rejectWindowFunctions},
17100     * {@link #rejectEmbeddedWindowFunction},
17101     * {@link #rejectHavingWindowFunction},
17102     * {@link #rejectOrderByWindowFunction},
17103     * {@link #rejectWindowFunctionInScope})
17104     * keep the strict {@code wd != null} check unchanged; slice 33
17105     * admission is gated by a single boolean local to
17106     * {@code buildOutputColumns}.
17107     *
17108     * <p>Non-whitelisted names (PERCENTILE_CONT / PERCENTILE_DISC /
17109     * RANK / DENSE_RANK / PERCENT_RANK / CUME_DIST / user-defined)
17110     * keep routing to {@link #buildWindowOutputColumn} where the
17111     * {@link #WINDOW_FUNCTION_NAMES} guard rejects them as
17112     * "unsupported window function".
17113     */
17114    private static boolean isAdmittedTopLevelWithinGroupAggregate(
17115            TFunctionCall fn, EDbVendor vendor) {
17116        if (fn == null) return false;
17117        if (!isWithinGroupOnlyWindowDef(fn.getWindowDef())) return false;
17118        if (vendor != EDbVendor.dbvoracle && vendor != EDbVendor.dbvmssql) {
17119            return false;
17120        }
17121        if (fn.getFunctionName() == null) return false;
17122        String name = fn.getFunctionName().toString();
17123        if (name == null || name.isEmpty()) return false;
17124        String lower = name.toLowerCase(Locale.ROOT);
17125        if (AGGREGATE_FUNCTION_NAMES.contains(lower)) return true;
17126        // Slice 42: hypothetical-set ordered-set aggregates (RANK /
17127        // DENSE_RANK / PERCENT_RANK / CUME_DIST) admitted on
17128        // Oracle / MSSQL with WITHIN-GROUP-only windowDef shape. The
17129        // surrounding {@code isWithinGroupOnlyWindowDef} guard above
17130        // already enforces the shape; the name-set membership here keeps
17131        // PERCENTILE_CONT / PERCENTILE_DISC / user-defined names rejected.
17132        return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains(lower);
17133    }
17134
17135    /**
17136     * Slice 35 / 36 / 46: top-level direct-attachment WITHIN GROUP
17137     * aggregate.
17138     *
17139     * <p>PG stores {@code LISTAGG(... ) WITHIN GROUP (...)} (slice 35) and
17140     * {@code STRING_AGG(... ) WITHIN GROUP (...)} (slice 36) in
17141     * {@code fn.getWithinGroup()} with {@code windowDef=null}. Both bypass
17142     * the slice-33 Oracle/MSSQL helper above, but the plain aggregate path
17143     * is otherwise already correct: the root is not a window function,
17144     * {@link #isAggregateFunction} sees the whitelisted name, and default
17145     * visitor descent does not walk direct {@code fn.withinGroup}, so
17146     * sources contain the function argument but not the WITHIN GROUP ORDER
17147     * BY ref. This helper exists only to unlock the slice-34 expression-text
17148     * fallback for the unaliased top-level form.
17149     *
17150     * <p>Slice 36 widens the PG name whitelist from {@code {listagg}} to
17151     * {@code {listagg, string_agg}}. Snowflake / DB2 / SparkSQL
17152     * {@code LISTAGG} / {@code STRING_AGG} WG remain rejected because
17153     * their argument-storage shape (e.g. DB2's {@code stringExpr}/
17154     * {@code separatorExpr}) is not yet probed for visitor descent and
17155     * silent empty {@code OutputColumn.sources} would manufacture
17156     * {@code IR_MISSING_DEPENDENCY} divergence.
17157     *
17158     * <p>Slice 46 widens the vendor gate to additionally admit
17159     * Snowflake — but only for {@code mode}, the only Snowflake
17160     * direct-attachment WITHIN GROUP name whose dlineage XML has been
17161     * probe-confirmed byte-equivalent to PG (the projector's slice-30
17162     * vendor-agnostic {@code AGGREGATE_FUNCTION_NAMES} +
17163     * {@code ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} entries already
17164     * cover {@code mode}, so no projector change is needed). Snowflake
17165     * {@code listagg} / {@code string_agg} / hypothetical-set names
17166     * stay out of slice-46 scope.
17167     *
17168     * <p>Slice 47 widens the PG name whitelist from
17169     * {@code {listagg, string_agg}} to {@code {listagg, string_agg, mode}},
17170     * the symmetrical lift to slice 46's Snowflake widen. The slice-46
17171     * pre-plan probe ({@code /tmp/Probe46Slice30.java}) already
17172     * confirmed PG aliased {@code mode()} WG was zero-divergence and the
17173     * unaliased form was blocked only by {@code effectiveOutputName}; PG
17174     * top-level {@code mode()} dlineage XML is byte-identical to
17175     * Snowflake's. No projector change is needed (slice 30 already
17176     * registered {@code mode}, vendor-agnostic).
17177     */
17178    private static boolean isAdmittedTopLevelDirectWithinGroupAggregate(
17179            TFunctionCall fn, EDbVendor vendor) {
17180        if (fn == null) return false;
17181        if (fn.getWithinGroup() == null) return false;
17182        if (fn.getWindowDef() != null) return false;
17183        if (fn.getFunctionName() == null) return false;
17184        String name = fn.getFunctionName().toString();
17185        if (name == null || name.isEmpty()) return false;
17186        String lower = name.toLowerCase(Locale.ROOT);
17187        if (vendor == EDbVendor.dbvpostgresql) {
17188            // Slice 35/36: PG LISTAGG / STRING_AGG WG.
17189            // Slice 47: PG mode() WG (parallels slice-46 Snowflake mode
17190            // lift; probed mode() carries no positional argument so
17191            // OutputColumn.sources is trivially empty matching
17192            // dlineage's zero-edge canonical model).
17193            return "listagg".equals(lower)
17194                    || "string_agg".equals(lower)
17195                    || ("mode".equals(lower) && hasNoFunctionArgs(fn));
17196        }
17197        if (vendor == EDbVendor.dbvsnowflake) {
17198            // Slice 46: Snowflake mode() WG only. The admitted shape is
17199            // constrained to the probed no-arg mode() form: OutputColumn.
17200            // sources is trivially empty (matching dlineage's zero-edge
17201            // canonical model), and dlineage XML was probe-confirmed
17202            // byte-identical to PG's mode() WG XML for both aliased and
17203            // unaliased forms.
17204            return "mode".equals(lower) && hasNoFunctionArgs(fn);
17205        }
17206        return false;
17207    }
17208    /**
17209     * Slice 27: detect {@code FILTER (WHERE ...)} on any function call in
17210     * the expression subtree. Probe Q5 (PostgreSQL) confirmed dlineage's
17211     * {@code fdr clause="on"} omits FILTER-predicate column refs while
17212     * {@link #collectColumnRefs} would include them — canonical-model
17213     * divergence. Reject as slice-27 boundary.
17214     */
17215    private static boolean containsAggregateWithFilter(TExpression e) {
17216        if (e == null) return false;
17217        final boolean[] found = {false};
17218        e.acceptChildren(new TParseTreeVisitor() {
17219            @Override
17220            public void preVisit(TFunctionCall fn) {
17221                if (found[0]) return;
17222                if (fn.getFilterClause() != null) found[0] = true;
17223            }
17224        });
17225        if (!found[0] && e.getExpressionType() == EExpressionType.function_t) {
17226            TFunctionCall fn = e.getFunctionCall();
17227            if (fn != null && fn.getFilterClause() != null) found[0] = true;
17228        }
17229        return found[0];
17230    }
17231
17232    /**
17233     * Slice 23: true iff every leaf of {@code e} is a {@code simple_constant_t}.
17234     * Admits {@code 1}, {@code 1+1}, {@code (1)}, {@code 'a' || 'b'} (vendor-
17235     * dependent). Slice 61 additionally admits unary {@code +}/{@code -}
17236     * wrappers over a constant operand so common signed literals like
17237     * {@code -1} and {@code -1.5} count as constants. Rejects column refs,
17238     * function calls (including {@code COALESCE}), CASE, scalar subqueries,
17239     * etc. The predicate body may STILL have inner WHERE / GROUP BY /
17240     * HAVING / ORDER BY referencing inner columns — only the projection
17241     * must be constant.
17242     */
17243    private static boolean isConstantExpression(TExpression e) {
17244        if (e == null) return false;
17245        EExpressionType t = e.getExpressionType();
17246        if (t == EExpressionType.simple_constant_t) return true;
17247        if (t == EExpressionType.parenthesis_t) {
17248            return e.getLeftOperand() != null && isConstantExpression(e.getLeftOperand());
17249        }
17250        // Slice 61: unary +/- over a constant operand. The Oracle parser
17251        // emits `-1` as {@code unary_minus_t} with {@code left=null} and
17252        // {@code right=simple_constant_t(1)}. Pre-slice-61 this fell
17253        // through and was rejected; slice 61 lifts it so signed literals
17254        // like `SELECT -1 FROM t` and `SELECT -1 UNION ALL SELECT -2`
17255        // round-trip through the constant-projection path.
17256        if (t == EExpressionType.unary_minus_t || t == EExpressionType.unary_plus_t) {
17257            TExpression operand = e.getRightOperand() != null
17258                    ? e.getRightOperand()
17259                    : e.getLeftOperand();
17260            return operand != null && isConstantExpression(operand);
17261        }
17262        // Pure binary ops (slice-22 isPureBinaryForDoParse helper) — both
17263        // operands must be constant. Concatenation, arithmetic, etc.
17264        if (TExpression.isPureBinaryForDoParse(t)) {
17265            TExpression l = e.getLeftOperand();
17266            TExpression r = e.getRightOperand();
17267            return l != null && isConstantExpression(l)
17268                    && r != null && isConstantExpression(r);
17269        }
17270        return false;
17271    }
17272
17273    /**
17274     * Reject subqueries in a predicate-subquery body's inner WHERE / JOIN ON /
17275     * GROUP BY / HAVING / ORDER BY (would be predicate subqueries OR scalar
17276     * subqueries). Mirrors the slice-11
17277     * {@link #rejectSubqueriesInScalarBodyClauses} structure but renders
17278     * through the shared predicate-subquery diagnostic context.
17279     */
17280    private static void rejectSubqueriesInPredicateBodyClauses(
17281            TSelectSqlStatement inner,
17282            PredicateSubqueryContext context) {
17283        TWhereClause where = inner.getWhereClause();
17284        if (where != null && containsAnySubquery(where)) {
17285            throw new SemanticIRBuildException(
17286                    PredicateSubqueryDiagnostics.unsupported(
17287                            DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_WHERE,
17288                            context,
17289                            "inner SELECT has a subquery in its WHERE clause; "
17290                                    + "not supported yet",
17291                            where));
17292        }
17293        if (inner.joins != null) {
17294            for (TJoin join : inner.joins) {
17295                TJoinItemList items = join.getJoinItems();
17296                if (items == null) continue;
17297                for (int i = 0; i < items.size(); i++) {
17298                    TJoinItem item = items.getJoinItem(i);
17299                    TExpression onCond = item == null ? null : item.getOnCondition();
17300                    if (onCond != null && containsAnySubqueryExpression(onCond)) {
17301                        throw new SemanticIRBuildException(
17302                                PredicateSubqueryDiagnostics.unsupported(
17303                                        DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_JOIN_ON,
17304                                        context,
17305                                        "inner SELECT has a subquery in a JOIN ON "
17306                                                + "clause; not supported yet",
17307                                        onCond));
17308                    }
17309                }
17310            }
17311        }
17312        TGroupBy groupBy = inner.getGroupByClause();
17313        if (groupBy != null) {
17314            TGroupByItemList items = groupBy.getItems();
17315            if (items != null && containsAnySubquery(items)) {
17316                throw new SemanticIRBuildException(
17317                        PredicateSubqueryDiagnostics.unsupported(
17318                                DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_GROUP_BY,
17319                                context,
17320                                "inner SELECT has a subquery in a GROUP BY clause; "
17321                                        + "not supported yet",
17322                                groupBy));
17323            }
17324        }
17325        // HAVING / ORDER BY subqueries are caught by the slice-9 / 10
17326        // deep-scan rejecters that fire during the recursive build.
17327    }
17328
17329    /**
17330     * Slice 25 (impl-review S2-fix): walk {@code onCond} (root + every
17331     * descendant outside {@code extractedRoots}) and return the first
17332     * wrapper expression whose <b>left</b> operand is a
17333     * {@code subquery_t}. Returns null if none.
17334     */
17335    private static TExpression findSubqueryOnLeftWrapper(TExpression onCond,
17336                                                          final Set<TExpression> extractedRoots) {
17337        if (onCond == null) return null;
17338        if (isSubqueryOnLeftOfWrapper(onCond)) return onCond;
17339        final TExpression[] found = {null};
17340        onCond.acceptChildren(new TParseTreeVisitor() {
17341            int skipDepth = 0;
17342
17343            @Override
17344            public void preVisit(TExpression e) {
17345                if (found[0] != null) return;
17346                if (extractedRoots.contains(e)) {
17347                    skipDepth++;
17348                    return;
17349                }
17350                if (skipDepth > 0) return;
17351                if (isSubqueryOnLeftOfWrapper(e)) {
17352                    found[0] = e;
17353                }
17354            }
17355
17356            @Override
17357            public void postVisit(TExpression e) {
17358                if (extractedRoots.contains(e) && skipDepth > 0) {
17359                    skipDepth--;
17360                }
17361            }
17362        });
17363        return found[0];
17364    }
17365
17366    /**
17367     * Slice 25 / Slice 26: true iff {@code e} is a comparison/IN/
17368     * quantifier wrapper that the slice-25 / slice-26 surface still
17369     * rejects on subquery-positioning grounds.
17370     *
17371     * <p>Slice 26 narrowing: {@code simple_comparison_t} with subquery
17372     * on the LHS and a non-subquery RHS is now ADMITTED (returns
17373     * false here so the post-extraction rejecter doesn't fire). Other
17374     * shapes still fail:
17375     * <ul>
17376     *   <li>{@code in_t} with LHS=subquery: dlineage's
17377     *       {@code fdr clause="on"} sources omit the outer column for
17378     *       IN-LHS, so admitting on the IR side would manufacture
17379     *       canonical-model divergence (still rejected).</li>
17380     *   <li>{@code group_comparison_t} with LHS=subquery: borderline
17381     *       grammar; defensively rejected (slice-26 boundary).</li>
17382     *   <li>{@code simple_comparison_t} with subqueries on BOTH
17383     *       sides: would require dual extraction; deferred to a future
17384     *       slice (slice-26 boundary). Caller emits a tuned message.</li>
17385     * </ul>
17386     */
17387    private static boolean isSubqueryOnLeftOfWrapper(TExpression e) {
17388        if (e == null) return false;
17389        EExpressionType t = e.getExpressionType();
17390        TExpression l = e.getLeftOperand();
17391        TExpression r = e.getRightOperand();
17392        boolean lhsIsSubq = l != null && l.getExpressionType() == EExpressionType.subquery_t;
17393        boolean rhsIsSubq = r != null && r.getExpressionType() == EExpressionType.subquery_t;
17394        if (t == EExpressionType.in_t) {
17395            return lhsIsSubq;
17396        }
17397        if (t == EExpressionType.group_comparison_t) {
17398            return lhsIsSubq;
17399        }
17400        if (t == EExpressionType.simple_comparison_t) {
17401            // Slice 26: lifted UNLESS both sides are subqueries.
17402            return lhsIsSubq && rhsIsSubq;
17403        }
17404        return false;
17405    }
17406
17407    /**
17408     * Slice 26: true iff {@code e} is a {@code simple_comparison_t}
17409     * whose BOTH operands are {@code subquery_t}. Used by the
17410     * post-extraction rejecter to emit a slice-26-specific tuned
17411     * message distinguishing this case from the slice-25 LHS-subquery
17412     * shapes.
17413     */
17414    private static boolean isComparisonWithBothSubqueries(TExpression e) {
17415        if (e == null) return false;
17416        if (e.getExpressionType() != EExpressionType.simple_comparison_t) return false;
17417        TExpression l = e.getLeftOperand();
17418        TExpression r = e.getRightOperand();
17419        return l != null && l.getExpressionType() == EExpressionType.subquery_t
17420                && r != null && r.getExpressionType() == EExpressionType.subquery_t;
17421    }
17422
17423    /**
17424     * Slice 23: after extraction, any remaining subquery-bearing expression in
17425     * the JOIN-ON tree is an unsupported shape — EXISTS that failed
17426     * extraction (inner-shape rejection), correlated wrappers, subquery on
17427     * left side, etc. Catch them here with a tuned message before
17428     * {@link #collectColumnRefsSkipping} would otherwise descend into them
17429     * and bind their inner refs against the outer scope.
17430     *
17431     * <p>Slice 25 (impl-review S2-fix): subquery-on-LEFT cases get a
17432     * tuned outer-shape reason via {@link #findSubqueryOnLeftWrapper};
17433     * the shared predicate-subquery context supplies the actual wrapper
17434     * and host clause in the final diagnostic.
17435     *
17436     * <p>Slice 26: a NEW first pass (before the slice-25 LHS-subquery
17437     * pass) detects {@code simple_comparison_t} wrappers with
17438     * subqueries on BOTH sides via
17439     * {@link #findComparisonWithBothSubqueries} and emits a slice-26
17440     * tuned message. Both-sides shape satisfies
17441     * {@link #isSubqueryOnLeftOfWrapper} (which slice 26 narrowed to
17442     * {@code lhsIsSubq && rhsIsSubq} for {@code simple_comparison_t}),
17443     * so ordering matters — without the both-sides first pass, the
17444     * slice-25 LHS-subquery wording would fire first.
17445     */
17446    private static void rejectAnyRemainingSubqueriesInJoinOn(TExpression onCond,
17447                                                              final Set<TExpression> extractedRoots) {
17448        rejectAnyRemainingSubqueriesFromClause(onCond, extractedRoots,
17449                PredicateClauseContext.JOIN_ON);
17450    }
17451
17452    /**
17453     * Slice 110 — clause-agnostic remaining-subquery rejecter. Mirrors
17454     * the slice-26 logic in {@link #rejectAnyRemainingSubqueriesInJoinOn}
17455     * but uses {@code ctx.*} codes / labels so the same body powers JOIN-ON
17456     * (slice 26) and UPDATE WHERE (slice 110).
17457     */
17458    private static void rejectAnyRemainingSubqueriesFromClause(TExpression onCond,
17459                                                                final Set<TExpression> extractedRoots,
17460                                                                final PredicateClauseContext ctx) {
17461        if (onCond == null) return;
17462        // Slice 26: tuned message for a comparison with subqueries on
17463        // BOTH sides. Fires at root or any descent. Checked BEFORE the
17464        // slice-25 subquery-on-LEFT pass because both-subqueries
17465        // satisfies isSubqueryOnLeftOfWrapper too — without this
17466        // ordering the slice-25 wording would fire first.
17467        TExpression bothSubqueriesWrapper = findComparisonWithBothSubqueries(onCond,
17468                extractedRoots);
17469        if (bothSubqueriesWrapper != null) {
17470            throw new SemanticIRBuildException(
17471                    Diagnostic.error(ctx.scalarComparisonBothSides,
17472                    "predicate subquery in " + ctx.clauseLabel + ": scalar comparison with "
17473                            + "subqueries on both sides is not supported yet "
17474                            + "(slice 26 admits exactly one subquery side, with a "
17475                            + "single column reference on the other side; rewrite "
17476                            + "as a join across a derived table or a CTE)", null));
17477        }
17478        // Slice 25 (impl-review S2-fix): tuned message for subquery
17479        // on the LEFT side of a wrapper. Fires at root or any descent.
17480        // Slice 26 narrowed isSubqueryOnLeftOfWrapper:
17481        // simple_comparison_t with LHS=subquery and non-subquery RHS is
17482        // now ADMITTED, so this rejecter only fires for in_t-LHS-subq /
17483        // group_comparison_t-LHS-subq (still rejected as asymmetric /
17484        // borderline shapes).
17485        TExpression leftSubqueryWrapper = findSubqueryOnLeftWrapper(onCond, extractedRoots);
17486        if (leftSubqueryWrapper != null) {
17487            throw new SemanticIRBuildException(
17488                    Diagnostic.error(ctx.predicateSubqueryOnLeft,
17489                    "predicate subquery in " + ctx.clauseLabel + ": "
17490                            + leftSubqueryWrapper.getExpressionType()
17491                            + " wrapper has a subquery on the LEFT side "
17492                            + "(only RHS-subquery IN / ANY-ALL-SOME and "
17493                            + "either-side scalar comparison are admitted; "
17494                            + "rewrite to put the subquery on the right side, "
17495                            + "or rewrite as a join across a derived table)", null));
17496        }
17497        // Root check: if the entire condition IS an EXISTS root and it WASN'T
17498        // extracted (meaning extraction threw an exception, which should not
17499        // reach here, OR some other root subquery shape), reject. The root
17500        // walker would otherwise miss it.
17501        TExpression rootSubject = isExistsRoot(onCond) ? unwrapExistsRoot(onCond) : onCond;
17502        if (rootSubject != null
17503                && (rootSubject.getExpressionType() == EExpressionType.subquery_t
17504                        || rootSubject.getSubQuery() != null
17505                        || rootSubject.getExpressionType() == EExpressionType.exists_t)
17506                && !extractedRoots.contains(rootSubject)) {
17507            throw new SemanticIRBuildException(
17508                    Diagnostic.error(ctx.genericSubqueryNotSupported,
17509                    "subquery in " + ctx.clauseLabel + " predicate is not supported yet "
17510                            + "(slice 26 accepts only uncorrelated EXISTS / "
17511                            + "IN-SELECT / scalar-comparison / ANY-ALL-SOME with "
17512                            + "single column-ref or constant-only inner projection "
17513                            + "and a single column ref on the non-subquery side)", null));
17514        }
17515        final boolean[] found = {false};
17516        onCond.acceptChildren(new TParseTreeVisitor() {
17517            int skipDepth = 0;
17518
17519            @Override
17520            public void preVisit(TExpression e) {
17521                if (found[0]) return;
17522                if (extractedRoots.contains(e)) {
17523                    skipDepth++;
17524                    return;
17525                }
17526                if (skipDepth > 0) return;
17527                if (e.getExpressionType() == EExpressionType.subquery_t
17528                        || e.getSubQuery() != null
17529                        || e.getExpressionType() == EExpressionType.exists_t) {
17530                    found[0] = true;
17531                }
17532            }
17533
17534            @Override
17535            public void postVisit(TExpression e) {
17536                if (extractedRoots.contains(e) && skipDepth > 0) {
17537                    skipDepth--;
17538                }
17539            }
17540        });
17541        if (found[0]) {
17542            throw new SemanticIRBuildException(
17543                    Diagnostic.error(ctx.genericSubqueryNotSupported,
17544                    "subquery in " + ctx.clauseLabel + " predicate is not supported yet "
17545                            + "(slice 26 accepts only uncorrelated EXISTS / "
17546                            + "IN-SELECT / scalar-comparison / ANY-ALL-SOME with "
17547                            + "single column-ref or constant-only inner projection "
17548                            + "and a single column ref on the non-subquery side)", null));
17549        }
17550    }
17551
17552    /**
17553     * Slice 26: walk {@code onCond} (root + every descendant outside
17554     * {@code extractedRoots}) and return the first
17555     * {@code simple_comparison_t} expression whose BOTH operands are
17556     * {@code subquery_t}. Returns null if none.
17557     */
17558    private static TExpression findComparisonWithBothSubqueries(TExpression onCond,
17559                                                                final Set<TExpression> extractedRoots) {
17560        if (onCond == null) return null;
17561        if (isComparisonWithBothSubqueries(onCond)) return onCond;
17562        final TExpression[] found = {null};
17563        onCond.acceptChildren(new TParseTreeVisitor() {
17564            int skipDepth = 0;
17565
17566            @Override
17567            public void preVisit(TExpression e) {
17568                if (found[0] != null) return;
17569                if (extractedRoots.contains(e)) {
17570                    skipDepth++;
17571                    return;
17572                }
17573                if (skipDepth > 0) return;
17574                if (isComparisonWithBothSubqueries(e)) {
17575                    found[0] = e;
17576                }
17577            }
17578
17579            @Override
17580            public void postVisit(TExpression e) {
17581                if (extractedRoots.contains(e) && skipDepth > 0) {
17582                    skipDepth--;
17583                }
17584            }
17585        });
17586        return found[0];
17587    }
17588
17589    /**
17590     * Slice 23: variant of {@link #rejectWindowFunctionInScope} that skips
17591     * subtrees in {@code skipRoots}. The outer-SELECT JOIN-ON path passes the
17592     * extracted EXISTS roots so a window function inside an extracted body
17593     * is NOT incorrectly rejected as a window in the outer JOIN-ON. (The
17594     * inner statement's own buildSelectStatement does its own
17595     * rejectWindowFunctionInScope sweeps on WHERE / GROUP BY / HAVING /
17596     * ORDER BY, so legitimate inner-window violations still surface.)
17597     */
17598    private static void rejectWindowFunctionInScopeSkipping(
17599            gudusoft.gsqlparser.nodes.TParseTreeNode root,
17600            String clauseLabel,
17601            final Set<TExpression> skipRoots) {
17602        if (root == null) return;
17603        // Root fast path: if the root itself IS a skipped subtree, nothing to
17604        // check. (acceptChildren wouldn't see the root anyway.)
17605        if (root instanceof TExpression && skipRoots.contains(root)) {
17606            return;
17607        }
17608        final boolean[] found = {false};
17609        root.acceptChildren(new TParseTreeVisitor() {
17610            int skipDepth = 0;
17611
17612            @Override
17613            public void preVisit(TExpression e) {
17614                if (skipRoots.contains(e)) {
17615                    skipDepth++;
17616                }
17617            }
17618
17619            @Override
17620            public void postVisit(TExpression e) {
17621                if (skipRoots.contains(e) && skipDepth > 0) {
17622                    skipDepth--;
17623                }
17624            }
17625
17626            @Override
17627            public void preVisit(TFunctionCall fn) {
17628                if (found[0] || skipDepth > 0) return;
17629                if (fn.getWindowDef() != null) found[0] = true;
17630            }
17631        });
17632        if (found[0]) {
17633            throw new SemanticIRBuildException(
17634                    Diagnostic.error(DiagnosticCode.CLAUSE_WINDOW_FUNCTION_LEAK,
17635                    clauseLabel + " contains a window function (OVER (...)); "
17636                            + "window functions are not allowed in " + clauseLabel
17637                            + " per standard SQL", root));
17638        }
17639    }
17640
17641    /**
17642     * Slice 23: variant of {@link #collectColumnRefs} that skips subtrees in
17643     * {@code skipRoots}. Used by the outer-SELECT JOIN-ON path so the
17644     * extracted EXISTS bodies' inner refs do not leak into outer
17645     * {@code joinColumnRefs}.
17646     */
17647    private static List<ColumnRef> collectColumnRefsSkipping(
17648            gudusoft.gsqlparser.nodes.TParseTreeNode root,
17649            final NameBindingProvider provider,
17650            final Set<TExpression> skipRoots) {
17651        // Slice 31 refactor: delegate to the extended variant with no
17652        // TWithinGroup skips. Behavior preserved exactly for the
17653        // existing slice-28 caller (outer JOIN-ON path at line ~3021)
17654        // and for the new slice-31 caller via
17655        // {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}.
17656        return collectColumnRefsSkippingExtended(root, provider,
17657                skipRoots, Collections.<TWithinGroup>emptySet());
17658    }
17659
17660    /**
17661     * Slice 28: collect every non-null {@link TFunctionCall#getFilterClause()}
17662     * subtree reachable from {@code root}. The returned set is identity-keyed
17663     * (uses {@link IdentityHashMap}) — required because the parser may yield
17664     * two structurally-equal FILTER WHERE expressions with different
17665     * identities; a value-keyed set would coalesce them and the
17666     * downstream {@link #collectColumnRefsSkipping} call would skip only one.
17667     *
17668     * <p>Contract: {@code root} is one of {@link TResultColumn} or
17669     * {@link TExpression}. Both call sites
17670     * ({@link #collectColumnRefsExcludingFilterClauses} for projection
17671     * source collection; the slice-28 correlation walk inside
17672     * {@link #extractOnePredicateSubqueryBody}) pass values of those
17673     * two types. Other {@code TParseTreeNode} subclasses are accepted
17674     * defensively (the visitor scan still works) but the top-level
17675     * direct-check fast paths only cover {@code TResultColumn} and
17676     * {@code TExpression}; this is intentional — adding a
17677     * {@code TFunctionCall} fast path would be reachable only if a
17678     * future call site were added with a {@code TFunctionCall} root.
17679     *
17680     * <p>Visitor-driven; descends into all expression subtrees the
17681     * standard {@code TFunctionCall.acceptChildren} path visits — function
17682     * args, {@code OVER} (analyticFunction / windowDef), FILTER, CASE arms,
17683     * parenthesised sub-expressions, AND the
17684     * {@code windowDef.withinGroup.orderBy} path on Oracle / MSSQL /
17685     * SparkSQL parsers. Note: the PostgreSQL parser stores WITHIN GROUP
17686     * on the direct {@code fn.withinGroup} field, which
17687     * {@code TFunctionCall.acceptChildren} does NOT visit, so PG WITHIN
17688     * GROUP ORDER BY refs are invisible to this collector. Slice 29
17689     * relies on that asymmetry to admit PG WITHIN GROUP aggregates in
17690     * predicate-subquery inner projections without a source-skip.
17691     * Used by:
17692     * <ul>
17693     *   <li>{@link #collectColumnRefsExcludingFilterClauses} (Pass 1) — the
17694     *       global source-skip in {@link #buildOutputColumns}.</li>
17695     *   <li>{@link #extractOnePredicateSubqueryBody}'s slice-28 correlation
17696     *       walk — projection-only correlation check for FILTER predicate
17697     *       refs.</li>
17698     * </ul>
17699     */
17700    private static Set<TExpression> collectFilterClauses(
17701            gudusoft.gsqlparser.nodes.TParseTreeNode root) {
17702        final Set<TExpression> out =
17703                Collections.newSetFromMap(new IdentityHashMap<TExpression, Boolean>());
17704        if (root == null) return out;
17705        // Visitor descends into all expression subtrees; preVisit on
17706        // TFunctionCall records the filter clause if present. The
17707        // visitor's preVisit(TFunctionCall) does NOT fire for a top-level
17708        // function_t expression's root TFunctionCall (matches the slice-13 /
17709        // slice-27 visitor descent behavior); the defensive direct checks
17710        // below cover the top-level case for the two supported root types.
17711        root.acceptChildren(new TParseTreeVisitor() {
17712            @Override
17713            public void preVisit(TFunctionCall fn) {
17714                TExpression f = fn.getFilterClause();
17715                if (f != null) out.add(f);
17716            }
17717        });
17718        if (root instanceof TExpression) {
17719            TExpression e = (TExpression) root;
17720            if (e.getExpressionType() == EExpressionType.function_t) {
17721                TFunctionCall fn = e.getFunctionCall();
17722                if (fn != null && fn.getFilterClause() != null) {
17723                    out.add(fn.getFilterClause());
17724                }
17725            }
17726        } else if (root instanceof TResultColumn) {
17727            TResultColumn rc = (TResultColumn) root;
17728            TExpression e = rc.getExpr();
17729            if (e != null && e.getExpressionType() == EExpressionType.function_t) {
17730                TFunctionCall fn = e.getFunctionCall();
17731                if (fn != null && fn.getFilterClause() != null) {
17732                    out.add(fn.getFilterClause());
17733                }
17734            }
17735        }
17736        return out;
17737    }
17738
17739    /**
17740     * Slice 30: collect every qualifier alias (the {@code x} of an
17741     * {@code x.region} TObjectName column reference) reachable from
17742     * {@code root}, without going through the resolver. Used by the
17743     * slice-30 WITHIN GROUP ORDER BY correlation walk in
17744     * {@link #extractOnePredicateSubqueryBody}.
17745     *
17746     * <p>Why bypass the resolver: PostgreSQL's parser stores WITHIN GROUP
17747     * on the direct {@code fn.withinGroup} field. {@code TFunctionCall.acceptChildren}
17748     * does NOT descend into that field, AND Resolver2 follows the same
17749     * traversal, so its {@code ResolutionResult} is null on TObjectName
17750     * nodes inside {@code fn.withinGroup.orderBy}. Calling
17751     * {@link #collectColumnRefs} on the WG ORDER BY would route through
17752     * {@code provider.bindColumn} → {@code NOT_FOUND}, and the
17753     * {@code non-exact column bindings} check would throw on legitimate
17754     * non-correlated refs. The qualifier-only collector here reads the
17755     * alias straight off the TObjectName via {@link TObjectName#getTableString()},
17756     * matching the slice-23 correlation invariant: qualified refs only.
17757     * Unqualified refs are out of scope (same schema-less limitation as
17758     * the rest of slice-23).
17759     *
17760     * <p>Returned in iteration order (so error messages identify the
17761     * first offender) using a list rather than a set.
17762     */
17763    private static List<String> collectQualifierAliases(
17764            gudusoft.gsqlparser.nodes.TParseTreeNode root) {
17765        final List<String> out = new ArrayList<>();
17766        if (root == null) return out;
17767        root.acceptChildren(new TParseTreeVisitor() {
17768            @Override
17769            public void preVisit(TObjectName node) {
17770                if (node.getDbObjectType() != EDbObjectType.column) return;
17771                String t = node.getTableString();
17772                if (t != null && !t.isEmpty()) out.add(t);
17773            }
17774        });
17775        return out;
17776    }
17777
17778    /**
17779     * Slice 30 / Slice 31: collect every WITHIN-GROUP {@code ORDER BY}
17780     * clause anywhere in the subtree, identity-keyed so two
17781     * structurally-equal order-by clauses don't collapse.
17782     *
17783     * <p>Two attachment styles are covered:
17784     * <ul>
17785     *   <li><b>Slice 30 — direct attachment</b>: PostgreSQL /
17786     *       Snowflake / DB2 / SparkSQL parsers store WITHIN GROUP on
17787     *       {@code fn.getWithinGroup()}. The default
17788     *       {@code TFunctionCall.acceptChildren} does NOT descend into
17789     *       that field, so the slice-23 {@link #collectAllInnerRefs}
17790     *       walk is blind to outer-alias references inside the ORDER BY.
17791     *       The slice-30 correlation walk needs explicit access, hence
17792     *       this helper.</li>
17793     *   <li><b>Slice 31 — windowDef attachment</b>: Oracle / MSSQL
17794     *       parsers store WITHIN GROUP on
17795     *       {@code fn.getWindowDef().getWithinGroup()} when the
17796     *       windowDef is {@link #isWithinGroupOnlyWindowDef
17797     *       WITHIN-GROUP-only}. The default {@code acceptChildren}
17798     *       DOES descend through {@code windowDef.acceptChildren}
17799     *       which calls {@code withinGroup.acceptChildren} which calls
17800     *       {@code orderBy.acceptChildren}, so column refs would
17801     *       already appear in {@link #collectAllInnerRefs}-driven
17802     *       walks. <b>However</b>, the slice-31 source-skip in
17803     *       {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}
17804     *       removes those refs from {@link OutputColumn#getSources()};
17805     *       the slice-23 correlation walk only sees {@code OutputColumn.sources}
17806     *       for the projection bucket, so a correlated
17807     *       {@code LISTAGG(x.id) WITHIN GROUP (ORDER BY e.region)} on
17808     *       Oracle would slip past the slice-23 loop after the source-
17809     *       skip. This dual-attachment helper closes that asymmetry.
17810     *       (Inner WHERE / JOIN / HAVING / ORDER BY clauses are
17811     *       independently rejected by the slice-13 strict
17812     *       {@code rejectWindowFunctionInScope} family — Oracle
17813     *       LISTAGG WG inside a clause never reaches this helper.)</li>
17814     * </ul>
17815     *
17816     * <p>The visitor's {@code preVisit(TFunctionCall)} does NOT fire for
17817     * a top-level {@code function_t} expression's root TFunctionCall;
17818     * defensive direct checks below cover the top-level case for both
17819     * {@link TExpression} and {@link TResultColumn} roots, mirroring
17820     * {@link #collectFilterClauses}.
17821     *
17822     * <p>Used by {@link #extractOnePredicateSubqueryBody}'s
17823     * projection-only correlation walk (line ~3690) for both
17824     * direct-attachment (slice 30) and windowDef-attachment (slice 31)
17825     * outer-alias references inside WITHIN GROUP ORDER BY.
17826     */
17827    private static Set<TOrderBy> collectDirectWithinGroupOrderBys(
17828            gudusoft.gsqlparser.nodes.TParseTreeNode root) {
17829        final Set<TOrderBy> out =
17830                Collections.newSetFromMap(new IdentityHashMap<TOrderBy, Boolean>());
17831        if (root == null) return out;
17832        root.acceptChildren(new TParseTreeVisitor() {
17833            @Override
17834            public void preVisit(TFunctionCall fn) {
17835                TOrderBy direct = fn.getWithinGroup() == null
17836                        ? null : fn.getWithinGroup().getOrderBy();
17837                if (direct != null) out.add(direct);
17838                TWindowDef wd = fn.getWindowDef();
17839                if (isWithinGroupOnlyWindowDef(wd)) {
17840                    TOrderBy wdOb = wd.getWithinGroup().getOrderBy();
17841                    if (wdOb != null) out.add(wdOb);
17842                }
17843            }
17844        });
17845        if (root instanceof TExpression) {
17846            TExpression e = (TExpression) root;
17847            if (e.getExpressionType() == EExpressionType.function_t) {
17848                addWithinGroupOrderByIfPresent(e.getFunctionCall(), out);
17849            }
17850        } else if (root instanceof TResultColumn) {
17851            TResultColumn rc = (TResultColumn) root;
17852            TExpression e = rc.getExpr();
17853            if (e != null && e.getExpressionType() == EExpressionType.function_t) {
17854                addWithinGroupOrderByIfPresent(e.getFunctionCall(), out);
17855            }
17856        }
17857        return out;
17858    }
17859
17860    /**
17861     * Slice 30 / 31: helper for top-level direct check inside
17862     * {@link #collectDirectWithinGroupOrderBys}. Adds the WITHIN GROUP
17863     * ORDER BY clause to {@code out} for whichever attachment style
17864     * the function carries.
17865     */
17866    private static void addWithinGroupOrderByIfPresent(TFunctionCall fn,
17867                                                       Set<TOrderBy> out) {
17868        if (fn == null) return;
17869        if (fn.getWithinGroup() != null && fn.getWithinGroup().getOrderBy() != null) {
17870            out.add(fn.getWithinGroup().getOrderBy());
17871        }
17872        TWindowDef wd = fn.getWindowDef();
17873        if (isWithinGroupOnlyWindowDef(wd) && wd.getWithinGroup().getOrderBy() != null) {
17874            out.add(wd.getWithinGroup().getOrderBy());
17875        }
17876    }
17877
17878    /**
17879     * Slice 28: variant of {@link #collectColumnRefs} that excludes column
17880     * refs inside {@code FILTER (WHERE ...)} clauses on any function call
17881     * in the subtree. Used by {@link #buildOutputColumns} for ALL output
17882     * source collection so the IR's per-projection {@code OutputColumn.sources}
17883     * matches dlineage's lineage-relationship view (which omits FILTER
17884     * predicate column refs entirely; see slice-28 probes Q1–Q4).
17885     *
17886     * <p>For projections that contain no FILTER aggregates (the common case),
17887     * Pass 1 yields zero skip-roots and Pass 2 reduces to the plain
17888     * {@link #collectColumnRefs}. The asymmetry between projection sources
17889     * (FILTER-skipped) and clause refs ({@code filterColumnRefs},
17890     * {@code joinColumnRefs}, {@code groupByColumnRefs},
17891     * {@code havingColumnRefs}, {@code orderByColumnRefs} — NOT
17892     * FILTER-skipped) is intentional: it keeps the existing
17893     * {@link #collectAllInnerRefs}-driven correlation check at line ~3603
17894     * sufficient for FILTER refs landing in non-projection clauses, while
17895     * the slice-28 correlation walk in {@link #extractOnePredicateSubqueryBody}
17896     * covers projection-FILTER refs.
17897     */
17898    private static List<ColumnRef> collectColumnRefsExcludingFilterClauses(
17899            gudusoft.gsqlparser.nodes.TParseTreeNode root,
17900            NameBindingProvider provider) {
17901        Set<TExpression> filterClauses = collectFilterClauses(root);
17902        if (filterClauses.isEmpty()) {
17903            return collectColumnRefs(root, provider);
17904        }
17905        return collectColumnRefsSkipping(root, provider, filterClauses);
17906    }
17907
17908    /**
17909     * Slice 31: identity-keyed set of every {@link TWithinGroup} reachable
17910     * from {@code root} via {@code fn.getWindowDef().getWithinGroup()} —
17911     * the Oracle / MSSQL attachment style for plain {@code WITHIN GROUP
17912     * (ORDER BY ...)} aggregates. Used as additional skip-roots in
17913     * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} so
17914     * the column refs inside the WITHIN GROUP ORDER BY do NOT enter
17915     * {@link OutputColumn#getSources()} on Oracle / MSSQL — matching
17916     * dlineage's omission of those refs from {@code fdr clause="on"}
17917     * sources (probe Q1 / Q3 / Q4 / Q5 in {@code /tmp/probe31}).
17918     *
17919     * <p>Discriminator: {@link #isWithinGroupOnlyWindowDef}. OVER-bearing
17920     * windowDefs (real window functions) are NOT collected here — the
17921     * slice-13 invariant rejecters keep them rejected before this
17922     * collector ever fires for projection sources, so they cannot
17923     * reach the source-skip in practice. Defensive: even if they did,
17924     * the discriminator excludes them so PARTITION BY / OVER ORDER BY
17925     * column refs (slice-13 / slice-19 alias-bound contracts) keep
17926     * their existing semantics.
17927     *
17928     * <p>The PostgreSQL direct {@code fn.getWithinGroup()} attachment
17929     * is NOT collected here because PG's
17930     * {@code TFunctionCall.acceptChildren} does not descend into the
17931     * direct field — slice 29 relied on that asymmetry to admit PG
17932     * WITHIN GROUP aggregates without any source-skip; slice 31
17933     * preserves that asymmetry on PG.
17934     */
17935    private static Set<TWithinGroup> collectWithinGroupClausesFromWindowDef(
17936            gudusoft.gsqlparser.nodes.TParseTreeNode root) {
17937        final Set<TWithinGroup> out =
17938                Collections.newSetFromMap(new IdentityHashMap<TWithinGroup, Boolean>());
17939        if (root == null) return out;
17940        root.acceptChildren(new TParseTreeVisitor() {
17941            @Override
17942            public void preVisit(TFunctionCall fn) {
17943                TWindowDef wd = fn.getWindowDef();
17944                if (isWithinGroupOnlyWindowDef(wd)) {
17945                    out.add(wd.getWithinGroup());
17946                }
17947            }
17948        });
17949        if (root instanceof TExpression) {
17950            TExpression e = (TExpression) root;
17951            if (e.getExpressionType() == EExpressionType.function_t) {
17952                addWithinGroupFromWindowDefIfPresent(e.getFunctionCall(), out);
17953            }
17954        } else if (root instanceof TResultColumn) {
17955            TResultColumn rc = (TResultColumn) root;
17956            TExpression e = rc.getExpr();
17957            if (e != null && e.getExpressionType() == EExpressionType.function_t) {
17958                addWithinGroupFromWindowDefIfPresent(e.getFunctionCall(), out);
17959            }
17960        }
17961        return out;
17962    }
17963
17964    /**
17965     * Slice 31: helper for top-level direct check inside
17966     * {@link #collectWithinGroupClausesFromWindowDef}. Mirrors the
17967     * slice-30 {@link #addWithinGroupOrderByIfPresent} helper but adds
17968     * the {@link TWithinGroup} node itself to {@code out} (the entire
17969     * WITHIN GROUP subtree is the skip-root, not just its ORDER BY).
17970     */
17971    private static void addWithinGroupFromWindowDefIfPresent(TFunctionCall fn,
17972                                                             Set<TWithinGroup> out) {
17973        if (fn == null) return;
17974        TWindowDef wd = fn.getWindowDef();
17975        if (isWithinGroupOnlyWindowDef(wd)) {
17976            out.add(wd.getWithinGroup());
17977        }
17978    }
17979
17980    /**
17981     * Slice 31: extends slice-28's filter-skipping projection-source
17982     * collector with an additional skip for Oracle / MSSQL
17983     * {@code fn.windowDef.withinGroup} subtrees. Reduces to slice-28
17984     * behavior on PostgreSQL (where windowDef is null) and to plain
17985     * {@link #collectColumnRefs} when neither FILTER nor WITHIN GROUP
17986     * is present.
17987     *
17988     * <p>Used by {@link #buildOutputColumns} for ALL projection source
17989     * collection (predicate-body short-circuit at line ~4952 and
17990     * normal projection loop at line ~5069) so the IR's
17991     * {@link OutputColumn#getSources()} matches dlineage's
17992     * lineage-relationship view across PG / Oracle / MSSQL. The
17993     * non-projection clause-bucket collectors
17994     * ({@code filterColumnRefs}, {@code joinColumnRefs},
17995     * {@code groupByColumnRefs}, {@code havingColumnRefs},
17996     * {@code orderByColumnRefs}) intentionally keep using plain
17997     * {@link #collectColumnRefs} — the slice-13 strict
17998     * {@code rejectWindowFunctionInScope} family rejects any
17999     * {@code wd != null} function in those clauses BEFORE collection
18000     * descends into them, so WITHIN GROUP refs cannot leak into
18001     * clause buckets in practice.
18002     */
18003    private static List<ColumnRef> collectColumnRefsExcludingFilterAndWithinGroupClauses(
18004            gudusoft.gsqlparser.nodes.TParseTreeNode root,
18005            NameBindingProvider provider) {
18006        Set<TExpression> filterClauses = collectFilterClauses(root);
18007        Set<TWithinGroup> withinGroupClauses = collectWithinGroupClausesFromWindowDef(root);
18008        if (filterClauses.isEmpty() && withinGroupClauses.isEmpty()) {
18009            return collectColumnRefs(root, provider);
18010        }
18011        return collectColumnRefsSkippingExtended(root, provider,
18012                filterClauses, withinGroupClauses);
18013    }
18014
18015    /**
18016     * Slice 31: variant of {@link #collectColumnRefsSkipping} that
18017     * additionally skips column refs inside {@link TWithinGroup}
18018     * subtrees in {@code wgSkipRoots} (Oracle / MSSQL
18019     * {@code fn.windowDef.withinGroup} attachment). The existing
18020     * {@code exprSkipRoots} carries the slice-28 FILTER subtrees.
18021     * Returns column refs in iteration order.
18022     *
18023     * <p>Refactor note: {@link #collectColumnRefsSkipping} now delegates
18024     * to this method with an empty {@code wgSkipRoots} set so its
18025     * behavior is preserved exactly for legacy callers (the outer
18026     * JOIN-ON path at line ~3021).
18027     */
18028    private static List<ColumnRef> collectColumnRefsSkippingExtended(
18029            gudusoft.gsqlparser.nodes.TParseTreeNode root,
18030            final NameBindingProvider provider,
18031            final Set<TExpression> exprSkipRoots,
18032            final Set<TWithinGroup> wgSkipRoots) {
18033        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
18034        final List<String> rejects = new ArrayList<>();
18035        // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at
18036        // the wrong side). Such a miss is a genuine error and must stay fatal;
18037        // only all-unqualified rejects are eligible for the join-graph degrade.
18038        final boolean[] sawQualifiedReject = {false};
18039        // Root fast path: if root IS a skipped TExpression subtree, return empty.
18040        if (root instanceof TExpression && exprSkipRoots.contains(root)) {
18041            return new ArrayList<>(refs);
18042        }
18043        root.acceptChildren(new TParseTreeVisitor() {
18044            int skipDepth = 0;
18045            int nestedSelectDepth = 0;
18046
18047            @Override
18048            public void preVisit(TExpression e) {
18049                if (exprSkipRoots.contains(e)) skipDepth++;
18050            }
18051
18052            @Override
18053            public void postVisit(TExpression e) {
18054                if (exprSkipRoots.contains(e) && skipDepth > 0) skipDepth--;
18055            }
18056
18057            @Override
18058            public void preVisit(TWithinGroup wg) {
18059                if (wgSkipRoots.contains(wg)) skipDepth++;
18060            }
18061
18062            @Override
18063            public void postVisit(TWithinGroup wg) {
18064                if (wgSkipRoots.contains(wg) && skipDepth > 0) skipDepth--;
18065            }
18066
18067            @Override
18068            public void preVisit(TSelectSqlStatement nested) {
18069                nestedSelectDepth++;
18070            }
18071
18072            @Override
18073            public void postVisit(TSelectSqlStatement nested) {
18074                nestedSelectDepth--;
18075            }
18076
18077            @Override
18078            public void preVisit(TObjectName node) {
18079                if (skipDepth > 0) return;
18080                if (nestedSelectDepth > 0) return;
18081                appendMergedOrBoundColumnRef(node, provider, refs, rejects,
18082                        sawQualifiedReject);
18083            }
18084        });
18085        if (!rejects.isEmpty()) {
18086            rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]);
18087        }
18088        return new ArrayList<>(refs);
18089    }
18090
18091    /**
18092     * Reject join shapes that would silently drop predicate semantics:
18093     * semi/anti, vendor-specific kinds; predicate-bearing joins with
18094     * no ON and no USING clause; CROSS / NATURAL JOIN with ON or
18095     * USING. Slice 63 admits {@code CROSS JOIN} via
18096     * {@link #ALLOWED_ON_LESS_JOIN_TYPES}. Slice 64 admits
18097     * {@code JOIN ... USING (...)} on predicate join types; the
18098     * per-key {@code joinColumnRefs} emission is handled in
18099     * {@link #buildRelations}. Slice 66 admits {@code NATURAL JOIN}
18100     * via {@link #NATURAL_JOIN_TYPES} when catalog metadata is
18101     * available on both sides; the catalog-required reject fires
18102     * inside {@link #buildRelations}, not here.
18103     */
18104    private static void rejectUnsupportedJoinShape(TJoinItem item) {
18105        EJoinType jt = item.getJoinType();
18106        boolean isPredicate = jt != null && ALLOWED_PREDICATE_JOIN_TYPES.contains(jt);
18107        boolean isOnLess = jt != null && ALLOWED_ON_LESS_JOIN_TYPES.contains(jt);
18108        boolean isNatural = isNaturalJoinType(jt);
18109        boolean isLateral = isLateralJoinType(jt);
18110        if (!isPredicate && !isOnLess && !isNatural && !isLateral) {
18111            throw new SemanticIRBuildException(
18112                    Diagnostic.error(DiagnosticCode.UNSUPPORTED_JOIN_TYPE,
18113                    "join type " + jt + " is not supported yet; "
18114                            + "only INNER/LEFT/RIGHT/FULL [OUTER] JOIN ... ON, "
18115                            + "JOIN ... USING (...), CROSS JOIN, CROSS/OUTER APPLY, and "
18116                            + "NATURAL [INNER/LEFT/RIGHT/FULL [OUTER]] JOIN are accepted", item));
18117        }
18118        boolean hasUsing = item.getUsingColumns() != null
18119                && item.getUsingColumns().size() > 0;
18120        boolean hasOn = item.getOnCondition() != null;
18121        if (isLateral) {
18122            // CROSS/OUTER APPLY is an ON-less lateral join; the correlation
18123            // lives inside the right operand, never on an ON/USING clause.
18124            // The parser does not attach ON/USING to APPLY items, so these
18125            // are defensive guards (reuse the CROSS_WITH_* codes — no new
18126            // diagnostic code is introduced for APPLY).
18127            if (hasOn) {
18128                throw new SemanticIRBuildException(
18129                        Diagnostic.error(DiagnosticCode.CROSS_WITH_ON,
18130                        "CROSS/OUTER APPLY must not carry an ON condition; the "
18131                                + "correlation belongs inside the right operand", item));
18132            }
18133            if (hasUsing) {
18134                throw new SemanticIRBuildException(
18135                        Diagnostic.error(DiagnosticCode.CROSS_WITH_USING,
18136                        "CROSS/OUTER APPLY must not carry a USING clause; the "
18137                                + "correlation belongs inside the right operand", item));
18138            }
18139            return;
18140        }
18141        if (isNatural) {
18142            if (hasOn) {
18143                throw new SemanticIRBuildException(
18144                        Diagnostic.error(DiagnosticCode.NATURAL_WITH_ON,
18145                        "NATURAL JOIN must not carry an ON condition; rewrite "
18146                                + "as JOIN ... ON, or drop the NATURAL keyword", item));
18147            }
18148            if (hasUsing) {
18149                throw new SemanticIRBuildException(
18150                        Diagnostic.error(DiagnosticCode.NATURAL_WITH_USING,
18151                        "NATURAL JOIN must not carry a USING clause; choose "
18152                                + "either NATURAL or USING, not both", item));
18153            }
18154            return;
18155        }
18156        if (isOnLess) {
18157            if (hasOn) {
18158                throw new SemanticIRBuildException(
18159                        Diagnostic.error(DiagnosticCode.CROSS_WITH_ON,
18160                        "CROSS JOIN must not carry an ON condition; rewrite "
18161                                + "as INNER JOIN ... ON, or drop the ON clause", item));
18162            }
18163            if (hasUsing) {
18164                throw new SemanticIRBuildException(
18165                        Diagnostic.error(DiagnosticCode.CROSS_WITH_USING,
18166                        "CROSS JOIN must not carry a USING clause; rewrite "
18167                                + "as INNER JOIN ... USING (...) or drop USING", item));
18168            }
18169            return;
18170        }
18171        // Predicate-bearing path.
18172        if (hasUsing && hasOn) {
18173            throw new SemanticIRBuildException(
18174                    Diagnostic.error(DiagnosticCode.JOIN_WITH_BOTH_ON_AND_USING,
18175                    "JOIN cannot carry both ON and USING; choose one", item));
18176        }
18177        if (!hasUsing && !hasOn) {
18178            throw new SemanticIRBuildException(
18179                    Diagnostic.error(DiagnosticCode.JOIN_MISSING_ON_OR_USING,
18180                    "JOIN with no ON or USING condition is not supported yet "
18181                            + "(implicit joins must be explicit and supported)", item));
18182        }
18183    }
18184
18185    /**
18186     * Slice 64 — populate {@code joinColumnRefs} for a USING-shaped
18187     * join item. Emits refs in <b>left-then-right</b> order per key.
18188     *
18189     * <p>Column-source resolution looks at two sources, in order:
18190     * <ol>
18191     *   <li>The catalog (via
18192     *       {@link NameBindingProvider#getRelationColumnNames(TTable)})
18193     *       for base tables;</li>
18194     *   <li>The slice-60 in-scope-relation-columns map (via
18195     *       {@link NameBindingProvider#getInScopeRelationColumns()})
18196     *       for CTE and FROM-subquery relations, keyed by effective
18197     *       alias.</li>
18198     * </ol>
18199     *
18200     * <p>Left side uses these two sources to narrow to the prior
18201     * relations that actually declare the USING key, walking
18202     * {@code topJoin.getTable()} then
18203     * {@code items[0..itemIndex-1].getTable()} in FROM order.
18204     *
18205     * <p>Right side is always {@code item.getTable()}. When either
18206     * source declares the right relation's columns and the USING key
18207     * is absent there, the build is failed-fast with a
18208     * non-exact-binding-style reject — matching what the resolver
18209     * does for plain {@code SELECT k} where {@code k} doesn't exist.
18210     *
18211     * <p>When neither source has any column info for the prior
18212     * relations (no catalog and no in-scope map), fall back to
18213     * emitting one ref for the immediately-prior relation so the
18214     * slice-64 admission still works without a catalog. Same
18215     * fallback applies to the right side (emit unconditionally).
18216     *
18217     * <p>This matches resolver2's all-chain-tables linkage
18218     * ({@code ScopeBuilder.preVisit(TJoinItem)}) for the cases where
18219     * catalog/in-scope info is missing, without adopting its
18220     * over-approximation when info IS available.
18221     */
18222    private static void populateUsingJoinRefs(TJoin topJoin,
18223                                              TJoinItemList items,
18224                                              int itemIndex,
18225                                              TTable rightTable,
18226                                              TObjectNameList usingCols,
18227                                              NameBindingProvider provider,
18228                                              List<ColumnRef> joinRefsOut) {
18229        // Slice 66: collect the SQL-written USING key spellings and
18230        // delegate to the shared {@link #emitMergedJoinRefs} helper
18231        // which serves both USING (this path) and NATURAL.
18232        List<String> keyNames = new ArrayList<>(usingCols.size());
18233        for (int k = 0; k < usingCols.size(); k++) {
18234            TObjectName usingKey = usingCols.getObjectName(k);
18235            if (usingKey == null) continue;
18236            String keyName = usingKey.getColumnNameOnly();
18237            if (keyName == null || keyName.isEmpty()) continue;
18238            keyNames.add(keyName);
18239        }
18240        emitMergedJoinRefs(JoinKind.USING, keyNames, topJoin, items,
18241                itemIndex, rightTable, provider, joinRefsOut);
18242    }
18243
18244    /**
18245     * Slice 66 — discriminator for {@link #emitMergedJoinRefs}. USING
18246     * comes from a syntactic clause and enforces left-and-right
18247     * "key-must-exist" rejects; NATURAL comes from catalog inference
18248     * and never has a missing key by construction.
18249     */
18250    private enum JoinKind { USING, NATURAL }
18251
18252    /**
18253     * Slice 66 — shared emit-refs helper used by USING and NATURAL.
18254     * Emits per-key {@code joinColumnRefs} in <b>left-then-right</b>
18255     * order, walking every prior FROM relation for the left side. The
18256     * {@code kind} discriminator controls:
18257     *
18258     * <ul>
18259     *   <li><b>CTE-explicit-column-list deferral</b>: applies to BOTH
18260     *       (the diagnostic wording mentions JOIN kind);</li>
18261     *   <li><b>Right-side "missing key" reject</b>: USING-only —
18262     *       NATURAL keys come from catalog intersection so the key
18263     *       must be present on the right by construction;</li>
18264     *   <li><b>Left-side "missing key" reject</b>: USING-only — same
18265     *       rationale.</li>
18266     * </ul>
18267     *
18268     * <p>Spelling: the caller supplies the emitted spelling (USING
18269     * passes the SQL-written spelling; NATURAL passes the catalog-
18270     * declared spelling of the first contributor — see
18271     * {@link #naturalSharedKeys}).
18272     */
18273    private static void emitMergedJoinRefs(JoinKind kind,
18274                                           List<String> keyNames,
18275                                           TJoin topJoin,
18276                                           TJoinItemList items,
18277                                           int itemIndex,
18278                                           TTable rightTable,
18279                                           NameBindingProvider provider,
18280                                           List<ColumnRef> joinRefsOut) {
18281        String rightAlias = effectiveAliasOf(rightTable);
18282        List<TTable> priorRelations = new ArrayList<>();
18283        if (topJoin.getTable() != null) {
18284            priorRelations.add(topJoin.getTable());
18285        }
18286        for (int j = 0; j < itemIndex; j++) {
18287            TJoinItem prevItem = items.getJoinItem(j);
18288            if (prevItem != null && prevItem.getTable() != null) {
18289                priorRelations.add(prevItem.getTable());
18290            }
18291        }
18292        // Slice 60 / 64 / 66 originally rejected USING / NATURAL joins
18293        // against a CTE with an explicit column list because the CTE
18294        // body's StatementGraph published inner-projection names rather
18295        // than the renamed list. Slice 103 lifts that rejection by
18296        // wiring the slice-102 rename helper into the SELECT-side CTE
18297        // walker; the published column list now matches the explicit
18298        // list, so lookupRelationColumnNames returns the renamed names
18299        // from the in-scope map and the merged-key emit below works.
18300        // `MERGED_JOIN_AGAINST_CTE_WITH_EXPLICIT_COLUMN_LIST` stays
18301        // declared-but-unreached (slice 71/72/82/86/95/96/97/98/99/100/
18302        // 101/102 precedent).
18303        for (String keyName : keyNames) {
18304            if (keyName == null || keyName.isEmpty()) continue;
18305
18306            // Left side FIRST (matches ON-clause natural reading order).
18307            // For USING: priorRelations with metadata-unknown or
18308            // declared-key emit a ref; missing-key skips. If no ref
18309            // emitted and all priors had known info → USING-only
18310            // reject (NATURAL never reaches this because the catalog
18311            // intersection guarantees at least one contributor).
18312            boolean emittedAnyLeft = false;
18313            boolean allPriorsHadColumnInfo = true;
18314            for (TTable prior : priorRelations) {
18315                List<String> cols = lookupRelationColumnNames(prior, provider);
18316                if (cols == null) {
18317                    allPriorsHadColumnInfo = false;
18318                    joinRefsOut.add(new ColumnRef(
18319                            effectiveAliasOf(prior), keyName));
18320                    emittedAnyLeft = true;
18321                    continue;
18322                }
18323                for (String c : cols) {
18324                    if (c != null && c.equalsIgnoreCase(keyName)) {
18325                        joinRefsOut.add(new ColumnRef(
18326                                effectiveAliasOf(prior), keyName));
18327                        emittedAnyLeft = true;
18328                        break;
18329                    }
18330                }
18331            }
18332            if (kind == JoinKind.USING
18333                    && !emittedAnyLeft && allPriorsHadColumnInfo
18334                    && !priorRelations.isEmpty()) {
18335                throw new SemanticIRBuildException(
18336                        Diagnostic.error(DiagnosticCode.USING_KEY_NOT_DECLARED,
18337                        "USING key '" + keyName + "' is not declared on "
18338                                + "any left-side relation; check that the "
18339                                + "key exists on at least one of the "
18340                                + "joined-in relations", rightTable));
18341            }
18342
18343            // Right side. USING: must exist OR catalog unknown.
18344            // NATURAL: by construction the key is in right's catalog.
18345            // For the unknown-catalog case we still emit (over-approximate).
18346            List<String> rightCols = lookupRelationColumnNames(rightTable, provider);
18347            if (rightCols == null) {
18348                joinRefsOut.add(new ColumnRef(rightAlias, keyName));
18349            } else {
18350                boolean rightHasKey = false;
18351                for (String c : rightCols) {
18352                    if (c != null && c.equalsIgnoreCase(keyName)) {
18353                        rightHasKey = true;
18354                        break;
18355                    }
18356                }
18357                if (!rightHasKey) {
18358                    if (kind == JoinKind.USING) {
18359                        throw new SemanticIRBuildException(
18360                                Diagnostic.error(DiagnosticCode.USING_KEY_NOT_DECLARED,
18361                                "USING key '" + keyName + "' is not declared on "
18362                                        + "right-side relation '" + rightAlias
18363                                        + "'; USING requires the key to exist on "
18364                                        + "both sides", rightTable));
18365                    }
18366                    // NATURAL: silently skip — should be unreachable
18367                    // because keys come from the intersection.
18368                    continue;
18369                }
18370                joinRefsOut.add(new ColumnRef(rightAlias, keyName));
18371            }
18372        }
18373    }
18374
18375    /**
18376     * Slice 64 — true iff the given table reference is a CTE with an
18377     * explicit column list (e.g. {@code WITH x(a, b) AS ...}). Slice
18378     * 64 originally used this to defer USING joins against such CTEs;
18379     * slice 103 lifted that deferral by wiring the slice-102 rename
18380     * helper into the SELECT-side CTE walker. The helper is retained
18381     * for {@link #buildUsingScope}'s ambiguity check (defense in depth)
18382     * and may be reused by future call sites that need to discriminate
18383     * the shape.
18384     */
18385    private static boolean hasExplicitCteColumnList(TTable table) {
18386        if (table == null) return false;
18387        TCTE cte = table.getCTE();
18388        return cte != null && cte.getColumnList() != null
18389                && cte.getColumnList().size() > 0;
18390    }
18391
18392    /**
18393     * Slice 65 — read the renamed column names from a CTE's explicit
18394     * column list ({@code WITH x(a, b) AS ...}). Used by
18395     * {@link #buildUsingScope}'s ambiguity check as a defense-in-depth
18396     * complement to {@link #lookupRelationColumnNames}. Slice 65
18397     * originally needed this because the CTE body's StatementGraph
18398     * published inner-projection names; slice 103 lifted that gap by
18399     * applying the slice-102 rename helper on the SELECT side, so the
18400     * in-scope-map path now returns the renamed list too.
18401     */
18402    private static java.util.List<String> explicitCteColumnNames(TTable table) {
18403        if (table == null) return null;
18404        TCTE cte = table.getCTE();
18405        if (cte == null) return null;
18406        if (cte.getColumnList() == null || cte.getColumnList().size() == 0) {
18407            return null;
18408        }
18409        java.util.List<String> names = new java.util.ArrayList<>(cte.getColumnList().size());
18410        for (int i = 0; i < cte.getColumnList().size(); i++) {
18411            TObjectName col = cte.getColumnList().getObjectName(i);
18412            if (col == null) continue;
18413            String n = col.getColumnNameOnly();
18414            if (n == null || n.isEmpty()) continue;
18415            names.add(n);
18416        }
18417        return names.isEmpty() ? null : names;
18418    }
18419
18420    /**
18421     * Slice 64 — look up column names for a FROM-clause relation
18422     * combining the slice-58 base-table catalog and the slice-60
18423     * in-scope CTE/subquery map. Returns {@code null} when neither
18424     * source has column info for the table.
18425     *
18426     * <p>The in-scope map is consulted <b>first</b>: when a CTE or
18427     * FROM-subquery has the same name as a base table in the catalog,
18428     * the scoped definition shadows the catalog (codex diff-review
18429     * round-2 P2 #1 — without this precedence, USING against the CTE
18430     * would see the catalog table's columns and reject a valid join).
18431     *
18432     * <p>Slice 103 — CTEs with an explicit column list are no longer
18433     * rejected upstream. The SELECT-side CTE walker now invokes the
18434     * slice-102 rename helper, so {@code ctePublishedColumns} carries
18435     * the renamed names; {@code addRelationToInScopeMap} reads from
18436     * that map, and this lookup returns the renamed list. (Slice 64's
18437     * older comment said the rename was deferred — that deferral was
18438     * lifted by slice 103.)
18439     */
18440    private static List<String> lookupRelationColumnNames(TTable table,
18441                                                          NameBindingProvider provider) {
18442        String key = effectiveAliasLowerCaseOrNull(table);
18443        if (key != null) {
18444            java.util.Map<String, List<String>> inScope = provider.getInScopeRelationColumns();
18445            if (inScope != null) {
18446                List<String> scoped = inScope.get(key);
18447                if (scoped != null) return scoped;
18448            }
18449        }
18450        return provider.getRelationColumnNames(table);
18451    }
18452
18453    /**
18454     * True when EVERY FROM relation of {@code select} has a known column list
18455     * (catalog metadata via {@link NameBindingProvider#getRelationColumnNames}
18456     * or in-scope derived columns via {@code getInScopeRelationColumns}). Used to
18457     * gate the join-graph {@code COLUMN_BINDING_NON_EXACT} degrade: the degrade
18458     * is justified only when the FROM is catalog-less for at least one endpoint,
18459     * because then an unqualified column that fails to bind <em>might</em> live
18460     * on the unknown side and we cannot prove otherwise. When every endpoint's
18461     * columns are known, the resolver can adjudicate — an unqualified column
18462     * matching no side is a genuine error and must stay fatal.
18463     *
18464     * <p>Returns {@code false} (i.e. not fully known → degrade-eligible) for an
18465     * empty/absent FROM list; callers gate on {@code relations.size() >= 2}
18466     * first, so that branch is not reached in practice.
18467     */
18468    private static boolean fromRelationColumnsFullyKnown(TSelectSqlStatement select,
18469                                                         NameBindingProvider provider) {
18470        if (select == null) {
18471            return false;
18472        }
18473        gudusoft.gsqlparser.nodes.TTableList tables = select.getTables();
18474        if (tables == null || tables.size() == 0) {
18475            return false;
18476        }
18477        for (int i = 0; i < tables.size(); i++) {
18478            TTable t = tables.getTable(i);
18479            if (t == null) {
18480                continue;
18481            }
18482            List<String> cols = lookupRelationColumnNames(t, provider);
18483            if (cols == null || cols.isEmpty()) {
18484                return false;
18485            }
18486        }
18487        return true;
18488    }
18489
18490    /**
18491     * Slice 66 — accumulated row type of the LEFT side of a top-level
18492     * {@code TJoin}. Maintained per top-level TJoin so that mixed
18493     * ON/CROSS/USING/NATURAL chains can be reasoned about against the
18494     * full visible row type (NATURAL JOIN's right operand sees every
18495     * column visible in the accumulated left, not just the immediate
18496     * prior table).
18497     *
18498     * <p>{@link #complete} flips to {@code false} when any contributor
18499     * along the chain has no resolvable catalog. A {@code false}
18500     * {@code complete} blocks subsequent NATURAL JoinItems from
18501     * inferring their shared-key list — they reject with a tuned
18502     * catalog-required diagnostic naming whichever side(s) lack
18503     * catalog metadata.
18504     */
18505    private static final class LeftOutputState {
18506        final java.util.LinkedHashMap<String, List<TTable>> columns = new java.util.LinkedHashMap<>();
18507        boolean complete = true;
18508        final List<String> missingAliases = new ArrayList<>();
18509
18510        void markMissing(TTable t) {
18511            complete = false;
18512            String alias = effectiveAliasOf(t);
18513            if (alias != null && !alias.isEmpty()) {
18514                if (!missingAliases.contains(alias)) {
18515                    missingAliases.add(alias);
18516                }
18517            }
18518        }
18519    }
18520
18521    /**
18522     * Slice 66 — result of {@link #naturalSharedKeys}. Either a SUCCESS
18523     * (with the inferred key list in left-output insertion order) or
18524     * one of three failure kinds:
18525     *
18526     * <ul>
18527     *   <li>{@code INCOMPLETE_LEFT}: at least one prior contributor on
18528     *       the accumulated left side had null/empty catalog;</li>
18529     *   <li>{@code MISSING_RIGHT}: right table has null/empty catalog;</li>
18530     *   <li>{@code BOTH_MISSING}: both above conditions hold.</li>
18531     * </ul>
18532     *
18533     * <p>Failures carry diagnostic aliases so the caller can produce
18534     * a side-specific reject message.
18535     */
18536    private static final class NaturalKeyResult {
18537        enum Kind { SUCCESS, INCOMPLETE_LEFT, MISSING_RIGHT, BOTH_MISSING }
18538        final Kind kind;
18539        final List<String> keys;
18540        final List<String> leftMissingAliases;
18541        final String rightAlias;
18542
18543        private NaturalKeyResult(Kind kind, List<String> keys,
18544                                 List<String> leftMissingAliases,
18545                                 String rightAlias) {
18546            this.kind = kind;
18547            this.keys = keys;
18548            this.leftMissingAliases = leftMissingAliases;
18549            this.rightAlias = rightAlias;
18550        }
18551        static NaturalKeyResult success(List<String> keys) {
18552            return new NaturalKeyResult(Kind.SUCCESS, keys, null, null);
18553        }
18554        static NaturalKeyResult incompleteLeft(List<String> missing) {
18555            return new NaturalKeyResult(Kind.INCOMPLETE_LEFT, null, missing, null);
18556        }
18557        static NaturalKeyResult missingRight(String alias) {
18558            return new NaturalKeyResult(Kind.MISSING_RIGHT, null, null, alias);
18559        }
18560        static NaturalKeyResult bothMissing(List<String> missing, String alias) {
18561            return new NaturalKeyResult(Kind.BOTH_MISSING, null, missing, alias);
18562        }
18563    }
18564
18565    /**
18566     * Slice 66 — seed the {@link LeftOutputState} with the top-left
18567     * table of a top-level TJoin. Used at the start of each TJoin walk.
18568     */
18569    private static void seedLeftOutput(LeftOutputState state, TTable t,
18570                                       NameBindingProvider provider) {
18571        if (t == null) return;
18572        List<String> cols = lookupRelationColumnNames(t, provider);
18573        if (cols == null || cols.isEmpty()) {
18574            state.markMissing(t);
18575            return;
18576        }
18577        for (String c : cols) {
18578            if (c == null || c.isEmpty()) continue;
18579            String colLC = c.toLowerCase(Locale.ROOT);
18580            List<TTable> contributors = state.columns.get(colLC);
18581            if (contributors == null) {
18582                contributors = new ArrayList<>();
18583                state.columns.put(colLC, contributors);
18584            }
18585            contributors.add(t);
18586        }
18587    }
18588
18589    /**
18590     * Slice 66 — append the right table of an ON-shaped or CROSS
18591     * JoinItem into the running {@link LeftOutputState}. Each catalog
18592     * column is added as a new entry (or extends the contributor list
18593     * for an existing same-named entry). Mirrors
18594     * {@link #seedLeftOutput} but additive.
18595     */
18596    /**
18597     * Merge the row type accumulated in {@code src} (a nested joined-table
18598     * operand's own {@link LeftOutputState}) into {@code dst} (the enclosing
18599     * join's running state). Used after recursing into a parenthesized
18600     * {@code <joined_table>} right operand so that a NATURAL join FOLLOWING the
18601     * nested operand at the enclosing level sees the sub-tree's columns, while
18602     * the sub-tree's own inner NATURAL inference ran against a fresh state.
18603     */
18604    private static void mergeLeftOutputState(LeftOutputState dst, LeftOutputState src) {
18605        if (src == null) return;
18606        for (Map.Entry<String, List<TTable>> e : src.columns.entrySet()) {
18607            List<TTable> contributors = dst.columns.get(e.getKey());
18608            if (contributors == null) {
18609                contributors = new ArrayList<>();
18610                dst.columns.put(e.getKey(), contributors);
18611            }
18612            contributors.addAll(e.getValue());
18613        }
18614        if (!src.complete) {
18615            dst.complete = false;
18616            for (String a : src.missingAliases) {
18617                if (!dst.missingAliases.contains(a)) {
18618                    dst.missingAliases.add(a);
18619                }
18620            }
18621        }
18622    }
18623
18624    private static void appendRightToLeftOutput(LeftOutputState state, TTable right,
18625                                                NameBindingProvider provider) {
18626        if (right == null) return;
18627        List<String> cols = lookupRelationColumnNames(right, provider);
18628        if (cols == null || cols.isEmpty()) {
18629            state.markMissing(right);
18630            return;
18631        }
18632        for (String c : cols) {
18633            if (c == null || c.isEmpty()) continue;
18634            String colLC = c.toLowerCase(Locale.ROOT);
18635            List<TTable> contributors = state.columns.get(colLC);
18636            if (contributors == null) {
18637                contributors = new ArrayList<>();
18638                state.columns.put(colLC, contributors);
18639            }
18640            contributors.add(right);
18641        }
18642    }
18643
18644    /**
18645     * Slice 66 — merge the right table of a USING-shaped or
18646     * NATURAL-shaped JoinItem. Columns in {@code mergedKeys} are
18647     * appended to the existing same-named contributor list at their
18648     * original output position (no new slot); other columns are
18649     * appended as new entries (or contributed to an existing same-named
18650     * entry — slice-59 plain-vs-plain duplicate admit).
18651     */
18652    private static void mergeRightIntoLeftOutput(LeftOutputState state, TTable right,
18653                                                 NameBindingProvider provider,
18654                                                 List<String> mergedKeys) {
18655        if (right == null) return;
18656        java.util.Set<String> mergedKeysLC = new HashSet<>();
18657        if (mergedKeys != null) {
18658            for (String k : mergedKeys) {
18659                if (k != null && !k.isEmpty()) {
18660                    mergedKeysLC.add(k.toLowerCase(Locale.ROOT));
18661                }
18662            }
18663        }
18664        List<String> cols = lookupRelationColumnNames(right, provider);
18665        if (cols == null || cols.isEmpty()) {
18666            state.markMissing(right);
18667            return;
18668        }
18669        for (String c : cols) {
18670            if (c == null || c.isEmpty()) continue;
18671            String colLC = c.toLowerCase(Locale.ROOT);
18672            // mergedKeysLC.contains(colLC) — append to existing entry;
18673            // !mergedKeysLC.contains(colLC) && state.columns.containsKey(colLC)
18674            //   — append to existing entry (plain-vs-plain duplicate);
18675            // !state.columns.containsKey(colLC) — new entry.
18676            List<TTable> contributors = state.columns.get(colLC);
18677            if (contributors == null) {
18678                contributors = new ArrayList<>();
18679                state.columns.put(colLC, contributors);
18680            }
18681            contributors.add(right);
18682        }
18683    }
18684
18685    /**
18686     * Slice 66 — infer the NATURAL JOIN shared-column list for the
18687     * current JoinItem. Returns one of four results per §6.1 of the
18688     * slice-66 plan. The shared list uses catalog-declared spelling
18689     * from the FIRST contributor that publishes each key (NATURAL has
18690     * no SQL-written key token, so the catalog form is the only
18691     * source of truth).
18692     */
18693    private static NaturalKeyResult naturalSharedKeys(LeftOutputState leftState,
18694                                                     TTable right,
18695                                                     NameBindingProvider provider) {
18696        List<String> rightCols = lookupRelationColumnNames(right, provider);
18697        boolean rightMissing = (rightCols == null || rightCols.isEmpty());
18698        if (!leftState.complete && rightMissing) {
18699            return NaturalKeyResult.bothMissing(leftState.missingAliases,
18700                    effectiveAliasOf(right));
18701        }
18702        if (!leftState.complete) {
18703            return NaturalKeyResult.incompleteLeft(leftState.missingAliases);
18704        }
18705        if (rightMissing) {
18706            return NaturalKeyResult.missingRight(effectiveAliasOf(right));
18707        }
18708        java.util.Set<String> rightLC = new HashSet<>();
18709        for (String c : rightCols) {
18710            if (c != null && !c.isEmpty()) {
18711                rightLC.add(c.toLowerCase(Locale.ROOT));
18712            }
18713        }
18714        List<String> shared = new ArrayList<>();
18715        for (java.util.Map.Entry<String, List<TTable>> e
18716                : leftState.columns.entrySet()) {
18717            String keyLC = e.getKey();
18718            if (rightLC.contains(keyLC)) {
18719                shared.add(firstCatalogSpelling(e.getValue(), keyLC, provider));
18720            }
18721        }
18722        return NaturalKeyResult.success(shared);
18723    }
18724
18725    /**
18726     * Slice 66 — return the catalog-declared spelling of {@code keyLC}
18727     * from the first contributor in insertion order that publishes the
18728     * key with a non-null spelling. Defensive fallback to {@code keyLC}
18729     * if no contributor exposes the spelling (unreachable in practice
18730     * because contributors are catalogued by construction).
18731     */
18732    private static String firstCatalogSpelling(List<TTable> contributors,
18733                                               String keyLC,
18734                                               NameBindingProvider provider) {
18735        if (contributors != null) {
18736            for (TTable t : contributors) {
18737                List<String> cols = lookupRelationColumnNames(t, provider);
18738                if (cols == null) continue;
18739                for (String c : cols) {
18740                    if (c != null && c.equalsIgnoreCase(keyLC)) {
18741                        return c;
18742                    }
18743                }
18744            }
18745        }
18746        return keyLC;
18747    }
18748
18749    /**
18750     * Slice 66 — diagnostic helper. Joins a list of aliases for the
18751     * NATURAL-required catalog reject message.
18752     */
18753    private static String formatAliasList(List<String> aliases) {
18754        if (aliases == null || aliases.isEmpty()) return "<none>";
18755        StringBuilder sb = new StringBuilder();
18756        for (int i = 0; i < aliases.size(); i++) {
18757            if (i > 0) sb.append(", ");
18758            sb.append("'").append(aliases.get(i)).append("'");
18759        }
18760        return sb.toString();
18761    }
18762
18763    /**
18764     * Slice 66 — turn a {@link NaturalKeyResult} failure into a
18765     * structured diagnostic for the gated reject inside
18766     * {@link #buildRelations}.
18767     */
18768    private static String formatNaturalCatalogReject(NaturalKeyResult r) {
18769        switch (r.kind) {
18770            case INCOMPLETE_LEFT:
18771                return "NATURAL JOIN requires catalog metadata for both sides; "
18772                        + "left-side row type is incomplete due to uncatalogued "
18773                        + "relation(s) " + formatAliasList(r.leftMissingAliases)
18774                        + "; supply a TSQLEnv (or in-scope CTE / FROM-subquery "
18775                        + "body) for the missing relation(s), or rewrite as "
18776                        + "JOIN ... ON";
18777            case MISSING_RIGHT:
18778                return "NATURAL JOIN requires catalog metadata for both sides; "
18779                        + "right-side relation '" + r.rightAlias
18780                        + "' has no resolvable column list; supply a TSQLEnv "
18781                        + "(or in-scope CTE / FROM-subquery body) for this "
18782                        + "relation, or rewrite as JOIN ... ON";
18783            case BOTH_MISSING:
18784                return "NATURAL JOIN requires catalog metadata for both sides; "
18785                        + "left-side row type is incomplete due to uncatalogued "
18786                        + "relation(s) " + formatAliasList(r.leftMissingAliases)
18787                        + " and right-side relation '" + r.rightAlias
18788                        + "' also has no resolvable column list; supply a "
18789                        + "TSQLEnv for the missing relation(s), or rewrite "
18790                        + "as JOIN ... ON";
18791            default:
18792                return "NATURAL JOIN: unexpected result kind " + r.kind;
18793        }
18794    }
18795
18796    /**
18797     * Slice 65 — fail fast when a JOIN ON clause references a USING
18798     * merged key by its bare (unqualified) name. JOIN ON requires
18799     * per-position scope (only relations BEFORE that JoinItem are
18800     * visible), which slice 65 does not yet model; the merged-key
18801     * collector applied by other clauses would over-include later
18802     * relations. Reject the shape so the slice-66+ slice can lift
18803     * with proper per-position scope.
18804     *
18805     * <p>This is the narrowed replacement for slice-64's
18806     * {@code rejectUnqualifiedUsingKeyReferences}, which scanned the
18807     * entire SELECT body. Slice 65 admits unqualified USING-key refs
18808     * in every other clause via the merged-key collector.
18809     *
18810     * <p>Qualified references (e.g. {@code a.k}, {@code b.k}) and
18811     * column references whose names don't match a USING key are
18812     * unaffected.
18813     */
18814    private static void rejectUnqualifiedMergedKeyInJoinOn(TSelectSqlStatement select,
18815                                                           NameBindingProvider provider) {
18816        if (select.joins == null) return;
18817        // Walk each TOP-LEVEL TJoin independently — each comma-FROM
18818        // group has its own scope for JOIN ON purposes (codex slice-65
18819        // diff-review round-4 P2 #1). Within one TJoin, walk JoinItems
18820        // in FROM order and track which merged keys (USING-declared OR
18821        // NATURAL-inferred) have been established. An ON clause is only
18822        // checked against keys that are ALREADY merged at that position;
18823        // a bare `k` in an ON before any USING(k) / NATURAL is just
18824        // resolver2's unqualified-binding case.
18825        //
18826        // Slice 66: NATURAL JoinItems contribute their catalog-inferred
18827        // key list to declaredKeysSoFar. When NATURAL would fail the
18828        // catalog requirement (INCOMPLETE_LEFT / MISSING_RIGHT /
18829        // BOTH_MISSING), the preflight silently skips recording this
18830        // JoinItem's keys — the gated reject in buildRelations will
18831        // fire with a catalog-required diagnostic and the user sees
18832        // that error first.
18833        //
18834        // Identity skip set: USING-clause own TObjectNames are
18835        // declarations not references; never matched against the
18836        // declaredKeysSoFar set since the preflight only walks ON
18837        // conditions. Kept as a defensive no-op.
18838        final java.util.Set<TObjectName> skip =
18839                java.util.Collections.newSetFromMap(
18840                        new java.util.IdentityHashMap<TObjectName, Boolean>());
18841        for (int j = 0; j < select.joins.size(); j++) {
18842            TJoin top = select.joins.getJoin(j);
18843            if (top == null) continue;
18844            TJoinItemList items = top.getJoinItems();
18845            if (items == null) continue;
18846            // Reset per top-level TJoin so independent comma-FROM
18847            // groups don't poison each other's ON clauses.
18848            final java.util.Set<String> declaredKeysSoFar = new java.util.HashSet<>();
18849            LeftOutputState leftState = new LeftOutputState();
18850            seedLeftOutput(leftState, top.getTable(), provider);
18851            for (int i = 0; i < items.size(); i++) {
18852                TJoinItem item = items.getJoinItem(i);
18853                if (item == null) continue;
18854                // Check ON FIRST (uses scope BEFORE this JoinItem), then
18855                // record this JoinItem's USING/NATURAL declarations so
18856                // future siblings see them.
18857                TExpression onCond = item.getOnCondition();
18858                if (onCond != null && !declaredKeysSoFar.isEmpty()) {
18859                    final java.util.Set<String> alreadyDeclared =
18860                            new java.util.HashSet<>(declaredKeysSoFar);
18861                    onCond.acceptChildren(new TParseTreeVisitor() {
18862                        int nestedSelectDepth = 0;
18863
18864                        @Override
18865                        public void preVisit(TSelectSqlStatement nested) {
18866                            nestedSelectDepth++;
18867                        }
18868
18869                        @Override
18870                        public void postVisit(TSelectSqlStatement nested) {
18871                            nestedSelectDepth--;
18872                        }
18873
18874                        @Override
18875                        public void preVisit(TObjectName node) {
18876                            if (nestedSelectDepth > 0) return;
18877                            if (skip.contains(node)) return;
18878                            if (node.getDbObjectType() != EDbObjectType.column) return;
18879                            String name = node.getColumnNameOnly();
18880                            if (name == null || name.isEmpty() || "*".equals(name)) return;
18881                            if (!alreadyDeclared.contains(name.toLowerCase(Locale.ROOT))) return;
18882                            String qualifier = node.getTableString();
18883                            if (qualifier == null || qualifier.isEmpty()) {
18884                                throw new SemanticIRBuildException(
18885                                        Diagnostic.error(DiagnosticCode.UNQUALIFIED_MERGED_KEY_IN_JOIN_ON,
18886                                        "unqualified reference to merged key '"
18887                                                + name + "' inside a JOIN ON condition "
18888                                                + "is deferred to a future slice "
18889                                                + "(per-position scope semantics needed); "
18890                                                + "qualify with a table alias "
18891                                                + "(e.g. a." + name + ") to disambiguate", null));
18892                            }
18893                        }
18894                    });
18895                }
18896                // Record this JoinItem's contribution to declaredKeysSoFar
18897                // and update leftState for NATURAL's accumulated-left
18898                // semantics.
18899                TTable rightTable = item.getTable();
18900                TObjectNameList usingCols = item.getUsingColumns();
18901                if (usingCols != null && usingCols.size() > 0) {
18902                    List<String> usingKeyNames = new ArrayList<>(usingCols.size());
18903                    for (int k = 0; k < usingCols.size(); k++) {
18904                        TObjectName n = usingCols.getObjectName(k);
18905                        if (n == null) continue;
18906                        skip.add(n);
18907                        String name = n.getColumnNameOnly();
18908                        if (name != null && !name.isEmpty()) {
18909                            declaredKeysSoFar.add(name.toLowerCase(Locale.ROOT));
18910                            usingKeyNames.add(name);
18911                        }
18912                    }
18913                    if (rightTable != null) {
18914                        mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames);
18915                    }
18916                } else if (isNaturalJoinType(item.getJoinType()) && rightTable != null) {
18917                    NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider);
18918                    if (r.kind == NaturalKeyResult.Kind.SUCCESS) {
18919                        for (String s : r.keys) {
18920                            if (s != null && !s.isEmpty()) {
18921                                declaredKeysSoFar.add(s.toLowerCase(Locale.ROOT));
18922                            }
18923                        }
18924                        mergeRightIntoLeftOutput(leftState, rightTable, provider, r.keys);
18925                    } else {
18926                        // Catalog-required reject fires upstream in
18927                        // buildRelations. Defensively append for state
18928                        // consistency.
18929                        appendRightToLeftOutput(leftState, rightTable, provider);
18930                    }
18931                } else if (rightTable != null) {
18932                    appendRightToLeftOutput(leftState, rightTable, provider);
18933                }
18934            }
18935        }
18936    }
18937
18938    /**
18939     * Slice 65 — compute the {@link UsingScope} for the current SELECT
18940     * body from its FROM-clause USING joins. Walks every {@link TJoin}
18941     * in {@code select.joins} and for each USING(k) JoinItem, builds
18942     * the per-key equivalence class via DSU-like union over prior
18943     * relations + the right-side relation. Then materializes each
18944     * class by a separate FROM-order pass with identity dedup so
18945     * chained USING joins (`a JOIN b USING(k) JOIN c USING(k)`)
18946     * produce {@code [a, b, c]}, never duplicates.
18947     *
18948     * <p>For each class, builds a {@link UsingScope.MergedKeyEntry}
18949     * with FROM-ordered merged source refs (one per relation that
18950     * publishes the key per catalog / in-scope map; unknown-metadata
18951     * priors emit refs unconditionally, matching slice-64's over-
18952     * approximation policy in {@link #populateUsingJoinRefs}).
18953     *
18954     * <p>Ambiguity is precomputed:
18955     * <ul>
18956     *   <li>{@code entries.size() > 1}: two disconnected USING classes
18957     *       share the same key name.</li>
18958     *   <li>{@code entries.size() == 1} AND a FROM relation outside
18959     *       the class has catalog metadata that declares the key:
18960     *       out-of-class same-named column.</li>
18961     * </ul>
18962     *
18963     * <p>Returns {@link UsingScope#EMPTY} when no USING clauses are
18964     * present in {@code select.joins}.
18965     */
18966    private static UsingScope buildUsingScope(TSelectSqlStatement select,
18967                                              NameBindingProvider provider) {
18968        if (select.joins == null) return UsingScope.EMPTY;
18969        // Slice 86 — delegate to the shared TJoinList-taking helper so
18970        // joined UPDATE (slice 86 buildUpdateUsingScope) can reuse the
18971        // identical scope-build pipeline.
18972        return buildUsingScopeFromJoinList(select.joins, provider);
18973    }
18974
18975    /**
18976     * Slice 86 — compute the {@link UsingScope} for a joined UPDATE's
18977     * FROM clause via {@code update.getJoins()}. Mirrors slice-65
18978     * {@link #buildUsingScope}: USING / NATURAL JoinItems contribute
18979     * merged-key equivalence classes; unqualified merged-key references
18980     * in SET RHS / WHERE / RETURNING resolve to the merged source list.
18981     *
18982     * <p>Returns {@link UsingScope#EMPTY} when no USING/NATURAL JoinItems
18983     * appear in the FROM clause.
18984     */
18985    private static UsingScope buildUpdateUsingScope(TUpdateSqlStatement update,
18986                                                    NameBindingProvider provider) {
18987        if (update == null) return UsingScope.EMPTY;
18988        return buildUsingScopeFromJoinList(update.getJoins(), provider);
18989    }
18990
18991    /**
18992     * Slice 86 — shared {@link UsingScope} computation extracted from
18993     * slice-65 {@link #buildUsingScope}. Takes the {@link TJoinList}
18994     * directly so it can be invoked from both SELECT
18995     * ({@link #buildUsingScope}) and joined UPDATE
18996     * ({@link #buildUpdateUsingScope}).
18997     *
18998     * <p>Behavior identical to slice 65/66: per-key DSU union over prior
18999     * relations + right-side relation per top-level {@link TJoin};
19000     * disconnected comma-FROM groups keep their own per-key components;
19001     * NATURAL JoinItems infer shared keys against accumulated left row
19002     * type via {@link LeftOutputState}; ambiguity detection walks all
19003     * FROM relations for out-of-class same-named columns.
19004     */
19005    private static UsingScope buildUsingScopeFromJoinList(TJoinList joins,
19006                                                          NameBindingProvider provider) {
19007        if (joins == null) return UsingScope.EMPTY;
19008        // Pass 1: per-key DSU. For each USING(k) or NATURAL JoinItem,
19009        // union the prior relations PUBLISHING the key (catalog-narrowed
19010        // per codex slice-66 round-1 P1 #1) with the right-side relation,
19011        // scoped to the enclosing top-level TJoin (chained merges within
19012        // one TJoin transitively connect through DSU). Disconnected
19013        // top-level TJoins (comma-FROM) keep their own per-key components.
19014        // Slice 66 maintains a LeftOutputState alongside the loop so
19015        // NATURAL JoinItems can infer their shared-key list against the
19016        // accumulated left row type.
19017        java.util.Map<String, java.util.List<java.util.List<TTable>>> perKeyComponents =
19018                new java.util.LinkedHashMap<>();
19019        // Track the SQL-written spelling of each merged key (the first
19020        // occurrence in FROM order). For USING keys this is the
19021        // SQL-written USING-clause case (slice-64 contract); for
19022        // NATURAL keys this is the catalog-declared spelling from the
19023        // first contributor.
19024        java.util.Map<String, String> originalSpellingByKey = new java.util.HashMap<>();
19025        // Catalog-less NATURAL JOIN degrade: ordered (FROM-order) list of
19026        // left-side relation aliases for NATURAL joins whose shared-key set
19027        // could not be computed. Lets unqualified references to a merged
19028        // NATURAL key resolve deterministically to the left side instead of
19029        // failing as COLUMN_BINDING_NON_EXACT (GSP R6 degrade-not-fail).
19030        java.util.List<String> naturalDegradeAliases = new java.util.ArrayList<>();
19031        // The degrade only applies when the FROM is PURELY NATURAL-connected —
19032        // a single top-level TJoin whose every join item is NATURAL. A mixed
19033        // FROM (plain ON/CROSS/USING item, or a comma-separated second TJoin)
19034        // introduces an independent relation, so an unqualified reference there
19035        // is genuinely ambiguous and MUST still reject (doc §5.3: "do not apply
19036        // this special-case rule to non-NATURAL joins"). Cleared below if any
19037        // non-NATURAL linkage is seen.
19038        boolean pureNatural = (joins.size() == 1);
19039        for (int jx = 0; jx < joins.size(); jx++) {
19040            TJoin top = joins.getJoin(jx);
19041            if (top == null) continue;
19042            TJoinItemList items = top.getJoinItems();
19043            if (items == null) continue;
19044            TTable topTable = top.getTable();
19045            // Slice 66: per-TJoin LeftOutputState for NATURAL inference.
19046            LeftOutputState leftState = new LeftOutputState();
19047            seedLeftOutput(leftState, topTable, provider);
19048            // Per-key in-progress chain for THIS top-level TJoin.
19049            java.util.Map<String, java.util.List<TTable>> inProgressByKey =
19050                    new java.util.HashMap<>();
19051            for (int i = 0; i < items.size(); i++) {
19052                TJoinItem item = items.getJoinItem(i);
19053                if (item == null) continue;
19054                TTable rightTable = item.getTable();
19055                if (rightTable == null) continue;
19056
19057                // Determine merged keys for this JoinItem and the
19058                // emitted spelling per key. Three cases:
19059                //   USING:   keys = syntactic usingCols; spelling = USING-clause text.
19060                //   NATURAL: keys = catalog intersection (when SUCCESS);
19061                //            spelling = catalog spelling.
19062                //   ON/CROSS/other: skip — append to leftState only.
19063                List<String> keyNames;
19064                java.util.Map<String, String> spellingByKeyLC = new java.util.HashMap<>();
19065                TObjectNameList usingCols = item.getUsingColumns();
19066                if (usingCols != null && usingCols.size() > 0) {
19067                    // USING introduces an explicit (non-NATURAL) merge — the
19068                    // FROM is no longer purely NATURAL, so disable the degrade.
19069                    pureNatural = false;
19070                    keyNames = new java.util.ArrayList<>(usingCols.size());
19071                    for (int k = 0; k < usingCols.size(); k++) {
19072                        TObjectName keyNode = usingCols.getObjectName(k);
19073                        if (keyNode == null) continue;
19074                        String keyName = keyNode.getColumnNameOnly();
19075                        if (keyName == null || keyName.isEmpty()) continue;
19076                        keyNames.add(keyName);
19077                        spellingByKeyLC.put(keyName.toLowerCase(Locale.ROOT), keyName);
19078                    }
19079                } else if (isNaturalJoinType(item.getJoinType())) {
19080                    NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider);
19081                    if (r.kind != NaturalKeyResult.Kind.SUCCESS) {
19082                        // Catalog-required reject already fired (or will
19083                        // fire) inside buildRelations. Defensively skip
19084                        // this JoinItem in the scope build; it does NOT
19085                        // contribute to the merged-key scope. Record the
19086                        // left-side relation so unqualified references to a
19087                        // merged NATURAL key degrade rather than reject.
19088                        String leftAlias = effectiveAliasOf(topTable);
19089                        if (leftAlias != null && !leftAlias.isEmpty()
19090                                && !naturalDegradeAliases.contains(leftAlias)) {
19091                            naturalDegradeAliases.add(leftAlias);
19092                        }
19093                        appendRightToLeftOutput(leftState, rightTable, provider);
19094                        continue;
19095                    }
19096                    keyNames = r.keys;
19097                    for (String s : keyNames) {
19098                        if (s != null && !s.isEmpty()) {
19099                            spellingByKeyLC.put(s.toLowerCase(Locale.ROOT), s);
19100                        }
19101                    }
19102                } else {
19103                    // ON / CROSS / other — no merged-key contribution. This
19104                    // join item introduces an independent relation, so an
19105                    // unqualified reference is genuinely ambiguous: disable the
19106                    // NATURAL degrade for the whole FROM.
19107                    pureNatural = false;
19108                    appendRightToLeftOutput(leftState, rightTable, provider);
19109                    continue;
19110                }
19111
19112                // Prior relations for this JoinItem in FROM order:
19113                // topTable + items[0..i-1].getTable().
19114                java.util.List<TTable> priorRelations = new java.util.ArrayList<>();
19115                if (topTable != null) priorRelations.add(topTable);
19116                for (int p = 0; p < i; p++) {
19117                    TJoinItem prev = items.getJoinItem(p);
19118                    if (prev != null && prev.getTable() != null) {
19119                        priorRelations.add(prev.getTable());
19120                    }
19121                }
19122                for (String keyName : keyNames) {
19123                    if (keyName == null || keyName.isEmpty()) continue;
19124                    String keyLC = keyName.toLowerCase(Locale.ROOT);
19125                    // Record the first emitted spelling we see for this
19126                    // key. USING uses SQL-written spelling; NATURAL uses
19127                    // catalog-declared spelling.
19128                    if (!originalSpellingByKey.containsKey(keyLC)) {
19129                        originalSpellingByKey.put(keyLC, spellingByKeyLC.get(keyLC));
19130                    }
19131                    java.util.List<TTable> chain = inProgressByKey.get(keyLC);
19132                    if (chain == null) {
19133                        chain = new java.util.ArrayList<>();
19134                        inProgressByKey.put(keyLC, chain);
19135                    }
19136                    // Slice 66 catalog-narrowed union (codex round-1 P1 #1):
19137                    // for each prior relation, include in this key's
19138                    // equivalence class only if (a) catalog is unknown
19139                    // (over-approximate; slice-64 fallback), or (b)
19140                    // catalog declares the key. Skip if catalog is
19141                    // known and the key is proven absent.
19142                    for (TTable prior : priorRelations) {
19143                        if (containsByIdentity(chain, prior)) continue;
19144                        List<String> priorCols = lookupRelationColumnNames(prior, provider);
19145                        if (priorCols == null) {
19146                            chain.add(prior);
19147                            continue;
19148                        }
19149                        boolean priorPublishes = false;
19150                        for (String pc : priorCols) {
19151                            if (pc != null && pc.equalsIgnoreCase(keyLC)) {
19152                                priorPublishes = true;
19153                                break;
19154                            }
19155                        }
19156                        if (priorPublishes) {
19157                            chain.add(prior);
19158                        }
19159                    }
19160                    if (!containsByIdentity(chain, rightTable)) {
19161                        chain.add(rightTable);
19162                    }
19163                }
19164                // After the merged-key bookkeeping, merge right into
19165                // the leftState so subsequent NATURAL JoinItems see
19166                // the accumulated row type.
19167                mergeRightIntoLeftOutput(leftState, rightTable, provider, keyNames);
19168            }
19169            // Flush this TJoin's in-progress chains as one component
19170            // per key.
19171            for (java.util.Map.Entry<String, java.util.List<TTable>> e :
19172                    inProgressByKey.entrySet()) {
19173                java.util.List<java.util.List<TTable>> bucket = perKeyComponents.get(e.getKey());
19174                if (bucket == null) {
19175                    bucket = new java.util.ArrayList<>();
19176                    perKeyComponents.put(e.getKey(), bucket);
19177                }
19178                bucket.add(e.getValue());
19179            }
19180        }
19181        // Mixed FROM (any non-NATURAL linkage seen): drop the degrade fallback
19182        // so genuinely-ambiguous unqualified references still reject.
19183        if (!pureNatural) {
19184            naturalDegradeAliases.clear();
19185        }
19186        if (perKeyComponents.isEmpty()) {
19187            // No merged-key scope, but a catalog-less NATURAL JOIN may still
19188            // need the degrade fallback for unqualified merged-key refs.
19189            return naturalDegradeAliases.isEmpty()
19190                    ? UsingScope.EMPTY
19191                    : new UsingScope(
19192                            java.util.Collections.<String, java.util.List<UsingScope.MergedKeyEntry>>emptyMap(),
19193                            java.util.Collections.<String, String>emptyMap(),
19194                            naturalDegradeAliases);
19195        }
19196        // Pass 2: materialize EquivalenceClass + MergedKeyEntry per
19197        // component. FROM-order is already preserved by Pass 1's
19198        // accumulation order (priorRelations + rightTable).
19199        java.util.Map<String, java.util.List<UsingScope.MergedKeyEntry>> entriesByName =
19200                new java.util.LinkedHashMap<>();
19201        for (java.util.Map.Entry<String, java.util.List<java.util.List<TTable>>> e :
19202                perKeyComponents.entrySet()) {
19203            String keyLC = e.getKey();
19204            // ColumnRef-emit spelling: SQL-written USING-clause spelling
19205            // (matches slice-64 populateUsingJoinRefs). Falls back to
19206            // keyLC if no spelling was recorded (defensive).
19207            String emitKeyName = originalSpellingByKey.containsKey(keyLC)
19208                    ? originalSpellingByKey.get(keyLC)
19209                    : keyLC;
19210            java.util.List<UsingScope.MergedKeyEntry> entries = new java.util.ArrayList<>();
19211            for (java.util.List<TTable> componentMembers : e.getValue()) {
19212                if (componentMembers.isEmpty()) continue;
19213                UsingScope.EquivalenceClass cls = new UsingScope.EquivalenceClass(
19214                        keyLC, componentMembers);
19215                java.util.List<ColumnRef> sources = new java.util.ArrayList<>();
19216                java.util.Set<String> seenAliases = new java.util.HashSet<>();
19217                for (TTable t : componentMembers) {
19218                    String effAlias = effectiveAliasOf(t);
19219                    if (effAlias == null || effAlias.isEmpty()) continue;
19220                    String aliasKey = effAlias.toLowerCase(Locale.ROOT);
19221                    if (seenAliases.contains(aliasKey)) continue;
19222                    java.util.List<String> cols = lookupRelationColumnNames(t, provider);
19223                    if (cols == null) {
19224                        // Metadata-unknown: emit ref (over-approximate).
19225                        sources.add(new ColumnRef(effAlias, emitKeyName));
19226                        seenAliases.add(aliasKey);
19227                        continue;
19228                    }
19229                    for (String c : cols) {
19230                        if (c != null && c.equalsIgnoreCase(keyLC)) {
19231                            sources.add(new ColumnRef(effAlias, emitKeyName));
19232                            seenAliases.add(aliasKey);
19233                            break;
19234                        }
19235                    }
19236                }
19237                if (!sources.isEmpty()) {
19238                    entries.add(new UsingScope.MergedKeyEntry(cls, sources));
19239                }
19240            }
19241            if (!entries.isEmpty()) {
19242                entriesByName.put(keyLC, entries);
19243            }
19244        }
19245        if (entriesByName.isEmpty()) {
19246            return naturalDegradeAliases.isEmpty()
19247                    ? UsingScope.EMPTY
19248                    : new UsingScope(
19249                            java.util.Collections.<String, java.util.List<UsingScope.MergedKeyEntry>>emptyMap(),
19250                            java.util.Collections.<String, String>emptyMap(),
19251                            naturalDegradeAliases);
19252        }
19253        // Pass 3: precompute ambiguity per key.
19254        java.util.Map<String, String> ambiguityByName = new java.util.HashMap<>();
19255        java.util.List<TTable> allFromRelations = walkAllFromRelationsFromJoinList(joins);
19256        for (java.util.Map.Entry<String, java.util.List<UsingScope.MergedKeyEntry>> e :
19257                entriesByName.entrySet()) {
19258            String keyLC = e.getKey();
19259            java.util.List<UsingScope.MergedKeyEntry> entries = e.getValue();
19260            if (entries.size() > 1) {
19261                ambiguityByName.put(keyLC,
19262                        "multiple disconnected USING(" + keyLC + ") equivalence "
19263                                + "classes appear in this FROM (their merged "
19264                                + "columns share the same key name)");
19265                continue;
19266            }
19267            // Single class. Walk all FROM relations; if any out-of-class
19268            // relation is catalog-known to publish the key, mark ambiguous.
19269            UsingScope.EquivalenceClass cls = entries.get(0).getEquivClass();
19270            java.util.IdentityHashMap<TTable, Boolean> inClass = new java.util.IdentityHashMap<>();
19271            for (TTable m : cls.getMembers()) inClass.put(m, Boolean.TRUE);
19272            for (TTable r : allFromRelations) {
19273                if (inClass.containsKey(r)) continue;
19274                // Slice 65 diff-review round-3 P2 #2 (slice 103 update):
19275                // post-slice-103 both branches return the same data —
19276                // `lookupRelationColumnNames` consults the in-scope map
19277                // populated from `ctePublishedColumns`, which now holds
19278                // the renamed names. The discriminator is retained as a
19279                // defense-in-depth path for any call site that bypasses
19280                // the SELECT-side CTE walker but still wants to detect
19281                // renamed-key collisions; the slice-103 path falls into
19282                // the `lookupRelationColumnNames` branch and gets the
19283                // same renamed list.
19284                java.util.List<String> cols;
19285                if (hasExplicitCteColumnList(r)) {
19286                    cols = explicitCteColumnNames(r);
19287                } else {
19288                    cols = lookupRelationColumnNames(r, provider);
19289                }
19290                if (cols == null) continue;          // unknown → trust writer
19291                for (String c : cols) {
19292                    if (c != null && c.equalsIgnoreCase(keyLC)) {
19293                        String outAlias = effectiveAliasOf(r);
19294                        ambiguityByName.put(keyLC,
19295                                "the USING(" + keyLC + ") merged column collides "
19296                                        + "with column '" + keyLC + "' on relation '"
19297                                        + (outAlias != null ? outAlias : "<unnamed>")
19298                                        + "' which is not part of the USING equivalence class");
19299                        break;
19300                    }
19301                }
19302                if (ambiguityByName.containsKey(keyLC)) break;
19303            }
19304        }
19305        return new UsingScope(entriesByName, ambiguityByName, naturalDegradeAliases);
19306    }
19307
19308    private static boolean containsByIdentity(java.util.List<TTable> list, TTable t) {
19309        for (TTable x : list) {
19310            if (x == t) return true;
19311        }
19312        return false;
19313    }
19314
19315    /**
19316     * Slice 65 — every FROM-clause relation reachable directly from
19317     * {@code select.joins} (every {@code top.getTable()} + every
19318     * {@code joinItem.getTable()}). Used by {@link #buildUsingScope}
19319     * to detect out-of-equivalence-class same-named columns.
19320     */
19321    private static java.util.List<TTable> walkAllFromRelations(TSelectSqlStatement select) {
19322        if (select == null) return new java.util.ArrayList<>();
19323        return walkAllFromRelationsFromJoinList(select.joins);
19324    }
19325
19326    /**
19327     * Slice 86 — shared {@link TJoinList}-taking walker for ambiguity
19328     * detection inside {@link #buildUsingScopeFromJoinList}. Used by both
19329     * SELECT ({@link #walkAllFromRelations}) and joined UPDATE
19330     * ({@link #buildUpdateUsingScope}).
19331     */
19332    private static java.util.List<TTable> walkAllFromRelationsFromJoinList(TJoinList joins) {
19333        java.util.List<TTable> out = new java.util.ArrayList<>();
19334        if (joins == null) return out;
19335        for (int j = 0; j < joins.size(); j++) {
19336            TJoin top = joins.getJoin(j);
19337            if (top == null) continue;
19338            if (top.getTable() != null) out.add(top.getTable());
19339            TJoinItemList items = top.getJoinItems();
19340            if (items == null) continue;
19341            for (int i = 0; i < items.size(); i++) {
19342                TJoinItem item = items.getJoinItem(i);
19343                if (item != null && item.getTable() != null) {
19344                    out.add(item.getTable());
19345                }
19346            }
19347        }
19348        return out;
19349    }
19350
19351    /**
19352     * Slice 64 — true iff any TJoinItem in {@code select.joins}
19353     * carries a non-empty USING list. Used by {@link #tryExpandStar}
19354     * to defer bare {@code *} over USING JOIN to S65 (merged-key
19355     * output naming).
19356     */
19357    private static boolean hasUsingInFromClause(TSelectSqlStatement select) {
19358        if (select.joins == null) return false;
19359        for (int j = 0; j < select.joins.size(); j++) {
19360            TJoin top = select.joins.getJoin(j);
19361            if (top == null) continue;
19362            TJoinItemList items = top.getJoinItems();
19363            if (items == null) continue;
19364            for (int i = 0; i < items.size(); i++) {
19365                TJoinItem item = items.getJoinItem(i);
19366                if (item != null
19367                        && item.getUsingColumns() != null
19368                        && item.getUsingColumns().size() > 0) {
19369                    return true;
19370                }
19371            }
19372        }
19373        return false;
19374    }
19375
19376    /**
19377     * Slice 66 — true iff any JoinItem is NATURAL AND its inferred
19378     * shared-column list is non-empty (catalog-resolved on both sides
19379     * and the intersection contains at least one column).
19380     *
19381     * <p>Routes bare {@code *} expansion through
19382     * {@link #expandBareStarOverUsing} when NATURAL contributes merged
19383     * keys. NATURAL with empty intersection or with INCOMPLETE_LEFT /
19384     * MISSING_RIGHT / BOTH_MISSING returns false for THIS JoinItem
19385     * (codex slice-66 round-4 P2 #3) — the bare-* path then falls
19386     * through to per-relation expansion, which is correct for an
19387     * empty-intersection NATURAL (Cartesian, no dedup needed). The
19388     * catalog-required reject for NATURAL fires upstream inside
19389     * {@link #buildRelations} before bare-* runs.
19390     *
19391     * <p>The walk maintains its own per-top-level-TJoin
19392     * {@link LeftOutputState} (so NATURAL inference against accumulated
19393     * left works the same way as in {@link #buildUsingScope} and
19394     * {@link #buildRelations}).
19395     */
19396    private static boolean hasNaturalJoinMergedKeysInFromClause(
19397            TSelectSqlStatement select, NameBindingProvider provider) {
19398        if (select.joins == null) return false;
19399        for (int j = 0; j < select.joins.size(); j++) {
19400            TJoin top = select.joins.getJoin(j);
19401            if (top == null) continue;
19402            TJoinItemList items = top.getJoinItems();
19403            if (items == null) continue;
19404            LeftOutputState leftState = new LeftOutputState();
19405            seedLeftOutput(leftState, top.getTable(), provider);
19406            for (int i = 0; i < items.size(); i++) {
19407                TJoinItem item = items.getJoinItem(i);
19408                if (item == null) continue;
19409                TTable rightTable = item.getTable();
19410                if (rightTable == null) continue;
19411                if (isNaturalJoinType(item.getJoinType())) {
19412                    NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider);
19413                    if (r.kind == NaturalKeyResult.Kind.SUCCESS
19414                            && r.keys != null && !r.keys.isEmpty()) {
19415                        return true;
19416                    }
19417                    appendRightToLeftOutput(leftState, rightTable, provider);
19418                    continue;
19419                }
19420                TObjectNameList usingCols = item.getUsingColumns();
19421                if (usingCols != null && usingCols.size() > 0) {
19422                    List<String> usingKeyNames = new ArrayList<>(usingCols.size());
19423                    for (int k = 0; k < usingCols.size(); k++) {
19424                        TObjectName usingKey = usingCols.getObjectName(k);
19425                        if (usingKey == null) continue;
19426                        String keyName = usingKey.getColumnNameOnly();
19427                        if (keyName != null && !keyName.isEmpty()) {
19428                            usingKeyNames.add(keyName);
19429                        }
19430                    }
19431                    mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames);
19432                } else {
19433                    appendRightToLeftOutput(leftState, rightTable, provider);
19434                }
19435            }
19436        }
19437        return false;
19438    }
19439
19440    /**
19441     * Two relations sharing the same effective alias would make
19442     * {@link ColumnRef#getRelationAlias()} ambiguous in the IR. Resolver2
19443     * may already flag column references in this case, but the IR-level
19444     * invariant still needs to hold.
19445     */
19446    /**
19447     * Reject two FROM relations whose aliases are the SAME NAME under the
19448     * vendor's identifier rules — they would make every {@link ColumnRef}
19449     * qualified by that alias ambiguous.
19450     *
19451     * <p>Slice 182 (GitHub #707, codex round-1 P2): this compared raw strings
19452     * with {@code HashSet<String>.add}, so it only caught a byte-identical
19453     * repeat. Oracle {@code FROM employees A1, departments a1} is ONE alias
19454     * under Oracle's case-folding rules but two distinct strings, and it built
19455     * a StatementGraph with two same-named relations. The outer / CTE-body /
19456     * FROM-subquery-body paths were shielded by
19457     * {@link #preflightDirectFromList}, which folds with
19458     * {@code toLowerCase(Locale.ROOT)}; the synthetic body contexts never call
19459     * that preflight, so the miss was reachable there — already via
19460     * {@code JOIN … ON}, and (before this fix) newly via comma FROM as well.
19461     *
19462     * <p>Keyed by {@link SQLUtil#canonKey} per the repo's one-canonical-equality
19463     * rule, which is also stricter than the preflight's naive fold in the right
19464     * direction: a delimited {@code "a1"} stays distinct from an unquoted
19465     * {@code a1} on a case-folding vendor, because quote state is part of a
19466     * name's identity. Keyed as {@code dotTable} because a relation alias lives
19467     * in the table namespace — the same object type every resolver2 / dlineage
19468     * alias comparison already uses.
19469     *
19470     * <p>SECOND CONDITION, APPLIED ONLY TO A COMMA FROM LIST — the
19471     * comma-promotion collision guard (codex round-2 P2, narrowed after codex
19472     * round-3 P2). Two aliases that canonical equality calls DISTINCT can still
19473     * collapse in the comma-join promotion, which keys aliases by
19474     * {@code toLowerCase(Locale.ROOT)} in {@code collectConjunctRelationAliases}
19475     * and {@code endpointAliasesLowerCase}. Oracle
19476     * {@code FROM employees "A1", departments "a1"} is two legitimately distinct
19477     * names that fold to one key, and the measured result was a SILENT WRONG
19478     * ANSWER: the graph came back {@code IMPLICIT_CROSS} with no conditions,
19479     * because the WHERE predicate was never seen as spanning two sides and so
19480     * was never promoted, while the {@code JOIN … ON} spelling of the same
19481     * query correctly returned {@code INNER}.
19482     *
19483     * <p>Gated on {@code fromUsesCommaList} because the promotion is the ONLY
19484     * consumer that turns this collision into a wrong answer on a path this
19485     * slice opened. Round 3 supplied the counterexample that forced the gate:
19486     * {@code EXISTS (SELECT 1 FROM employees "A1" JOIN departments "a1" ON 1=1)}
19487     * was analyzed CORRECTLY before this slice (two relations, INNER/EXPLICIT) —
19488     * {@code emitLineageForStatement}'s alias map does overwrite an entry, but a
19489     * source-free output never consults it — so an ungated reject deleted a
19490     * correct result, which this repo forbids outright.
19491     *
19492     * <p>The gate makes the guard non-regressing BY CONSTRUCTION: a comma FROM
19493     * inside a body context did not analyze at all before this slice, and on the
19494     * outer / CTE-body / FROM-subquery-body paths the same pair is already
19495     * refused by {@code preflightOneTable}'s own lowercase fold. So no shape
19496     * that previously produced a correct graph stops producing one.
19497     *
19498     * <p>FOLLOW-UP, out of scope for #707: key the promotion (and the lineage
19499     * alias map, whose last-write-wins overwrite is a pre-existing latent bug
19500     * reachable only when a colliding alias feeds a sourced output) by canonical
19501     * identity, then drop this second condition and the fold in
19502     * {@code preflightOneTable}. Piecemeal is unsafe — loosening a guard before
19503     * its consumers are collision-safe turns a visible rejection into a silent
19504     * overwrite.
19505     */
19506    private static void rejectDuplicateAliases(List<RelationSource> relations,
19507                                               EDbVendor vendor,
19508                                               boolean fromUsesCommaList) {
19509        Set<Object> seenCanonical = new HashSet<>();
19510        Set<String> seenPromotionKey = new HashSet<>();
19511        for (RelationSource r : relations) {
19512            String alias = r.getAlias();
19513            Object canonical = (alias == null)
19514                    ? null
19515                    : SQLUtil.canonKey(vendor, ESQLDataObjectType.dotTable, alias);
19516            if (!seenCanonical.add(canonical)) {
19517                throw new SemanticIRBuildException(
19518                        Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS,
19519                        "duplicate relation alias '" + alias
19520                                + "' is not supported (would make ColumnRef ambiguous)", null));
19521            }
19522            // Distinct names that still collide in the comma-join promotion's
19523            // lowercased alias sets — see the javadoc. Rejected rather than
19524            // analyzed, because analyzing publishes IMPLICIT_CROSS for what is
19525            // really an INNER join, with nothing to flag it. Only the comma
19526            // form reaches that promotion, so only the comma form is gated.
19527            if (!fromUsesCommaList) continue;
19528            String promotionKey = (alias == null)
19529                    ? null
19530                    : alias.toLowerCase(Locale.ROOT);
19531            if (!seenPromotionKey.add(promotionKey)) {
19532                throw new SemanticIRBuildException(
19533                        Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS,
19534                        "relation aliases '" + alias + "' and an earlier alias differ only "
19535                                + "in letter case; they are distinct names for this vendor, "
19536                                + "but a comma-separated FROM list with such a pair cannot "
19537                                + "yet be analyzed (the join-condition promotion would "
19538                                + "collide them and report a cross join). Rename one of "
19539                                + "them, or use an explicit JOIN ... ON", null));
19540            }
19541        }
19542    }
19543
19544    private static RelationSource buildRelation(TTable table, NameBindingProvider provider,
19545                                                boolean allowFromSubqueries) {
19546        return buildRelation(table, provider, allowFromSubqueries,
19547                RelationSource.NO_INSTANCE_ID);
19548    }
19549
19550    private static RelationSource buildRelation(TTable table, NameBindingProvider provider,
19551                                                boolean allowFromSubqueries, int instanceId) {
19552        // Reject FROM-subqueries when the caller did not extract them as
19553        // separate statements. After slice 18 the still-uncovered scopes
19554        // are scalar bodies (slice-11 boundary), set-op branches (slice-16
19555        // boundary), and set-op CTE bodies (build()'s set-op CTE dispatch
19556        // passes allowFromSubqueries=false to each branch).
19557        if (table.getTableType() == gudusoft.gsqlparser.ETableSource.subquery
19558                && !allowFromSubqueries) {
19559            // Slice 74: use effectiveAliasOf so anonymous subqueries
19560            // surface their synth name in the diagnostic instead of the
19561            // empty-string the prior `getAliasName() == null` ternary
19562            // produced.
19563            String bodyAlias = effectiveAliasOf(table);
19564            throw new SemanticIRBuildException(
19565                    Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_BODY_CONTEXT_NOT_SUPPORTED,
19566                    "FROM-clause subquery '" + (bodyAlias == null || bodyAlias.isEmpty() ? "<anonymous>" : bodyAlias)
19567                            + "' inside a scalar body, set-op branch, or set-op CTE body is not supported yet", table));
19568        }
19569        RelationBinding binding = provider.bindRelation(table);
19570        if (binding == null) {
19571            throw new SemanticIRBuildException(
19572                    Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED,
19573                    "could not bind table " + safeName(table) + " (only base tables and in-scope CTEs are supported)", table));
19574        }
19575        // Effective alias: prefer the SQL-written alias, then the slice-74
19576        // synthetic alias for anonymous FROM-subqueries, then the table
19577        // name (mirrors effectiveAliasOf so RelationSource.alias and
19578        // ColumnRef.relationAlias stay aligned).
19579        String alias = effectiveAliasOf(table);
19580        // Slice 164 (S3): attach the FROM-clause table-reference span
19581        // (includes alias, e.g. "t1 a"). Null-safe via SourceSpan.of.
19582        // Slice 179 (R4): carry the stable FROM-order instanceId.
19583        return new RelationSource(alias, binding, SourceSpan.of(table), instanceId);
19584    }
19585
19586    private static String safeName(TTable t) {
19587        try {
19588            return t.getName();
19589        } catch (RuntimeException e) {
19590            return "<unnamed>";
19591        }
19592    }
19593
19594    // -----------------------------------------------------------------
19595    // Slice 167 (join-analysis S6) — ordered, chain-correct structured
19596    // JoinGraph (GAP 1). Walks the same TJoinList the flat join refs come
19597    // from, but stops flattening: each TJoinItem becomes a JoinEntity, and
19598    // comma-separated top-level TJoins become IMPLICIT_CROSS entities.
19599    //
19600    // Left-deep chaining: the right endpoint is always the newly added
19601    // RELATION; the left endpoint is the first RELATION for join order 0,
19602    // else the JOIN_RESULT accumulated by the prior join. No predicate
19603    // decomposition here (that is slice 168). The flat joinColumnRefs are
19604    // untouched.
19605    // -----------------------------------------------------------------
19606    /**
19607     * R8 — append predicate-derived semi-join entities to a FROM-clause
19608     * {@link JoinGraph}. Each {@link SemiJoinFact} becomes a
19609     * {@link SemanticJoinType#SEMI} / {@link SemanticJoinType#ANTI_SEMI}
19610     * {@link JoinEntity} whose left endpoint is the outer relation owning
19611     * the correlated outer column (resolved from {@code relations} by
19612     * alias; falls back to the first FROM relation) and whose right
19613     * endpoint is the lifted subquery's {@link JoinEndpointKind#SUBQUERY}
19614     * block. Orders continue after the FROM joins. Returns the input graph
19615     * unchanged when there are no facts.
19616     */
19617    private static JoinGraph appendSemiJoins(JoinGraph base,
19618                                             List<SemiJoinFact> semiFacts,
19619                                             List<RelationSource> relations) {
19620        if (semiFacts == null || semiFacts.isEmpty()) {
19621            return base;
19622        }
19623        List<JoinEntity> joins = new ArrayList<>(base.getJoins());
19624        int order = joins.size();
19625        for (SemiJoinFact f : semiFacts) {
19626            JoinEndpoint left = f.outerAnchorUnknown
19627                    ? unknownOuterEndpointFor(relations, joins, order)
19628                    : outerEndpointFor(f.outerAliases, relations, joins);
19629            if (left == null) {
19630                // No FROM relation to anchor the outer side — skip rather
19631                // than fabricate an endpoint (honest omission).
19632                continue;
19633            }
19634            JoinEndpoint right = JoinEndpoint.subquery(f.innerStatementIndex, f.innerLabel);
19635            joins.add(new JoinEntity(f.polarity, left, right, order++,
19636                    JoinSourceSyntax.SEMI, /*natural=*/ false,
19637                    /*usingColumns=*/ null, f.conditions, f.span,
19638                    f.conditionText, /*lateral=*/ false));
19639        }
19640        return joins.isEmpty() ? JoinGraph.EMPTY : new JoinGraph(joins);
19641    }
19642
19643    /**
19644     * R8 — left endpoint for a semi-join. When the correlation references a
19645     * single outer relation, that relation is the endpoint. When it
19646     * references several (or none can be matched but FROM joins exist), the
19647     * endpoint is the accumulated outer rowset (a {@link JoinEndpointKind#JOIN_RESULT}
19648     * over the last FROM join, carrying every FROM alias) — codex R8 [P2]:
19649     * a single-relation endpoint would be wrong when the semi-join's
19650     * conditions span multiple outer relations. Falls back to the first
19651     * FROM relation; returns {@code null} only when there is no FROM
19652     * relation to anchor.
19653     */
19654    /**
19655     * Degrade only (GitHub #708) — left endpoint for a semi-join whose body was
19656     * never analyzed. The correlation target is UNKNOWN, so anchoring to a
19657     * specific relation would publish a wrong edge: for
19658     * {@code FROM A, B ... EXISTS (<unsupported>)} correlating to B, naming A
19659     * is not a missing fact, it is a false one. Anchor to the whole outer
19660     * rowset ({@link JoinEndpointKind#JOIN_RESULT}) instead — true whichever
19661     * relation the body referenced.
19662     *
19663     * <p>A single-relation host is the one case where the relation IS provable
19664     * (there is nothing else it could correlate to), so it keeps the precise
19665     * RELATION endpoint.
19666     */
19667    private static JoinEndpoint unknownOuterEndpointFor(List<RelationSource> relations,
19668                                                        List<JoinEntity> priorJoins,
19669                                                        int order) {
19670        if (relations == null || relations.isEmpty()) {
19671            return null;
19672        }
19673        if (relations.size() == 1) {
19674            return outerEndpointFor(Collections.<String>emptyList(), relations, priorJoins);
19675        }
19676        List<String> allAliases = new ArrayList<>();
19677        for (RelationSource rs : relations) {
19678            if (rs.getAlias() != null && !rs.getAlias().isEmpty()) {
19679                allAliases.add(rs.getAlias());
19680            }
19681        }
19682        // Order of the join that produced the outer rowset. Prefer the last
19683        // real join; fall back to this entity's own order slot when the host
19684        // has several relations but no join entity to point at.
19685        int producing = (priorJoins != null && !priorJoins.isEmpty())
19686                ? priorJoins.get(priorJoins.size() - 1).getOrder()
19687                : order;
19688        return JoinEndpoint.joinResult(producing, allAliases);
19689    }
19690
19691    private static JoinEndpoint outerEndpointFor(List<String> outerAliases,
19692                                                 List<RelationSource> relations,
19693                                                 List<JoinEntity> priorJoins) {
19694        if (relations == null || relations.isEmpty()) {
19695            return null;
19696        }
19697        // Multiple distinct outer relations → the joined outer rowset.
19698        if (outerAliases != null && countMatchingRelations(outerAliases, relations) > 1
19699                && priorJoins != null && !priorJoins.isEmpty()) {
19700            List<String> allAliases = new ArrayList<>();
19701            for (RelationSource rs : relations) {
19702                if (rs.getAlias() != null && !rs.getAlias().isEmpty()) {
19703                    allAliases.add(rs.getAlias());
19704                }
19705            }
19706            int lastOrder = priorJoins.get(priorJoins.size() - 1).getOrder();
19707            return JoinEndpoint.joinResult(lastOrder, allAliases);
19708        }
19709        // Single outer relation: match it by alias, else the first FROM relation.
19710        RelationSource chosen = null;
19711        if (outerAliases != null) {
19712            for (String a : outerAliases) {
19713                for (RelationSource rs : relations) {
19714                    if (rs.getAlias() != null && rs.getAlias().equalsIgnoreCase(a)) {
19715                        chosen = rs;
19716                        break;
19717                    }
19718                }
19719                if (chosen != null) break;
19720            }
19721        }
19722        if (chosen == null) {
19723            chosen = relations.get(0);
19724        }
19725        String alias = chosen.getAlias();
19726        if (alias == null || alias.isEmpty()) {
19727            return null;
19728        }
19729        String qn = chosen.getBinding() == null
19730                ? null : chosen.getBinding().getQualifiedName();
19731        return JoinEndpoint.relation(alias, qn, chosen.getInstanceId());
19732    }
19733
19734    /** R8 — count how many FROM relations are named by {@code outerAliases}. */
19735    private static int countMatchingRelations(List<String> outerAliases,
19736                                              List<RelationSource> relations) {
19737        int n = 0;
19738        for (RelationSource rs : relations) {
19739            if (rs.getAlias() == null) continue;
19740            for (String a : outerAliases) {
19741                if (rs.getAlias().equalsIgnoreCase(a)) {
19742                    n++;
19743                    break;
19744                }
19745            }
19746        }
19747        return n;
19748    }
19749
19750    private static JoinGraph buildJoinGraph(TSelectSqlStatement select,
19751                                            NameBindingProvider provider) {
19752        if (select == null || select.getJoins() == null || select.getJoins().size() == 0) {
19753            return JoinGraph.EMPTY;
19754        }
19755        List<JoinEntity> entities = new ArrayList<JoinEntity>();
19756        JoinEndpoint currentLeft = null;
19757        List<String> accumulated = new ArrayList<String>();
19758        int order = 0;
19759        boolean firstRelationSeen = false;
19760        // Slice 179 (R4): same FROM-order ordinal as buildRelations (driver
19761        // first, then each join-item right table) so endpoint instanceIds
19762        // align with RelationSource instanceIds by construction.
19763        int relInstanceId = 0;
19764
19765        for (int j = 0; j < select.getJoins().size(); j++) {
19766            TJoin topJoin = select.getJoins().getJoin(j);
19767            if (topJoin == null) continue;
19768            TTable driver = topJoin.getTable();
19769            if (driver != null) {
19770                String dAlias = effectiveAliasOf(driver);
19771                String dQn = relationQnOf(driver);
19772                if (!firstRelationSeen) {
19773                    currentLeft = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId++);
19774                    accumulated.add(currentLeft.getAlias());
19775                    firstRelationSeen = true;
19776                } else {
19777                    // Comma-FROM: implicit cross between the accumulated
19778                    // left result and this new top-level relation. A
19779                    // `FROM m, LATERAL (...)` operand is a lateral cross — mark
19780                    // the entity lateral (and surface LATERAL syntax) so a
19781                    // consumer does not flag it as an accidental cartesian.
19782                    boolean driverLateral = isLateralTable(driver);
19783                    JoinEndpoint right = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId++);
19784                    entities.add(new JoinEntity(SemanticJoinType.IMPLICIT_CROSS,
19785                            currentLeft, right, order,
19786                            driverLateral ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.COMMA,
19787                            false, null, null, SourceSpan.of(driver), null, driverLateral));
19788                    accumulated.add(right.getAlias());
19789                    currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated));
19790                    order++;
19791                }
19792            } else if (topJoin.getJoin() != null) {
19793                // Parenthesized joined-table as the top-level driver operand,
19794                // e.g. `FROM (a JOIN b ON …) JOIN c ON …`. Recurse to emit the
19795                // sub-tree's entities and obtain its result endpoint, then treat
19796                // it as this position's left input (or comma-cross partner).
19797                int[] o = {order};
19798                int[] rid = {relInstanceId};
19799                JoinEndpoint sub = buildJoinGraphForJoin(topJoin.getJoin(), provider, entities, o, rid);
19800                order = o[0];
19801                relInstanceId = rid[0];
19802                if (sub == null) return JoinGraph.EMPTY;
19803                if (!firstRelationSeen) {
19804                    currentLeft = sub;
19805                    accumulated.addAll(aliasesOf(sub));
19806                    firstRelationSeen = true;
19807                } else {
19808                    entities.add(new JoinEntity(SemanticJoinType.IMPLICIT_CROSS,
19809                            currentLeft, sub, order, JoinSourceSyntax.COMMA,
19810                            false, null, null, SourceSpan.of(topJoin), null));
19811                    accumulated.addAll(aliasesOf(sub));
19812                    currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated));
19813                    order++;
19814                }
19815            }
19816            TJoinItemList items = topJoin.getJoinItems();
19817            if (items == null) continue;
19818            for (int i = 0; i < items.size(); i++) {
19819                TJoinItem item = items.getJoinItem(i);
19820                if (item == null) continue;
19821                TTable right = item.getTable();
19822                JoinEndpoint rightEp;
19823                if (right != null) {
19824                    String rAlias = effectiveAliasOf(right);
19825                    String rQn = relationQnOf(right);
19826                    rightEp = JoinEndpoint.relation(safeAlias(rAlias, rQn), rQn, relInstanceId++);
19827                } else if (item.getJoin() != null) {
19828                    // Parenthesized joined-table as the right operand,
19829                    // e.g. `FROM a JOIN (b JOIN c ON …) ON …`. Recurse to emit
19830                    // the sub-tree's inner entities and use its result endpoint
19831                    // as the right side of this outer join.
19832                    int[] o = {order};
19833                    int[] rid = {relInstanceId};
19834                    rightEp = buildJoinGraphForJoin(item.getJoin(), provider, entities, o, rid);
19835                    order = o[0];
19836                    relInstanceId = rid[0];
19837                    if (rightEp == null) return JoinGraph.EMPTY;
19838                } else {
19839                    // Genuinely neither a table nor a nested join — skip safely
19840                    // (no endpoint guesswork).
19841                    continue;
19842                }
19843                if (currentLeft == null) {
19844                    // A join item with no established left input — never guess a
19845                    // left endpoint; surface the graph as empty.
19846                    return JoinGraph.EMPTY;
19847                }
19848                SemanticJoinType jt = mapSemanticJoinType(item.getJoinType());
19849                boolean natural = isNaturalJoinType(item.getJoinType());
19850                // Lateral via the APPLY join type (SQL Server) OR a LATERAL
19851                // right operand (ANSI / PostgreSQL `CROSS JOIN LATERAL (...)`,
19852                // `JOIN LATERAL (...) ON ...`, `LEFT JOIN LATERAL (...) ON ...`).
19853                boolean lateral = isLateralJoinType(item.getJoinType())
19854                        || isLateralTable(item.getTable());
19855                List<String> using = usingColumnNames(item);
19856                // Slice 168 (S7): decompose this join's ON condition into
19857                // resolved predicate trees and attach them to the entity.
19858                // CROSS/OUTER APPLY carry no ON (correlation is inside the
19859                // right operand), so onPredicates stays null and conditions
19860                // stay empty — the correlation is preserved by the child
19861                // StatementGraph the right derived table produces.
19862                List<Predicate> onPredicates = (item.getOnCondition() != null)
19863                        ? PredicateTreeBuilder.build(item.getOnCondition(), provider)
19864                        : null;
19865                // Slice 177 (R1): verbatim ON-condition text (null when no ON).
19866                String conditionText = verbatimText(item.getOnCondition());
19867                // A condition-less lateral join (APPLY, CROSS JOIN LATERAL) is
19868                // self-described by sourceSyntax=LATERAL plus the isLateral flag
19869                // so a consumer never mistakes its empty condition for a buggy
19870                // cartesian INNER. A lateral join that DOES carry an explicit
19871                // condition (`JOIN LATERAL ... ON`, or a USING/NATURAL form)
19872                // keeps EXPLICIT syntax — the user wrote an explicit join
19873                // condition; its laterality rides on the orthogonal isLateral
19874                // flag only.
19875                boolean hasExplicitCondition = item.getOnCondition() != null
19876                        || (using != null && !using.isEmpty()) || natural;
19877                JoinSourceSyntax syntax = (lateral && !hasExplicitCondition)
19878                        ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.EXPLICIT;
19879                // R7: a USING join's TJoinItem ends at the last USING column
19880                // name, not the closing ')', so SourceSpan.of(item) drops the
19881                // ')'. Extend the span end to one-past the closing ')' for
19882                // USING joins. ON / NATURAL / CROSS items already reach their
19883                // true clause end, so they keep SourceSpan.of(item).
19884                SourceSpan joinSpan = usingJoinSpan(item);
19885                entities.add(new JoinEntity(jt, currentLeft, rightEp, order,
19886                        syntax, natural, using, onPredicates,
19887                        joinSpan, conditionText, lateral));
19888                accumulated.addAll(aliasesOf(rightEp));
19889                currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated));
19890                order++;
19891            }
19892        }
19893        return new JoinGraph(entities);
19894    }
19895
19896    /**
19897     * Recursive join-graph builder for a parenthesized / nested
19898     * {@code <joined_table>} sub-tree (a {@link TJoin} reached via
19899     * {@link TJoinItem#getJoin()} or {@link TJoin#getJoin()}). Emits the
19900     * sub-tree's {@link JoinEntity}s into {@code entities}, advances the shared
19901     * {@code order} and {@code relInstanceId} counters (one-element arrays so the
19902     * caller sees the updates), and returns the sub-tree's result endpoint to be
19903     * used as a left/right endpoint of the enclosing join. Returns {@code null}
19904     * when an operand is genuinely neither a table nor a nested join, so the
19905     * caller surfaces the graph as empty rather than guessing.
19906     *
19907     * <p>Traversal order (driver first, then items left-to-right, recursing
19908     * inline) is identical to {@link #appendJoinRelations}, so endpoint
19909     * instanceIds align with RelationSource instanceIds by construction.
19910     */
19911    private static JoinEndpoint buildJoinGraphForJoin(TJoin join, NameBindingProvider provider,
19912            List<JoinEntity> entities, int[] order, int[] relInstanceId) {
19913        if (join == null) return null;
19914        JoinEndpoint currentLeft;
19915        List<String> accumulated = new ArrayList<String>();
19916        TTable driver = join.getTable();
19917        if (driver != null) {
19918            String dAlias = effectiveAliasOf(driver);
19919            String dQn = relationQnOf(driver);
19920            currentLeft = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId[0]++);
19921            accumulated.add(currentLeft.getAlias());
19922        } else if (join.getJoin() != null) {
19923            currentLeft = buildJoinGraphForJoin(join.getJoin(), provider, entities, order, relInstanceId);
19924            if (currentLeft == null) return null;
19925            accumulated.addAll(aliasesOf(currentLeft));
19926        } else {
19927            return null;
19928        }
19929        TJoinItemList items = join.getJoinItems();
19930        for (int i = 0; items != null && i < items.size(); i++) {
19931            TJoinItem item = items.getJoinItem(i);
19932            if (item == null) continue;
19933            JoinEndpoint rightEp;
19934            TTable right = item.getTable();
19935            if (right != null) {
19936                String rAlias = effectiveAliasOf(right);
19937                String rQn = relationQnOf(right);
19938                rightEp = JoinEndpoint.relation(safeAlias(rAlias, rQn), rQn, relInstanceId[0]++);
19939            } else if (item.getJoin() != null) {
19940                rightEp = buildJoinGraphForJoin(item.getJoin(), provider, entities, order, relInstanceId);
19941                if (rightEp == null) return null;
19942            } else {
19943                return null;
19944            }
19945            SemanticJoinType jt = mapSemanticJoinType(item.getJoinType());
19946            boolean natural = isNaturalJoinType(item.getJoinType());
19947            boolean lateral = isLateralJoinType(item.getJoinType())
19948                    || isLateralTable(item.getTable());
19949            List<String> using = usingColumnNames(item);
19950            List<Predicate> onPredicates = (item.getOnCondition() != null)
19951                    ? PredicateTreeBuilder.build(item.getOnCondition(), provider)
19952                    : null;
19953            String conditionText = verbatimText(item.getOnCondition());
19954            boolean hasExplicitCondition = item.getOnCondition() != null
19955                    || (using != null && !using.isEmpty()) || natural;
19956            JoinSourceSyntax syntax = (lateral && !hasExplicitCondition)
19957                    ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.EXPLICIT;
19958            SourceSpan joinSpan = usingJoinSpan(item);
19959            entities.add(new JoinEntity(jt, currentLeft, rightEp, order[0],
19960                    syntax, natural, using, onPredicates,
19961                    joinSpan, conditionText, lateral));
19962            accumulated.addAll(aliasesOf(rightEp));
19963            currentLeft = JoinEndpoint.joinResult(order[0], new ArrayList<String>(accumulated));
19964            order[0]++;
19965        }
19966        return currentLeft;
19967    }
19968
19969    /**
19970     * FROM-order aliases contributed by a join endpoint: the full contributing
19971     * list for a join result, or the single alias for a relation / subquery
19972     * endpoint. Used to extend the accumulated alias list when a nested join
19973     * sub-tree is folded into an enclosing join.
19974     */
19975    private static List<String> aliasesOf(JoinEndpoint ep) {
19976        if (ep == null) return new ArrayList<String>();
19977        List<String> contrib = ep.getContributingAliases();
19978        if (contrib != null && !contrib.isEmpty()) {
19979            return new ArrayList<String>(contrib);
19980        }
19981        List<String> single = new ArrayList<String>();
19982        if (ep.getAlias() != null && !ep.getAlias().isEmpty()) {
19983            single.add(ep.getAlias());
19984        }
19985        return single;
19986    }
19987
19988    /**
19989     * R7 — compute the {@link SourceSpan} for a join entity, extending a
19990     * {@code USING (...)} join's span to include the closing {@code ')'}.
19991     *
19992     * <p>For a {@code USING} join the {@link TJoinItem}'s end token is the
19993     * last USING column name (e.g. {@code k} in {@code USING (id, k)}), so
19994     * {@link SourceSpan#of(TParseTreeNode)} ends one token early and the
19995     * slice drops the trailing {@code ')'}. Here we advance the span end to
19996     * one-past the closing {@code ')'} that follows the USING column list.
19997     *
19998     * <p>ON / NATURAL / CROSS / APPLY items already reach their true clause
19999     * end (the ON condition expression reaches the clause end; the others
20000     * carry no parenthesised key list), so they fall through to the plain
20001     * {@code SourceSpan.of(item)}.
20002     */
20003    private static SourceSpan usingJoinSpan(TJoinItem item) {
20004        if (item == null) return null;
20005        TObjectNameList using = item.getUsingColumns();
20006        TSourceToken start = item.getStartToken();
20007        if (using == null || using.size() == 0 || start == null) {
20008            return SourceSpan.of(item);
20009        }
20010        TSourceToken end = using.getEndToken();
20011        if (end == null) {
20012            return SourceSpan.of(item);
20013        }
20014        // Advance to the closing ')' that follows the USING column list. The
20015        // list's end token is the last column name; the next solid token is
20016        // the ')'. Scan a small bounded window so a stray whitespace/comment
20017        // layout never wedges the walk, and never run past it.
20018        if (!")".equals(end.getAstext())) {
20019            TSourceToken t = end.nextSolidToken();
20020            int guard = 0;
20021            while (t != null && guard++ < 8 && !")".equals(t.getAstext())) {
20022                t = t.nextSolidToken();
20023            }
20024            if (t != null && ")".equals(t.getAstext())) {
20025                end = t;
20026            }
20027        }
20028        return SourceSpan.of(start, end);
20029    }
20030
20031    /**
20032     * Slice 177 (R1) — reconstruct the verbatim source substring spanned by
20033     * a node, by concatenating the token chain from start to end WITHOUT
20034     * inserting separators (so original whitespace / comments are
20035     * preserved). Returns null when the node or its boundary tokens are
20036     * null. Used to populate {@code JoinEntity.conditionText} so consumers
20037     * don't re-implement span-slicing for the common "show me the ON text"
20038     * case.
20039     */
20040    private static String verbatimText(TParseTreeNode node) {
20041        if (node == null) return null;
20042        TSourceToken s = node.getStartToken();
20043        TSourceToken e = node.getEndToken();
20044        if (s == null || e == null) return null;
20045        StringBuilder sb = new StringBuilder();
20046        TSourceToken c = s;
20047        int guard = 0;
20048        boolean reachedEnd = false;
20049        while (c != null && guard++ < 2_000_000) {
20050            if (c.astext != null) sb.append(c.astext);
20051            if (c == e) {
20052                reachedEnd = true;
20053                break;
20054            }
20055            c = c.getNextTokenInChain();
20056        }
20057        // If the end token was not reachable from the start (or the guard
20058        // tripped), the accumulated prefix would be truncated/unrelated SQL.
20059        // Return null (unavailable) rather than silently-wrong verbatim text.
20060        if (!reachedEnd) return null;
20061        return sb.length() == 0 ? null : sb.toString();
20062    }
20063
20064    /** Endpoint alias: the effective alias, falling back to the qualified name. */
20065    private static String safeAlias(String alias, String qn) {
20066        if (alias != null && !alias.isEmpty()) return alias;
20067        if (qn != null && !qn.isEmpty()) return qn;
20068        return "<anonymous>";
20069    }
20070
20071    /** Bare relation name for an endpoint's qualifiedName; null when unavailable. */
20072    private static String relationQnOf(TTable t) {
20073        if (t == null) return null;
20074        TObjectName n = t.getTableName();
20075        if (n != null) {
20076            String s = n.toString();
20077            if (s != null && !s.isEmpty()) return s;
20078        }
20079        return null;
20080    }
20081
20082    /** USING column names (empty for non-USING joins). */
20083    private static List<String> usingColumnNames(TJoinItem item) {
20084        List<String> out = new ArrayList<String>();
20085        if (item.getUsingColumns() != null) {
20086            for (int u = 0; u < item.getUsingColumns().size(); u++) {
20087                TObjectName c = item.getUsingColumns().getObjectName(u);
20088                if (c != null) {
20089                    String name = c.getColumnNameOnly();
20090                    if (name == null || name.isEmpty()) name = c.toString();
20091                    if (name != null && !name.isEmpty()) out.add(name);
20092                }
20093            }
20094        }
20095        return out;
20096    }
20097
20098    /**
20099     * Map the parser's {@link EJoinType} to the stable
20100     * {@link SemanticJoinType}. NATURAL variants map to the underlying
20101     * join type ({@code natural_left} -&gt; LEFT); the NATURAL semantics
20102     * are carried by {@code JoinEntity.isNatural()}. Bare {@code natural}
20103     * maps to {@link SemanticJoinType#NATURAL}. {@code crossapply} /
20104     * {@code outerapply} degrade to INNER / LEFT with the lateral nature
20105     * carried by {@code JoinEntity.isLateral()}. Shapes the semantic
20106     * builder does not admit (nested / semi / anti / vendor extensions)
20107     * map to {@link SemanticJoinType#UNSUPPORTED} — never guessed.
20108     */
20109    private static SemanticJoinType mapSemanticJoinType(EJoinType jt) {
20110        if (jt == null) return SemanticJoinType.UNSUPPORTED;
20111        switch (jt) {
20112            case inner:
20113            case join:
20114            case straight:
20115            case natural_inner:
20116                return SemanticJoinType.INNER;
20117            case left:
20118            case leftouter:
20119            case natural_left:
20120            case natural_leftouter:
20121                return SemanticJoinType.LEFT;
20122            case right:
20123            case rightouter:
20124            case natural_right:
20125            case natural_rightouter:
20126                return SemanticJoinType.RIGHT;
20127            case full:
20128            case fullouter:
20129            case natural_full:
20130            case natural_fullouter:
20131                return SemanticJoinType.FULL;
20132            case cross:
20133                return SemanticJoinType.CROSS;
20134            case crossapply:
20135                // SQL Server CROSS APPLY: inner lateral join (drops left
20136                // rows with no right match). Lateral nature carried by
20137                // JoinEntity.isLateral().
20138                return SemanticJoinType.INNER;
20139            case outerapply:
20140                // SQL Server OUTER APPLY: left lateral join (keeps left
20141                // rows, right side nullable).
20142                return SemanticJoinType.LEFT;
20143            case natural:
20144                return SemanticJoinType.NATURAL;
20145            default:
20146                return SemanticJoinType.UNSUPPORTED;
20147        }
20148    }
20149
20150    // -----------------------------------------------------------------
20151    // Slice 58 / 59 — catalog-backed SELECT * expansion.
20152    //
20153    // The hook in buildOutputColumns calls tryExpandStar(rc, select,
20154    // provider, isPredicateBody, stmtName) for any result column whose
20155    // columnNameOnly is "*" (and as defense in depth for any
20156    // EExpressionType.list_t expression). tryExpandStar returns a
20157    // StarExpansionResult that is either EXPANDED (with a list of
20158    // OutputColumns) or one of several reasoned rejection kinds. The
20159    // hook then either appends the expanded columns or throws a
20160    // structured SemanticIRBuildException whose message is unique per
20161    // kind so external callers can pattern-match without parsing a
20162    // generic "not supported yet" string.
20163    //
20164    // Scope (slice 58):
20165    //   - single base-table FROM (1 join, no join items)
20166    //   - bare `*` or qualified `t.*`
20167    //   - catalog provided via NameBindingProvider#getRelationColumnNames
20168    //
20169    // Slice 59 lift:
20170    //   - multi-relation FROM is now supported when a single top-level
20171    //     TJoin carries one or more explicit JOIN clauses (joinItems).
20172    //     Each FROM relation must individually satisfy slice-58 rules
20173    //     (binding kind TABLE, catalog declares columns). Bare `*`
20174    //     concatenates per-relation expansions in FROM order; qualified
20175    //     `t.*` selects the one relation whose effective alias matches.
20176    //   - qualifier matching is now effective-alias only
20177    //     (alias if present, else table name) — case-insensitive. This
20178    //     unifies the rule across single- and multi-relation paths;
20179    //     `SELECT employees.* FROM employees e` rejects because the
20180    //     effective alias is `e`, not `employees`.
20181    //   - star expansion is rejected inside synthetic body contexts
20182    //     (scalar-subquery / set-op-branch / predicate-subquery) via
20183    //     SYNTHETIC_BODY_CONTEXT; the slice-58 path silently allowed
20184    //     this for catalog-equipped builds even though a multi-column
20185    //     expansion would corrupt scalar-body shape downstream.
20186    //
20187    // Slice 60 lift:
20188    //   - CTE star and FROM-subquery star (`a.*` and bare `*` over a CTE
20189    //     or FROM-clause subquery alias) are now supported via the
20190    //     in-scope-relation-columns map carried on the provider. The
20191    //     map is populated at each consuming-SELECT call site in build()
20192    //     and extractFromSubqueriesAsStatements before the consumer's
20193    //     buildOutputColumns runs; tryExpandStar reads it for CTE /
20194    //     SUBQUERY bindings. Explicit CTE column lists
20195    //     (`WITH a(x, y) AS ...`) stay rejected because the CTE body's
20196    //     StatementGraph publishes inner-projection names, not the
20197    //     explicit list, and emitLineageForStatement would point at
20198    //     non-existent body outputs. Lifting that path needs either
20199    //     body-output renaming or a published-name → body-name lineage
20200    //     map; deferred to a future slice.
20201    //
20202    // Slice 62 lift:
20203    //   - Comma-FROM (multiple top-level TJoin elements parsed from
20204    //     `FROM a, b, c`) is now admitted at the outer / CTE-body /
20205    //     FROM-subquery-body call sites. {@link #tryExpandStar} walks
20206    //     every top-level TJoin and accumulates relations in FROM
20207    //     order; bare `*` concatenates per-relation expansions and
20208    //     qualified `t.*` selects the matching effective-alias.
20209    //     Synthetic body contexts (scalar / set-op-branch / set-op-CTE
20210    //     / predicate) still reject comma-FROM via the gated reject
20211    //     in buildRelations and the slice-62 reject inside
20212    //     preflightPredicateSubqueryShape.
20213    //
20214    // Out of scope (slice 60+):
20215    //   - SELECT * EXCEPT/REPLACE (BigQuery extensions; no slice scheduled)
20216    //   - Explicit CTE column list star expansion (slice 61+)
20217    // -----------------------------------------------------------------
20218
20219    enum StarExpansionKind {
20220        EXPANDED,
20221        PREDICATE_BODY_GUARD,
20222        // Defensive catch-all for malformed FROM lists: missing top-level
20223        // TJoin, null table on a top-level TJoin or a join item, or
20224        // empty {@code select.joins}. Slice 62 made comma-FROM admit
20225        // here (the walk iterates every top-level TJoin), so reaching
20226        // this kind indicates a parse-tree anomaly rather than a comma-
20227        // FROM rejection.
20228        MULTI_RELATION_FROM,
20229        NON_BASE_TABLE_RELATION,
20230        QUALIFIER_NOT_FOUND,
20231        // Slice 59: a qualifier matches 2+ relations (case-insensitive
20232        // effective-alias collision). Real SQL never reaches this case
20233        // unless `rejectDuplicateAliases` permitted a case-only collision
20234        // (it is case-sensitive at SemanticIRBuilder.java:5621).
20235        QUALIFIER_AMBIGUOUS,
20236        NO_CATALOG_OR_UNKNOWN_TABLE,
20237        // Slice 59: star expansion in synthetic body contexts
20238        // (scalar-subquery, set-op-branch, predicate-subquery) is rejected
20239        // because multi-column expansion would violate the body's shape
20240        // contract (e.g. scalar bodies must project exactly one column).
20241        SYNTHETIC_BODY_CONTEXT,
20242        // Slice 60: CTE has an explicit column list
20243        // (`WITH a(x, y) AS ...`). Deferred to a future slice because
20244        // the CTE body's StatementGraph publishes inner-projection
20245        // names, not the explicit list, and lineage emission cannot
20246        // bridge that without either body-output renaming or a
20247        // published-name → body-name map.
20248        EXPLICIT_CTE_COLUMN_LIST_DEFERRED,
20249        // Slice 60: CTE / SUBQUERY binding's published-column map
20250        // lookup returned null or empty. This indicates a builder
20251        // invariant failure (the body should have been built and
20252        // registered before the consumer's buildOutputColumns runs);
20253        // user SQL cannot reach this kind under normal builds — only
20254        // fabricated providers or a missed plumbing path would. The
20255        // diagnostic names the binding kind and qualified name so
20256        // regressions are loud, not silent.
20257        NO_INSCOPE_RELATION_COLUMNS
20258    }
20259
20260    static final class StarExpansionResult {
20261        final StarExpansionKind kind;
20262        final List<OutputColumn> columns;
20263        final String qualifier;
20264        final String detail;
20265
20266        private StarExpansionResult(StarExpansionKind kind,
20267                                    List<OutputColumn> columns,
20268                                    String qualifier,
20269                                    String detail) {
20270            this.kind = kind;
20271            this.columns = columns;
20272            this.qualifier = qualifier;
20273            this.detail = detail;
20274        }
20275
20276        static StarExpansionResult expanded(List<OutputColumn> cols) {
20277            return new StarExpansionResult(StarExpansionKind.EXPANDED, cols, null, null);
20278        }
20279
20280        static StarExpansionResult reject(StarExpansionKind kind) {
20281            return new StarExpansionResult(kind, null, null, null);
20282        }
20283
20284        static StarExpansionResult reject(StarExpansionKind kind, String qualifier, String detail) {
20285            return new StarExpansionResult(kind, null, qualifier, detail);
20286        }
20287    }
20288
20289    /**
20290     * Effective alias for a FROM-clause {@link TTable}: the SQL-written
20291     * alias if present, else the slice-74 synthetic alias for unaliased
20292     * FROM-subquery TTables (position-keyed via
20293     * {@link FromSubqueryNaming#synthAliasFor}), else the table name.
20294     * Mirrors the rule used by {@link #buildRelation} so
20295     * {@link ColumnRef#getRelationAlias()} stays aligned with what the
20296     * lineage emitter expects.
20297     */
20298    private static String effectiveAliasOf(TTable t) {
20299        if (t == null) return null;
20300        String alias = t.getAliasName();
20301        if (alias != null && !alias.isEmpty()) return alias;
20302        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
20303            return FromSubqueryNaming.synthAliasFor(t);
20304        }
20305        return t.getName();
20306    }
20307
20308    /**
20309     * Slice 58 / 59 — attempt to expand a {@code SELECT *} or
20310     * {@code SELECT alias.*} result column using the catalog exposed via
20311     * {@link NameBindingProvider#getRelationColumnNames(TTable)}.
20312     *
20313     * <p>Slice 58 supported only single-base-table FROM. Slice 59 lifts
20314     * the multi-relation case to JOIN forms (single top-level TJoin with
20315     * explicit JOIN clauses). Comma-FROM stays rejected by
20316     * {@code buildRelations}.
20317     *
20318     * <p>Returns {@link StarExpansionKind#EXPANDED} with one
20319     * {@link OutputColumn} per catalog-declared column on success.
20320     * Otherwise returns a reasoned rejection so the caller can throw a
20321     * shape-specific {@link SemanticIRBuildException}.
20322     */
20323    /**
20324     * True when some FROM operand of {@code select} is a parenthesized / nested
20325     * {@code <joined_table>}: a {@link TJoin} reached via {@link TJoin#getJoin()}
20326     * (nested driver) or {@link TJoinItem#getJoin()} (nested right operand) whose
20327     * {@code getTable()} is null. Used to scope the MULTI_RELATION_FROM
20328     * star-expansion degrade to the genuinely-analyzable nested-join shape — a
20329     * null FROM table WITHOUT a nested join, or a null top-level {@link TJoin},
20330     * is a broken invariant and stays fatal.
20331     */
20332    private static boolean hasNestedJoinOperand(TSelectSqlStatement select) {
20333        if (select == null || select.joins == null) {
20334            return false;
20335        }
20336        for (int j = 0; j < select.joins.size(); j++) {
20337            TJoin topJoin = select.joins.getJoin(j);
20338            if (topJoin == null) {
20339                continue;
20340            }
20341            if (topJoin.getTable() == null && topJoin.getJoin() != null) {
20342                return true;
20343            }
20344            TJoinItemList items = topJoin.getJoinItems();
20345            for (int i = 0; items != null && i < items.size(); i++) {
20346                TJoinItem item = items.getJoinItem(i);
20347                if (item != null && item.getTable() == null && item.getJoin() != null) {
20348                    return true;
20349                }
20350            }
20351        }
20352        return false;
20353    }
20354
20355    private static StarExpansionResult tryExpandStar(TResultColumn rc,
20356                                                     TSelectSqlStatement select,
20357                                                     NameBindingProvider provider,
20358                                                     boolean isPredicateBody,
20359                                                     String stmtName) {
20360        if (isPredicateBody) {
20361            // Defensive: the active rejection lives in
20362            // preflightPredicateSubqueryShape (~line 3880) and fires before
20363            // this code runs. Slice-24 EXISTS-with-* tests pin that path.
20364            return StarExpansionResult.reject(StarExpansionKind.PREDICATE_BODY_GUARD);
20365        }
20366        // Slice 59: reject star expansion in synthetic body contexts.
20367        // Scalar-subquery bodies must project exactly one column;
20368        // set-op-branch bodies must keep per-branch column-count parity;
20369        // predicate-subquery bodies are constant or column-ref shapes
20370        // (slice 23/24/27). Multi-column expansion would corrupt all
20371        // three. The preflight at SemanticIRBuilder.java:1007 only
20372        // checks AST result-column count/name, not "*", so without this
20373        // guard a catalog-equipped scalar body `SELECT * FROM small`
20374        // would silently emit multiple OutputColumns.
20375        if (stmtName != null
20376                && (isScalarSyntheticName(stmtName)
20377                        || isSetOpBranchSyntheticName(stmtName)
20378                        || isPredicateSubquerySyntheticName(stmtName))) {
20379            return StarExpansionResult.reject(
20380                    StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null,
20381                    "star expansion is not supported inside synthetic body '"
20382                            + stmtName + "' (scalar, set-op branch, or predicate body)");
20383        }
20384        // Extract qualifier (empty string for bare `*`, alias/name for `t.*`).
20385        String qualifier = "";
20386        TExpression expr = rc.getExpr();
20387        if (expr != null && expr.getObjectOperand() != null) {
20388            String q = expr.getObjectOperand().getTableString();
20389            if (q != null && !q.isEmpty()) {
20390                qualifier = q;
20391            }
20392        }
20393        // FROM-clause shape gate. Slice 59 supported a single top-level
20394        // TJoin with zero or more explicit JOIN clauses. Slice 62 lifts
20395        // comma-FROM to multi-TJoin: walk every top-level TJoin in
20396        // {@code select.joins} (a comma-FROM list parses as multiple
20397        // top-level TJoins) and accumulate every relation in FROM order.
20398        if (select.joins == null || select.joins.size() == 0) {
20399            return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM);
20400        }
20401        List<TTable> fromRelations = new ArrayList<>();
20402        for (int j = 0; j < select.joins.size(); j++) {
20403            TJoin topJoin = select.joins.getJoin(j);
20404            if (topJoin == null) {
20405                return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM);
20406            }
20407            TTable leftTable = topJoin.getTable();
20408            if (leftTable == null) {
20409                return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM);
20410            }
20411            fromRelations.add(leftTable);
20412            TJoinItemList items = topJoin.getJoinItems();
20413            if (items == null) continue;
20414            for (int i = 0; i < items.size(); i++) {
20415                TJoinItem item = items.getJoinItem(i);
20416                TTable rightTable = item.getTable();
20417                if (rightTable == null) {
20418                    return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM);
20419                }
20420                fromRelations.add(rightTable);
20421            }
20422        }
20423        // Slice 65 / 66: bare `*` over a USING / NATURAL JOIN collapses
20424        // merged keys. For each FROM relation in order, walk catalog/
20425        // in-scope columns; emit one OutputColumn per merged key (sources
20426        // = merged ref list) and one per non-merged column. Qualified
20427        // `t.*` is unaffected (single-relation path, no merged-key dedup).
20428        if (qualifier.isEmpty()
20429                && (hasUsingInFromClause(select)
20430                        || hasNaturalJoinMergedKeysInFromClause(select, provider))) {
20431            return expandBareStarOverUsing(select, provider, fromRelations);
20432        }
20433        // Qualified `t.*`: pick the (unique) FROM relation whose
20434        // effective alias matches the qualifier (case-insensitive).
20435        // Effective alias = `alias != null && !alias.isEmpty() ? alias :
20436        // tableName`, matching buildRelation at line 5649. Slice 58's
20437        // alias-OR-name match (line 5785 before slice 59) is replaced
20438        // here so `SELECT employees.* FROM employees e` rejects
20439        // (qualifier=`employees` ≠ effective alias `e`), consistent
20440        // with standard SQL correlation-name semantics.
20441        if (!qualifier.isEmpty()) {
20442            List<TTable> matches = new ArrayList<>();
20443            for (TTable t : fromRelations) {
20444                String ea = effectiveAliasOf(t);
20445                if (ea != null && ea.equalsIgnoreCase(qualifier)) {
20446                    matches.add(t);
20447                }
20448            }
20449            if (matches.isEmpty()) {
20450                return StarExpansionResult.reject(
20451                        StarExpansionKind.QUALIFIER_NOT_FOUND, qualifier, null);
20452            }
20453            if (matches.size() > 1) {
20454                StringBuilder names = new StringBuilder();
20455                for (int i = 0; i < matches.size(); i++) {
20456                    if (i > 0) names.append(", ");
20457                    names.append(effectiveAliasOf(matches.get(i)));
20458                }
20459                return StarExpansionResult.reject(
20460                        StarExpansionKind.QUALIFIER_AMBIGUOUS, qualifier,
20461                        "matches " + matches.size() + " FROM-clause relations: "
20462                                + names);
20463            }
20464            return expandSingleRelation(matches.get(0), provider, qualifier);
20465        }
20466        // Bare `*`: expand every FROM relation in order. Fail fast on
20467        // the first relation that does not satisfy the slice-58 rules
20468        // (binding kind TABLE, catalog declares columns); the caller
20469        // sees the per-relation rejection kind and detail. No partial
20470        // outputs are returned.
20471        List<OutputColumn> all = new ArrayList<>();
20472        for (TTable t : fromRelations) {
20473            StarExpansionResult one = expandSingleRelation(t, provider, "");
20474            if (one.kind != StarExpansionKind.EXPANDED) {
20475                return one;
20476            }
20477            all.addAll(one.columns);
20478        }
20479        return StarExpansionResult.expanded(all);
20480    }
20481
20482    /**
20483     * Slice 58 / 59 — pure per-relation star expander. Applies the
20484     * base-table-only + catalog rules and builds one
20485     * {@link OutputColumn} per catalog-declared column with a
20486     * {@link ColumnRef} whose {@code relationAlias} is the effective
20487     * alias of {@code target}. Returns {@link StarExpansionKind#EXPANDED}
20488     * on success, otherwise a tuned rejection.
20489     *
20490     * <p>The {@code qualifier} parameter is the SQL-written qualifier
20491     * for qualified `t.*` (empty for bare `*`); it is plumbed back into
20492     * the rejection result so the caller can include it in
20493     * user-visible diagnostics.
20494     */
20495    private static StarExpansionResult expandSingleRelation(TTable target,
20496                                                            NameBindingProvider provider,
20497                                                            String qualifier) {
20498        // The TTable's tableType cannot distinguish CTE from base table —
20499        // CTE references arrive as ETableSource.objectname. Use the
20500        // provider's bindRelation to get the resolved RelationKind.
20501        RelationBinding binding = provider.bindRelation(target);
20502        if (binding == null) {
20503            String diagAlias = effectiveAliasOf(target);
20504            return StarExpansionResult.reject(
20505                    StarExpansionKind.NON_BASE_TABLE_RELATION, qualifier,
20506                    "FROM source '"
20507                            + (diagAlias != null ? diagAlias : "<unnamed>")
20508                            + "' could not be bound (only base tables, in-scope CTEs, and FROM-subqueries are supported)");
20509        }
20510        RelationKind kind = binding.getKind();
20511        if (kind == RelationKind.TABLE) {
20512            // Slice 58 catalog-backed path. Returns null when no
20513            // catalog, when the catalog doesn't declare this table,
20514            // or when the table has no columns.
20515            List<String> columnNames = provider.getRelationColumnNames(target);
20516            if (columnNames == null || columnNames.isEmpty()) {
20517                String diagAlias = effectiveAliasOf(target);
20518                return StarExpansionResult.reject(
20519                        StarExpansionKind.NO_CATALOG_OR_UNKNOWN_TABLE, qualifier,
20520                        diagAlias);
20521            }
20522            return buildExpansionFromColumnNames(target, columnNames);
20523        }
20524        if (kind == RelationKind.CTE) {
20525            // Slice 60 + Slice 103: key by EFFECTIVE ALIAS in the consuming
20526            // SELECT, not by CTE name. This avoids a collision when a
20527            // FROM-subquery alias equals a visible CTE name and the
20528            // CTE is referenced under a different alias (codex
20529            // diff-review): `WITH a AS (...) SELECT c.*, a.* FROM a c
20530            // JOIN (SELECT ...) a ON ...` — both 'a' (CTE) and 'a'
20531            // (subquery alias) live in the FROM clause; effective
20532            // aliases are 'c' and 'a' respectively, so per-relation
20533            // entries cannot overwrite each other.
20534            //
20535            // Slice 103 — explicit CTE column lists (WITH a(x, y) AS ...)
20536            // are no longer rejected here. The slice-102 rename helper now
20537            // runs on the SELECT-side CTE walker too; the in-scope map
20538            // populated by addRelationToInScopeMap reads from
20539            // ctePublishedColumns, which the helper has populated with the
20540            // renamed names. Star expansion just falls through to the
20541            // in-scope lookup below; the renamed list comes back.
20542            String lookupKey = effectiveAliasLowerCaseOrNull(target);
20543            List<String> cteColumns = (lookupKey == null) ? null
20544                    : provider.getInScopeRelationColumns().get(lookupKey);
20545            if (cteColumns == null || cteColumns.isEmpty()) {
20546                String diagAlias = effectiveAliasOf(target);
20547                return StarExpansionResult.reject(
20548                        StarExpansionKind.NO_INSCOPE_RELATION_COLUMNS,
20549                        qualifier,
20550                        "CTE '" + diagAlias + "' has no published columns in "
20551                                + "the in-scope map (builder invariant: the "
20552                                + "CTE body should have been built and "
20553                                + "registered before this consumer ran)");
20554            }
20555            return buildExpansionFromColumnNames(target, cteColumns);
20556        }
20557        if (kind == RelationKind.SUBQUERY) {
20558            // Slice 60: same effective-alias keying as the CTE branch
20559            // above (codex diff-review). For a subquery the effective
20560            // alias IS the alias the SQL writer wrote (preflight
20561            // rejects anonymous subqueries), so this branch is also
20562            // unambiguous under the alias-collision example.
20563            String lookupKey = effectiveAliasLowerCaseOrNull(target);
20564            List<String> subColumns = (lookupKey == null) ? null
20565                    : provider.getInScopeRelationColumns().get(lookupKey);
20566            if (subColumns == null || subColumns.isEmpty()) {
20567                String diagAlias = effectiveAliasOf(target);
20568                return StarExpansionResult.reject(
20569                        StarExpansionKind.NO_INSCOPE_RELATION_COLUMNS,
20570                        qualifier,
20571                        "FROM-clause subquery '"
20572                                + (diagAlias != null ? diagAlias : "<unnamed>")
20573                                + "' has no published columns in the in-scope "
20574                                + "map (builder invariant: the subquery body "
20575                                + "should have been extracted and registered "
20576                                + "before this consumer ran)");
20577            }
20578            return buildExpansionFromColumnNames(target, subColumns);
20579        }
20580        // OUTER_REFERENCE / UNION / UNKNOWN: keep the slice-58 / 59
20581        // rejection contract. None of these arrive on a slice-60
20582        // FROM-clause relation via the current builder paths
20583        // (OUTER_REFERENCE bindings live only on RelationSource for
20584        // correlated scalar lookup; UNION is a set-op branch concept,
20585        // not a FROM-clause relation). Defensive catch-all.
20586        String diagAlias = effectiveAliasOf(target);
20587        String detail;
20588        switch (kind) {
20589            case OUTER_REFERENCE:
20590                detail = "OUTER_REFERENCE star expansion is not supported (relation '"
20591                        + diagAlias + "')";
20592                break;
20593            case UNION:
20594            case UNKNOWN:
20595            default:
20596                detail = "FROM source '" + diagAlias
20597                        + "' must be a base table, CTE, or FROM-subquery (got kind="
20598                        + kind + ")";
20599                break;
20600        }
20601        return StarExpansionResult.reject(
20602                StarExpansionKind.NON_BASE_TABLE_RELATION, qualifier, detail);
20603    }
20604
20605    /**
20606     * Slice 60 — shared helper that turns a column-name list into the
20607     * star-expansion OutputColumn list with one {@link ColumnRef} per
20608     * column whose {@code relationAlias} is the effective alias of the
20609     * target table (alias if present, else the table name). Used by all
20610     * three slice-58 / 59 / 60 paths.
20611     */
20612    private static StarExpansionResult buildExpansionFromColumnNames(
20613            TTable target, List<String> columnNames) {
20614        String alias = effectiveAliasOf(target);
20615        List<OutputColumn> outputs = new ArrayList<>(columnNames.size());
20616        for (String colName : columnNames) {
20617            ColumnRef ref = new ColumnRef(alias, colName);
20618            outputs.add(new OutputColumn(
20619                    colName,
20620                    /*derived=*/ false,
20621                    /*aggregate=*/ false,
20622                    Collections.singletonList(ref),
20623                    /*windowSpec=*/ null));
20624        }
20625        return StarExpansionResult.expanded(outputs);
20626    }
20627
20628    /**
20629     * Slice 65 — bare {@code *} over a USING JOIN: deduplicate the
20630     * merged key within each equivalence class. For each FROM relation
20631     * in order:
20632     * <ul>
20633     *   <li>look up columns via the existing
20634     *       {@link #lookupRelationColumnNames} (catalog + in-scope map);
20635     *       reject with {@link StarExpansionKind#NO_CATALOG_OR_UNKNOWN_TABLE}
20636     *       when null (we can't dedup without knowing what's there);</li>
20637     *   <li>for each column, check
20638     *       {@link UsingScope#entryContaining(String, TTable)};
20639     *       if a class contains this relation, emit a single
20640     *       merged-source {@link OutputColumn} the first time the class
20641     *       is seen and skip duplicates from later class members;</li>
20642     *   <li>otherwise emit a plain single-source OutputColumn.</li>
20643     * </ul>
20644     *
20645     * <p>Duplicate-output guard fires ONLY when the conflicting names
20646     * involve a USING-merged entry (merged-vs-plain or two disconnected
20647     * merged classes for the same key). Plain duplicates from
20648     * non-USING multi-relation expansion remain admitted (slice-59
20649     * behavior).
20650     *
20651     * <p>Output column order is <b>left-table order with USING-key
20652     * dedup within each equivalence class</b>: the merged column
20653     * appears at the position of its first member in FROM order. E.g.
20654     * {@code a(id, k), b(k, name)} → {@code [id, k, name]}.
20655     *
20656     * <p>This is INTENTIONALLY DIFFERENT from the ANSI/PostgreSQL
20657     * physical column order (which puts USING columns first, then
20658     * remaining left, then remaining right — would yield
20659     * {@code [k, id, name]}). The Semantic IR is not a query
20660     * executor; the order it surfaces is a lineage-tracking
20661     * presentation choice. Left-table order:
20662     * <ol>
20663     *   <li>matches the slice-65 roadmap resume protocol
20664     *       ({@code docs/designs/sql-semantic-governance-unified-roadmap.md}
20665     *       §13.1) which fixed this order before implementation;</li>
20666     *   <li>keeps the merged column physically adjacent to its
20667     *       left-side neighbors, matching how lineage tooling
20668     *       traditionally renders combined JOIN output;</li>
20669     *   <li>does not depend on USING-clause ordering (which is a
20670     *       syntactic choice, not a semantic one).</li>
20671     * </ol>
20672     * Codex diff-review round 5 flagged this as P2 (non-ANSI). The
20673     * choice was confirmed in plan-review and is locked by
20674     * {@code bareStarOverUsingLeftPositionPreserved} so any future
20675     * change to ANSI order is a deliberate observable contract
20676     * change, not a silent fix.
20677     */
20678    private static StarExpansionResult expandBareStarOverUsing(
20679            TSelectSqlStatement select,
20680            NameBindingProvider provider,
20681            List<TTable> fromRelations) {
20682        UsingScope scope = provider.getUsingScope();
20683        if (scope.isEmpty()) {
20684            // The slice-65 caller checks hasUsingInFromClause before
20685            // routing here, so an empty scope here means buildUsingScope
20686            // computed empty entries (unreachable in practice). Fall
20687            // back to a generic reject so the caller surfaces a
20688            // structured diagnostic.
20689            return StarExpansionResult.reject(
20690                    StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null,
20691                    "bare * over JOIN ... USING reached the merged-key expander "
20692                            + "with an empty UsingScope (builder invariant failure)");
20693        }
20694        LinkedHashSet<String> emittedNamesLC = new LinkedHashSet<>();
20695        Set<String> mergedNamesLC = new HashSet<>();
20696        java.util.IdentityHashMap<UsingScope.EquivalenceClass, Boolean> emittedClasses =
20697                new java.util.IdentityHashMap<>();
20698        List<OutputColumn> outputs = new ArrayList<>();
20699        for (TTable t : fromRelations) {
20700            // Slice 103 lifted the explicit-CTE-column-list deferral:
20701            // populateUsingJoinRefs no longer rejects, and
20702            // lookupRelationColumnNames returns the renamed names from
20703            // the in-scope map (populated by the slice-102 rename
20704            // helper that the SELECT-side CTE walker now invokes).
20705            List<String> cols = lookupRelationColumnNames(t, provider);
20706            if (cols == null) {
20707                return StarExpansionResult.reject(
20708                        StarExpansionKind.NO_CATALOG_OR_UNKNOWN_TABLE, null,
20709                        effectiveAliasOf(t));
20710            }
20711            for (String c : cols) {
20712                if (c == null) continue;
20713                String keyLC = c.toLowerCase(Locale.ROOT);
20714                UsingScope.MergedKeyEntry entry = scope.entryContaining(keyLC, t);
20715                if (entry != null) {
20716                    // USING-merged column. Dedup per class.
20717                    if (emittedClasses.containsKey(entry.getEquivClass())) {
20718                        continue;
20719                    }
20720                    emittedClasses.put(entry.getEquivClass(), Boolean.TRUE);
20721                    OutputColumn cand = new OutputColumn(
20722                            c, /*derived=*/ false, /*aggregate=*/ false,
20723                            entry.getSources(), /*windowSpec=*/ null);
20724                    StarExpansionResult dup = appendMergedAwareOrReject(
20725                            outputs, emittedNamesLC, mergedNamesLC, cand, /*isMerged=*/ true);
20726                    if (dup != null) return dup;
20727                } else {
20728                    // Plain column. Slice-59 behavior: duplicate plain
20729                    // names are admitted. Codex round-5: the merged-aware
20730                    // guard fires only when ONE side is merged.
20731                    OutputColumn cand = new OutputColumn(
20732                            c, /*derived=*/ false, /*aggregate=*/ false,
20733                            Collections.singletonList(new ColumnRef(effectiveAliasOf(t), c)),
20734                            /*windowSpec=*/ null);
20735                    StarExpansionResult dup = appendMergedAwareOrReject(
20736                            outputs, emittedNamesLC, mergedNamesLC, cand, /*isMerged=*/ false);
20737                    if (dup != null) return dup;
20738                }
20739            }
20740        }
20741        return StarExpansionResult.expanded(outputs);
20742    }
20743
20744    /**
20745     * Slice 65 — duplicate-output helper for
20746     * {@link #expandBareStarOverUsing}. Fires the merged-vs-non-merged
20747     * collision guard. Returns a {@link StarExpansionResult} when the
20748     * caller should reject; returns {@code null} when the candidate is
20749     * appended successfully.
20750     */
20751    private static StarExpansionResult appendMergedAwareOrReject(
20752            List<OutputColumn> outputs,
20753            LinkedHashSet<String> emittedNamesLC,
20754            Set<String> mergedNamesLC,
20755            OutputColumn cand,
20756            boolean isMerged) {
20757        String nameLC = cand.getName().toLowerCase(Locale.ROOT);
20758        boolean alreadyEmitted = emittedNamesLC.contains(nameLC);
20759        boolean alreadyMerged = mergedNamesLC.contains(nameLC);
20760        // Reject only when at least one side is a USING-merged entry.
20761        // Plain-vs-plain duplicates remain admitted (slice-59 behavior).
20762        if (alreadyEmitted && (isMerged || alreadyMerged)) {
20763            return StarExpansionResult.reject(
20764                    StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null,
20765                    "bare * over JOIN ... USING produces ambiguous output "
20766                            + "column '" + cand.getName() + "': a USING-merged "
20767                            + "entry and a same-named column from outside the "
20768                            + "USING equivalence class collide (or two disconnected "
20769                            + "USING classes share the same key name); qualify "
20770                            + "with t.* per relation or rename a column to "
20771                            + "disambiguate");
20772        }
20773        emittedNamesLC.add(nameLC);
20774        if (isMerged) mergedNamesLC.add(nameLC);
20775        outputs.add(cand);
20776        return null;
20777    }
20778
20779    /**
20780     * Slice 60 — read the published column names for an already-built
20781     * statement (CTE body or FROM-subquery body) from its
20782     * {@link StatementGraph#getOutputColumns()}. Used by {@code build()}
20783     * and {@code extractFromSubqueriesAsStatements} to populate the
20784     * in-scope map before each consuming SELECT's
20785     * {@code buildOutputColumns} runs.
20786     */
20787    private static List<String> outputColumnNames(StatementGraph body) {
20788        List<OutputColumn> cols = body.getOutputColumns();
20789        List<String> names = new ArrayList<>(cols.size());
20790        for (OutputColumn c : cols) names.add(c.getName());
20791        return Collections.unmodifiableList(names);
20792    }
20793
20794    /**
20795     * Slice 60 — effective alias of a TTable lower-cased, or null when
20796     * the table has neither an alias nor a name. The alias-collision
20797     * fix (codex diff-review) replaced CTE-name / subquery-alias
20798     * keying with effective-alias keying; this helper centralises the
20799     * lookup-key computation.
20800     */
20801    private static String effectiveAliasLowerCaseOrNull(TTable t) {
20802        String alias = effectiveAliasOf(t);
20803        if (alias == null || alias.isEmpty()) return null;
20804        return alias.toLowerCase(Locale.ROOT);
20805    }
20806
20807    /**
20808     * Slice 60 — build a per-consumer effective-alias-keyed map of
20809     * "FROM-clause relation alias → published column names" by walking
20810     * the consumer's direct FROM/JOIN list (single top-level TJoin,
20811     * left table + each joinItem.getTable()).
20812     *
20813     * <p>Each CTE-bound relation contributes its effective alias →
20814     * {@code ctePublishedColumns.get(cteName.toLowerCase())}. Each
20815     * FROM-subquery contributes its alias → {@code
20816     * outputColumnNames(stmts.get(subqueryAliasToIndex.get(alias)))}.
20817     * Base-table relations are skipped because their star expansion
20818     * uses the catalog path (TSQLEnv); adding them here would force
20819     * dialect-specific catalog walks before catalog access is required.
20820     *
20821     * <p>The codex diff-review found that a single name-keyed map
20822     * collides when a FROM-subquery alias equals a visible CTE name
20823     * (`WITH a AS (...) ... FROM a c JOIN (SELECT ...) a ...`). Keying
20824     * by effective alias (which is unique per FROM clause —
20825     * {@link #preflightDirectFromList} rejects duplicates) closes the
20826     * collision class.
20827     *
20828     * @param consumer              the SELECT whose FROM list to walk
20829     * @param consumerProvider      provider used only for bindRelation
20830     *                              (CTE vs TABLE discrimination)
20831     * @param ctePublishedColumns   CTE-name → columns lookup
20832     *                              populated as CTE bodies are built
20833     * @param subqueryAliasToIndex  this consumer's own subquery alias
20834     *                              → stmts index lookup
20835     * @param stmts                 already-built statement list
20836     * @return mutable effective-alias-keyed in-scope map for this
20837     *         consumer; callers wrap it via
20838     *         {@code provider.withInScopeRelationColumns(map)}
20839     */
20840    private static Map<String, List<String>> buildEffectiveAliasInScopeMap(
20841            TSelectSqlStatement consumer,
20842            NameBindingProvider consumerProvider,
20843            Map<String, List<String>> ctePublishedColumns,
20844            Map<String, Integer> subqueryAliasToIndex,
20845            List<StatementGraph> stmts) {
20846        Map<String, List<String>> result = new HashMap<>();
20847        if (consumer.joins == null) return result;
20848        for (TJoin join : consumer.joins) {
20849            addRelationToInScopeMap(join.getTable(), consumerProvider,
20850                    ctePublishedColumns, subqueryAliasToIndex, stmts, result);
20851            TJoinItemList items = join.getJoinItems();
20852            if (items == null) continue;
20853            for (int i = 0; i < items.size(); i++) {
20854                TJoinItem item = items.getJoinItem(i);
20855                if (item == null) continue;
20856                addRelationToInScopeMap(item.getTable(), consumerProvider,
20857                        ctePublishedColumns, subqueryAliasToIndex, stmts, result);
20858            }
20859        }
20860        return result;
20861    }
20862
20863    private static void addRelationToInScopeMap(
20864            TTable t,
20865            NameBindingProvider consumerProvider,
20866            Map<String, List<String>> ctePublishedColumns,
20867            Map<String, Integer> subqueryAliasToIndex,
20868            List<StatementGraph> stmts,
20869            Map<String, List<String>> result) {
20870        if (t == null) return;
20871        // Slice 137 — a PIVOT / UNPIVOT source can itself be a FROM-subquery
20872        // (or CTE): `FROM (SELECT ...) [alias] PIVOT(...)`. Unwrap the
20873        // pivoted_table to its source relation so the source's published
20874        // columns are registered under the SAME effective alias that
20875        // lookupRelationColumnNames resolves in expandPivot/UnpivotBareStar.
20876        // The source-subquery alias is already registered in
20877        // subqueryAliasToIndex by the slice-136 processDirectSubqueryTable
20878        // pre-pass (which performs the same unwrap), so the subquery branch
20879        // below finds its statement index. A base-table pivot source falls
20880        // through unchanged: bindRelation returns TABLE → no in-scope entry,
20881        // and the slice-133 catalog path (getRelationColumnNames) handles it.
20882        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table
20883                && t.getPivotedTable() != null
20884                && !t.getPivotedTable().getRelations().isEmpty()) {
20885            TTable src = t.getPivotedTable().getRelations().get(0);
20886            if (src == null) return;
20887            t = src;
20888        }
20889        String key = effectiveAliasLowerCaseOrNull(t);
20890        if (key == null) return;
20891        if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
20892            Integer idx = subqueryAliasToIndex.get(key);
20893            if (idx != null) {
20894                result.put(key, outputColumnNames(stmts.get(idx)));
20895            }
20896            return;
20897        }
20898        // objectname (base-table OR CTE reference). Use bindRelation
20899        // to discriminate; base tables don't need an in-scope entry
20900        // (slice 58 catalog path handles them via getRelationColumnNames).
20901        RelationBinding b = consumerProvider.bindRelation(t);
20902        if (b == null) return;
20903        if (b.getKind() == RelationKind.CTE) {
20904            String cteName = t.getName();
20905            if (cteName == null) return;
20906            List<String> cols = ctePublishedColumns.get(cteName.toLowerCase(Locale.ROOT));
20907            if (cols != null && !cols.isEmpty()) {
20908                result.put(key, cols);
20909            }
20910        }
20911        // For TABLE, OUTER_REFERENCE, UNION, UNKNOWN bindings the
20912        // in-scope map is intentionally not populated; the
20913        // base-table catalog path or rejection path applies.
20914    }
20915
20916    /**
20917     * Build the {@link OutputColumn} list. Slice 4 lifts the
20918     * simple-object-name / single-source restriction: any expression with at
20919     * least one column reference is accepted, and the column is marked
20920     * {@link OutputColumn#isDerived()} when the expression is anything
20921     * other than a direct column reference. Slice 61 also admits
20922     * canonical constant-only projections (zero column refs) outside
20923     * scalar-subquery bodies, using alias-or-expression text naming.
20924     */
20925    private static List<OutputColumn> buildOutputColumns(TSelectSqlStatement select,
20926                                                         NameBindingProvider provider,
20927                                                         boolean allowScalarProjectionSubqueries,
20928                                                         boolean allowWindowProjection,
20929                                                         boolean isPredicateBody,
20930                                                         String stmtName) {
20931        TResultColumnList rcl = select.getResultColumnList();
20932        if (rcl == null || rcl.size() == 0) {
20933            throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS, "SELECT has no projected columns", select));
20934        }
20935        // Slice 23/24/27: predicate-body short-circuit. The preflight
20936        // (§4.4 / slice-24 §4.1.1 / slice-27 §4.1) already validated that
20937        // the inner SELECT projects exactly one column, of an admitted
20938        // shape — constant (slice 23), simple column ref (slice 24), or
20939        // expression / function call / CASE / aggregate over inner
20940        // columns (slice 27). Discriminate on the shape:
20941        //
20942        // - Constant: bypass the regular result-column loop (which would
20943        //   reject empty-source non-aggregate projections via the
20944        //   "no column refs" guard at line ~4397) and emit one synthetic
20945        //   OutputColumn with empty sources. The synthesised name
20946        //   `<predicate_subquery_<i>>_const_0` guarantees no collision
20947        //   with real column names.
20948        //
20949        // - Slice-24 column ref (simple_object_name_t with name): fall
20950        //   through to the normal loop; effectiveOutputName(rc) returns
20951        //   the column name. OutputColumn carries name, derived=false,
20952        //   aggregate=false, sources=[ColumnRef(...)].
20953        //
20954        // - Slice-27 expression / function / CASE / aggregate without
20955        //   alias: synthesise the OutputColumn here. The normal loop's
20956        //   {@link #effectiveOutputName} would throw on rc with neither
20957        //   alias nor column name (a slice-6 invariant for OUTER
20958        //   projections); for predicate bodies the OutputColumn name is
20959        //   internal scaffolding only — no consumer references it
20960        //   externally — so a synthetic name is sound. For aggregate-over-
20961        //   constants (COUNT(*), SUM(1)) sources is empty and aggregate=true
20962        //   matches the line-4397 guard's intent. The slice-24 projector
20963        //   pass walks OutputColumn.sources to base-column terminals and
20964        //   emits JOIN canonical edges (zero terminals → zero edges,
20965        //   multi-source → multiple edges).
20966        if (isPredicateBody) {
20967            TResultColumn rc0 = rcl.getResultColumn(0);
20968            if (rc0.getExpr() != null && isConstantExpression(rc0.getExpr())) {
20969                String synthName = (stmtName != null ? stmtName : "<predicate_subquery_?>")
20970                        + "_const_0";
20971                return Collections.singletonList(new OutputColumn(
20972                        synthName, /*derived=*/ true, /*aggregate=*/ false,
20973                        Collections.<ColumnRef>emptyList(), /*windowSpec=*/ null));
20974            }
20975            // Slice 27 + Slice 32: synthesise the OutputColumn for any
20976            // slice-27/31-admitted predicate-body projection EXCEPT the
20977            // slice-24 simple_object_name_t shape. Slice 27 fired this
20978            // branch only when both alias AND columnNameOnly were absent
20979            // (missingName=true); slice 32 widens it to also fire when
20980            // alias is present, so aliased Oracle / MSSQL plain
20981            // {@code LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY ...) AS lst}
20982            // is admitted (the slice-31 boundary lifted by slice 32).
20983            //
20984            // The simple_object_name_t exclusion is intentional. That
20985            // shape MUST keep falling through to the normal loop, where
20986            // {@link #effectiveOutputName} returns the column name (or
20987            // alias if present), {@code derived=false}, and
20988            // {@code sources=[ColumnRef(...)]} — the slice-24 baseline.
20989            // Per {@code TResultColumn.getColumnNameOnly()}, only
20990            // {@code simple_object_name_t}, {@code typecast_t}, and
20991            // {@code sqlserver_proprietary_column_alias_t} populate
20992            // columnNameOnly; function_t / case_t / pure-binary all
20993            // return empty, so the cascade below is alias > _proj_0
20994            // (no columnNameOnly intermediate).
20995            if (rc0.getExpr() != null
20996                    && rc0.getExpr().getExpressionType() != EExpressionType.simple_object_name_t) {
20997                String alias = rc0.getColumnAlias();
20998                String name;
20999                if (alias != null && !alias.isEmpty()) {
21000                    // Slice 32 widening: aliased projection. Use the
21001                    // alias as the OutputColumn name.
21002                    name = alias;
21003                } else {
21004                    // Slice 27 carryover: unaliased non-column-ref
21005                    // projection. Synthesise a stable name. The synth
21006                    // name is used internally by
21007                    // {@link gudusoft.gsqlparser.ir.semantic.diff.SemanticIRProjector}
21008                    // (line ~161 — BFS start key keyed by
21009                    // {@code stmtOutputKey(idx, out.getName())}) to walk
21010                    // predicate-body lineage to base columns; uniqueness
21011                    // within the single-column predicate body is
21012                    // sufficient for that walk. {@code _proj_0} is also
21013                    // exposed by the JSON exporter but is not externally
21014                    // referenced by callers — only the inner JOIN
21015                    // canonical edges (target.column omitted; role=JOIN)
21016                    // are visible to consumers.
21017                    name = (stmtName != null ? stmtName : "<predicate_subquery_?>")
21018                            + "_proj_0";
21019                }
21020                boolean aggregate = isAggregateFunction(rc0.getExpr());
21021                // Slice 43 / 44: PG (slice 43) and Snowflake (slice 44)
21022                // hypothetical-set ordered-set aggregates ({@code rank} /
21023                // {@code dense_rank} / {@code percent_rank} /
21024                // {@code cume_dist}) via direct {@code fn.getWithinGroup()}
21025                // attachment do not satisfy
21026                // {@link #isHypotheticalSetWithinGroupCall} (which requires
21027                // a non-null windowDef) and are not in the regular
21028                // {@link #AGGREGATE_FUNCTION_NAMES} whitelist. Inside the
21029                // predicate-body branch they are admitted as aggregates
21030                // when the slice-43 / 44 vendor-gated shape predicate
21031                // fires — contained here (NOT folded into
21032                // {@code isAggregateFunction}) so the carve-out cannot
21033                // accidentally lift the top-level PG / Snowflake case
21034                // (whose dlineage XML is structurally identical to the
21035                // OVER form — see Slice43Test / Slice44Test javadoc).
21036                if (!aggregate
21037                        && rc0.getExpr().getExpressionType() == EExpressionType.function_t
21038                        && isDirectAttachmentHypotheticalSetCall(
21039                                rc0.getExpr().getFunctionCall(), select.dbvendor)) {
21040                    aggregate = true;
21041                }
21042                // Slice 28: FILTER-aware collector excludes column refs inside
21043                // FILTER (WHERE ...) subtrees so OutputColumn.sources matches
21044                // dlineage's lineage-relationship view (FILTER predicate refs
21045                // absent from fdd / fdr).
21046                // Slice 31: also excludes column refs inside Oracle / MSSQL
21047                // {@code fn.windowDef.withinGroup} (the WITHIN GROUP ORDER BY)
21048                // so plain {@code LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY x.region)}
21049                // emits sources=[x.id] only — matching dlineage's omission of the
21050                // WITHIN GROUP ORDER BY ref from {@code fdr clause="on"} sources
21051                // (probe Q1 in {@code /tmp/probe31}). Slice 32 reuses the
21052                // same collector unchanged.
21053                List<ColumnRef> sources = collectColumnRefsExcludingFilterAndWithinGroupClauses(rc0, provider);
21054                if (sources.isEmpty() && !aggregate) {
21055                    // Non-aggregate with no inner column refs: should be
21056                    // covered by the constant short-circuit above. If we
21057                    // reach here, fall through to the normal loop's
21058                    // line-4397 guard for a conservative tuned message.
21059                } else {
21060                    return Collections.singletonList(new OutputColumn(
21061                            name, /*derived=*/ true, aggregate,
21062                            sources, /*windowSpec=*/ null));
21063                }
21064            }
21065            // simple_object_name_t falls through (slice-24 carryover):
21066            // the normal loop produces derived=false /
21067            // sources=[ColumnRef(...)] using effectiveOutputName.
21068        }
21069        // Slice 19 (alias-bound PARTITION BY discriminator): the resolver
21070        // synthesises EXACT_MATCH bindings for PARTITION BY <name> when no
21071        // schema metadata is available (TableNamespace.resolveColumn
21072        // inferred_from_usage fallback), even when <name> is a SELECT-list
21073        // alias on a calculated expression. The discriminator is exposed
21074        // by NameBindingProvider#isCalculatedProjectionAliasFallback and
21075        // consulted in buildWindowPartitionRefs / buildWindowOrderRefs;
21076        // see Slice13Test#partitionByExpressionAliasIsRejectedAsAliasBound
21077        // and the shadowing-with-metadata companion. Slice 19 prefers
21078        // conservative rejection in the no-metadata case; with TSQLEnv
21079        // declaring the shadowed column, ColumnSource#hasDefiniteEvidence
21080        // returns true and the discriminator falls through.
21081        List<OutputColumn> out = new ArrayList<>(rcl.size());
21082        for (int i = 0; i < rcl.size(); i++) {
21083            TResultColumn rc = rcl.getResultColumn(i);
21084            if (rc.getExpr() == null) {
21085                throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NULL_EXPRESSION, "result column " + rc + " has null expression", rc));
21086            }
21087            EExpressionType type = rc.getExpr().getExpressionType();
21088            // Slice 58: catalog-backed star expansion for a single base
21089            // table. Star projections were rejected by slices 1-57 with
21090            // "SELECT * / list expansions are deferred"; slice 58 lifts
21091            // the single-base-table case when a catalog is available via
21092            // NameBindingProvider#getRelationColumnNames(TTable). Bare
21093            // `*` and qualified `t.*` both arrive here as
21094            // simple_object_name_t with rc.getColumnNameOnly() == "*"
21095            // (probed; see slice-58 plan); the prior EExpressionType.list_t
21096            // branch is dead defense for stars in practice but stays in
21097            // case a future grammar variant routes them differently.
21098            String colNameOnly = rc.getColumnNameOnly();
21099            if ("*".equals(colNameOnly) || type == EExpressionType.list_t) {
21100                StarExpansionResult exp = tryExpandStar(rc, select, provider,
21101                        isPredicateBody, stmtName);
21102                switch (exp.kind) {
21103                    case EXPANDED:
21104                        out.addAll(exp.columns);
21105                        continue;
21106                    case PREDICATE_BODY_GUARD:
21107                        // Defensive; preflightPredicateSubqueryShape at line ~3880
21108                        // rejects SELECT * in EXISTS earlier with a tuned
21109                        // message. This branch only fires if a future call
21110                        // site enters buildOutputColumns with isPredicateBody
21111                        // and a star still present.
21112                        throw new SemanticIRBuildException(
21113                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_PREDICATE_BODY,
21114                                "result column " + rc + " is a star expansion (SELECT *) "
21115                                        + "inside a predicate body; not supported yet", rc));
21116                    case SYNTHETIC_BODY_CONTEXT:
21117                        // Slice 59: star expansion is rejected inside a
21118                        // synthetic body (scalar-subquery / set-op-branch /
21119                        // predicate-subquery). Multi-column expansion would
21120                        // violate the body's shape contract; the slice-58
21121                        // path could silently produce this for
21122                        // catalog-equipped builds.
21123                        throw new SemanticIRBuildException(
21124                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_SYNTHETIC_BODY,
21125                                "result column " + rc + " is a star expansion (SELECT *); "
21126                                        + (exp.detail != null ? exp.detail
21127                                                : "star expansion is not supported inside a synthetic body"), rc));
21128                    case MULTI_RELATION_FROM: {
21129                        // GSP R6 degrade (star-expansion-multi-relation-from-
21130                        // degrade.md): tryExpandStar returns this kind when a
21131                        // FROM operand is a parenthesized / nested <joined_table>
21132                        // (TJoin.getTable() / TJoinItem.getTable() is null), so
21133                        // the single-relation expander cannot pick one relation.
21134                        // The parenthesized-join recursion (buildRelations /
21135                        // buildJoinGraph) already builds a complete multi-relation
21136                        // join graph for exactly this FROM shape, so failing here
21137                        // would discard a fully-analyzable program. DEGRADE just
21138                        // like the NO_CATALOG_OR_UNKNOWN_TABLE arm below: record a
21139                        // non-fatal warning, emit the star as an UNEXPANDED MARKER
21140                        // output column (no sources — never fabricate names), and
21141                        // continue. Synthetic bodies are caught earlier as
21142                        // SYNTHETIC_BODY_CONTEXT (set-op parity unaffected);
21143                        // catalog-present flat expansion still returns EXPANDED.
21144                        //
21145                        // Scope the degrade to the genuinely-analyzable nested-join
21146                        // shape: a null FROM table caused by a parenthesized
21147                        // <joined_table> operand, for which buildRelations /
21148                        // buildJoinGraph already built the graph. A
21149                        // MULTI_RELATION_FROM with NO nested operand (the
21150                        // defensive topJoin==null path, or a null FROM table with
21151                        // no nested join) is a broken invariant with no graph to
21152                        // preserve — keep it fatal rather than fabricating a
21153                        // valid-looking program with a sourceless marker.
21154                        if (!hasNestedJoinOperand(select)) {
21155                            throw new SemanticIRBuildException(
21156                                    Diagnostic.error(DiagnosticCode.STAR_EXPANSION_MULTI_RELATION_FROM,
21157                                    "result column " + rc + " is a star expansion (SELECT *); "
21158                                            + "FROM source could not be determined", rc));
21159                        }
21160                        recordBuildWarning(Diagnostic.warn(
21161                                DiagnosticCode.STAR_EXPANSION_MULTI_RELATION_FROM,
21162                                "result column " + rc + " is a star expansion (SELECT *) "
21163                                        + "over a parenthesized / nested join — left "
21164                                        + "unexpanded (catalog-less degrade)", rc));
21165                        String mrStarQualifier = null;
21166                        if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) {
21167                            mrStarQualifier = rc.getExpr().getObjectOperand().getTableString();
21168                        }
21169                        String mrStarName = (mrStarQualifier != null && !mrStarQualifier.isEmpty())
21170                                ? mrStarQualifier + ".*" : "*";
21171                        out.add(new OutputColumn(mrStarName, /*derived=*/ false,
21172                                /*aggregate=*/ false,
21173                                Collections.<ColumnRef>emptyList(),
21174                                /*windowSpec=*/ null));
21175                        continue;
21176                    }
21177                    case NON_BASE_TABLE_RELATION:
21178                        throw new SemanticIRBuildException(
21179                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_NON_BASE_TABLE,
21180                                "result column " + rc + " is a star expansion (SELECT *); "
21181                                        + (exp.detail != null ? exp.detail
21182                                                : "FROM source must be a base table"), rc));
21183                    case QUALIFIER_NOT_FOUND:
21184                        throw new SemanticIRBuildException(
21185                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_QUALIFIER_NOT_FOUND,
21186                                "result column " + rc + " (qualified star "
21187                                        + (exp.qualifier == null ? "?" : exp.qualifier)
21188                                        + ".*) does not match any FROM-clause relation", rc));
21189                    case QUALIFIER_AMBIGUOUS:
21190                        // Slice 59: 2+ FROM relations have the same
21191                        // effective alias. Real SQL never reaches this
21192                        // unless rejectDuplicateAliases:~5621 (case-
21193                        // sensitive) allowed a case-only collision.
21194                        throw new SemanticIRBuildException(
21195                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_QUALIFIER_AMBIGUOUS,
21196                                "result column " + rc + " (qualified star "
21197                                        + (exp.qualifier == null ? "?" : exp.qualifier)
21198                                        + ".*) is ambiguous: "
21199                                        + (exp.detail != null ? exp.detail
21200                                                : "multiple FROM-clause relations match"), rc));
21201                    case NO_CATALOG_OR_UNKNOWN_TABLE: {
21202                        // GSP R6 degrade (star-expansion-no-catalog-degrade.md):
21203                        // a catalog-less SELECT * / t.* cannot be expanded to
21204                        // real column names, but the join structure is fixed by
21205                        // the FROM / ON clauses and does NOT depend on star
21206                        // expansion. Rather than discarding the whole statement
21207                        // graph (relations + join graph + scope), DEGRADE:
21208                        // record a non-fatal warning and emit the star as an
21209                        // UNEXPANDED MARKER output column (no sources — never
21210                        // fabricate column names), then continue the build.
21211                        //
21212                        // Catalog-present expansion is unaffected (it returns
21213                        // EXPANDED above). Synthetic bodies (scalar / set-op
21214                        // branch / predicate) are caught earlier as
21215                        // SYNTHETIC_BODY_CONTEXT and stay fatal, so set-op
21216                        // column-count parity is never relaxed by this degrade.
21217                        // Mirrors the NATURAL (NATURAL_CATALOG_REQUIRED) and
21218                        // USING (COLUMN_BINDING_NON_EXACT) catalog-less degrades.
21219                        String relationLabel;
21220                        if (exp.qualifier != null && !exp.qualifier.isEmpty()) {
21221                            relationLabel = exp.qualifier;
21222                        } else if (exp.detail != null && !exp.detail.isEmpty()) {
21223                            relationLabel = exp.detail;
21224                        } else {
21225                            relationLabel = "the FROM relation";
21226                        }
21227                        recordBuildWarning(Diagnostic.warn(
21228                                DiagnosticCode.STAR_EXPANSION_NO_CATALOG,
21229                                "result column " + rc + " is a star expansion (SELECT *); "
21230                                        + "requires catalog with column declarations for "
21231                                        + relationLabel
21232                                        + " — left unexpanded (catalog-less degrade)", rc));
21233                        // Unexpanded-star marker: the star's own text (`*` or
21234                        // `t.*`), no sources. Derived=false so the empty-source
21235                        // RESULT_COLUMN_NO_COLUMN_REFS guard does not apply
21236                        // (that guard targets derived no-ref expressions).
21237                        String starQualifier = null;
21238                        if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) {
21239                            starQualifier = rc.getExpr().getObjectOperand().getTableString();
21240                        }
21241                        String starName = (starQualifier != null && !starQualifier.isEmpty())
21242                                ? starQualifier + ".*" : "*";
21243                        out.add(new OutputColumn(starName, /*derived=*/ false,
21244                                /*aggregate=*/ false,
21245                                Collections.<ColumnRef>emptyList(),
21246                                /*windowSpec=*/ null));
21247                        continue;
21248                    }
21249                    case EXPLICIT_CTE_COLUMN_LIST_DEFERRED:
21250                        // Slice 103 lifted the explicit-CTE-column-list
21251                        // deferral: the SELECT-side CTE walker now runs
21252                        // the slice-102 rename helper, so the in-scope
21253                        // map publishes the renamed columns and
21254                        // expandSingleRelation returns EXPANDED instead
21255                        // of falling into this arm. The case is kept
21256                        // declared-but-unreached for API stability and
21257                        // exhaustive-switch coverage (slice 71/72/82/86
21258                        // /95/96/97/98/99/100/101/102 precedent). If a
21259                        // future call path re-introduces the kind, the
21260                        // throw still fires with a faithful diagnostic.
21261                        throw new SemanticIRBuildException(
21262                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_EXPLICIT_CTE_COLUMN_LIST,
21263                                "result column " + rc + " is a star expansion (SELECT *); "
21264                                        + (exp.detail != null ? exp.detail
21265                                                : "star expansion through an explicit CTE column list is deferred to a future slice"), rc));
21266                    case NO_INSCOPE_RELATION_COLUMNS:
21267                        // Slice 60: builder invariant failure — a CTE
21268                        // or FROM-subquery body was not registered in
21269                        // the provider's in-scope-relation-columns map
21270                        // before this consumer ran. User SQL cannot
21271                        // reach this kind under normal build()
21272                        // execution; reaching it indicates a missing
21273                        // call site is not narrowing the provider
21274                        // before invoking buildOutputColumns.
21275                        throw new SemanticIRBuildException(
21276                                Diagnostic.error(DiagnosticCode.STAR_EXPANSION_NO_INSCOPE_RELATION_COLUMNS,
21277                                "result column " + rc + " is a star expansion (SELECT *); "
21278                                        + (exp.detail != null ? exp.detail
21279                                                : "in-scope CTE/subquery column map is empty for this relation (builder invariant failure)"), rc));
21280                    // No `default`: switch is intentionally exhaustive
21281                    // over StarExpansionKind. The post-switch throw
21282                    // below is the actual runtime guard if a future
21283                    // enum value is added without updating this
21284                    // switch.
21285                }
21286                throw new SemanticIRBuildException(
21287                        Diagnostic.error(DiagnosticCode.STAR_EXPANSION_UNHANDLED_KIND,
21288                        "result column " + rc + " is a star expansion (SELECT *); "
21289                                + "unhandled StarExpansionKind=" + exp.kind, rc));
21290            }
21291            // Top-level scalar subquery in projection (slice 11). When the
21292            // caller permits it (allowScalarProjectionSubqueries=true), the
21293            // outer caller has already extracted the inner SELECT as its
21294            // own statement via extractScalarSubqueriesAsStatements; here
21295            // we just construct the OutputColumn shell with empty sources
21296            // and let emitLineageForStatement wire the
21297            // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge.
21298            if (type == EExpressionType.subquery_t) {
21299                if (!allowScalarProjectionSubqueries) {
21300                    throw new SemanticIRBuildException(
21301                            Diagnostic.error(DiagnosticCode.NESTED_SCALAR_SUBQUERY_IN_PROJECTION,
21302                            "nested scalar subquery in projection (inside another "
21303                                    + "scalar subquery body or FROM-clause subquery body) "
21304                                    + "is not supported yet", rc));
21305                }
21306                String alias = rc.getColumnAlias();
21307                if (alias == null || alias.isEmpty()) {
21308                    throw new SemanticIRBuildException(
21309                            Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_ALIAS_REQUIRED,
21310                            "scalar subquery projection must have an alias", rc));
21311                }
21312                out.add(new OutputColumn(alias, /*derived=*/ true,
21313                        /*aggregate=*/ false,
21314                        Collections.<ColumnRef>emptyList(),
21315                        /*windowSpec=*/ null));
21316                continue;
21317            }
21318            // Slice 13: detect top-level window function before deep scans
21319            // so the embedded-window rejecter can identity-skip the
21320            // legitimate top-level window function call.
21321            boolean topLevelWindow = isTopLevelWindowProjection(rc.getExpr());
21322            // Slice 33: detect Oracle / MSSQL plain WITHIN-GROUP-only
21323            // aggregate at the projection root. When admitted, the root
21324            // function carries fn.windowDef!=null but is the legitimate
21325            // top-level form — the slice-13 invariant rejecters
21326            // (isTopLevelWindowProjection / rejectWindowFunctions /
21327            // rejectEmbeddedWindowFunction) keep their strict wd!=null
21328            // check unchanged; this local boolean is what discriminates
21329            // them. The admission helper combines:
21330            //   - isWithinGroupOnlyWindowDef (no OVER, no KEEP DENSE_RANK)
21331            //   - explicit EDbVendor gate (Oracle / MSSQL only — mirrors
21332            //     the slice-31 predicate-body gate at line ~3860)
21333            //   - function name in AGGREGATE_FUNCTION_NAMES whitelist
21334            // PG / Snowflake / DB2 / SparkSQL produce direct fn.withinGroup
21335            // (windowDef=null) and never reach this admission helper; their
21336            // top-level WG already builds today via the normal aggregate
21337            // path (with pre-existing AGGREGATION_MISMATCH divergence on
21338            // the dlineage projector side that slice 33 deliberately does
21339            // not address — see the slice-30 rationale on
21340            // ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES for why a name-only
21341            // projector override is unsafe across the dual-form aggregates
21342            // SUM / MIN / MAX / LISTAGG that have OVER (PARTITION BY)
21343            // forms on Oracle).
21344            TFunctionCall slice33RootFn = rc.getExpr().getExpressionType() == EExpressionType.function_t
21345                    ? rc.getExpr().getFunctionCall()
21346                    : null;
21347            boolean slice33TopLevelWG = isAdmittedTopLevelWithinGroupAggregate(
21348                    slice33RootFn, select.dbvendor);
21349            boolean slice35TopLevelDirectWG = isAdmittedTopLevelDirectWithinGroupAggregate(
21350                    slice33RootFn, select.dbvendor);
21351            // Reject scalar subqueries embedded inside larger projection
21352            // expressions (slice 11 + codex round-2 MUST 7). Catches both
21353            // top-level subquery_t hidden under a wrapping expression
21354            // (e.g. UPPER((SELECT ...)) — though the parser sometimes
21355            // strips the wrap) AND predicate subqueries that don't surface
21356            // as subquery_t (EXISTS in projection, IN-projection).
21357            // Slice 9/10 deep-scan pattern.
21358            rejectEmbeddedSubqueryInProjection(rc.getExpr(), rc);
21359            // Slice 13: reject window functions embedded inside larger
21360            // projection expressions (e.g. `ROW_NUMBER() OVER (...) + 1`,
21361            // `UPPER(LAG(...) OVER (...))`). The helper identity-skips
21362            // the legitimate top-level window function call when
21363            // `topLevelWindow=true`.
21364            //
21365            // Slice 33: also identity-skip the top-level WITHIN-GROUP-only
21366            // aggregate root. TFunctionCall.acceptChildren preVisits the
21367            // root function (TFunctionCall.java:1528), so without
21368            // skipTopLevel=true the visitor would catch the slice-33-
21369            // admitted root (fn.windowDef!=null). Embedded WG inside
21370            // UPPER / CASE still rejects because the visitor finds a
21371            // non-root function whose windowDef!=null — the inner
21372            // function is not == identity to the root, so the skip
21373            // doesn't apply.
21374            rejectEmbeddedWindowFunction(rc.getExpr(), rc, topLevelWindow || slice33TopLevelWG);
21375            // Slice 33/35 fast path: WITHIN-GROUP-only aggregate — fall
21376            // through to the normal aggregate path. Oracle / MSSQL use the
21377            // windowDef attachment (slice 33); PostgreSQL direct attachment
21378            // is already on the normal aggregate path but shares the
21379            // unaliased expression-text fallback below (slice 35).
21380            if (slice33TopLevelWG || slice35TopLevelDirectWG) {
21381                // No special branch — fall through to the plain aggregate
21382                // / expression / column path below.
21383            } else if (topLevelWindow) {
21384                if (!allowWindowProjection) {
21385                    throw new SemanticIRBuildException(
21386                            Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_AS_PROJECTION_NOT_SUPPORTED,
21387                            "result column " + rc + " is a window function; not supported "
21388                                    + "inside this body (e.g. scalar-subquery body)", rc));
21389                }
21390                out.add(buildWindowOutputColumn(rc, select, provider));
21391                continue;
21392            }
21393            // Plain aggregate / expression / column path. The
21394            // rejectWindowFunctions call below is now defensive — the
21395            // top-level-window fast path above intercepts legitimate
21396            // windows, and rejectEmbeddedWindowFunction caught any
21397            // descendant window functions.
21398            //
21399            // Slice 33: skip rejectWindowFunctions for the slice-33-
21400            // admitted shape. The root function has windowDef!=null but
21401            // is the legitimate top-level form; calling
21402            // rejectWindowFunctions here would reject it via the
21403            // strict-wd!=null check (kept unchanged per slice-31
21404            // invariant).
21405            if (!slice33TopLevelWG && !slice35TopLevelDirectWG) {
21406                rejectWindowFunctions(rc.getExpr(), rc);
21407            }
21408            boolean derived = (type != EExpressionType.simple_object_name_t);
21409            boolean aggregate = isAggregateFunction(rc.getExpr());
21410            // Slice 28: FILTER-aware collector excludes column refs inside
21411            // FILTER (WHERE ...) subtrees so OutputColumn.sources matches
21412            // dlineage's lineage-relationship view (FILTER predicate refs
21413            // absent from fdd / fdr).
21414            // Slice 31: also excludes column refs inside Oracle / MSSQL
21415            // {@code fn.windowDef.withinGroup} so plain WITHIN GROUP
21416            // aggregates emit sources from function args only. Defense-
21417            // in-depth here: the slice-31 lift only admits Oracle / MSSQL
21418            // plain WITHIN GROUP at the unaliased predicate-body
21419            // short-circuit (line ~5216) — the strict
21420            // {@link #rejectWindowFunctions} call above keeps top-level
21421            // windowDef-bearing projections rejected outside the
21422            // predicate-body context, so this collector reduces to the
21423            // slice-28 FILTER-only variant in practice today.
21424            List<ColumnRef> sources = collectColumnRefsExcludingFilterAndWithinGroupClauses(rc, provider);
21425            if (sources.isEmpty() && !aggregate) {
21426                boolean canonicalConstant = isConstantExpression(rc.getExpr());
21427                boolean inScalarBody = isScalarSyntheticName(stmtName);
21428                if (canonicalConstant && !inScalarBody) {
21429                    // Slice 61: constant-only projection lift. Predicate
21430                    // bodies still use the earlier slice-23 short-circuit,
21431                    // while scalar-subquery bodies intentionally keep the
21432                    // slice-11/20 invariant that scalar body projections
21433                    // must have a column source.
21434                    String alias = rc.getColumnAlias();
21435                    String name = (alias != null && !alias.isEmpty())
21436                            ? alias
21437                            : rc.getExpr().toString();
21438                    out.add(new OutputColumn(name, /*derived=*/ true,
21439                            /*aggregate=*/ false,
21440                            Collections.<ColumnRef>emptyList(),
21441                            /*windowSpec=*/ null));
21442                    continue;
21443                }
21444                // Join-structure degrade (GSP R6): a plain unqualified column
21445                // that could not be bound under a fully-built join graph (its
21446                // COLUMN_BINDING_NON_EXACT was downgraded to a warning by
21447                // rejectNonExactBindings) is emitted with no sources rather
21448                // than aborting the whole analysis. The join structure is
21449                // intact; only this column's source side is unknown without a
21450                // catalog (honest: not fabricated). Both anchors qualify:
21451                //   - a JOIN ... USING merged-key scope, OR
21452                //   - an explicit ON / CROSS / comma join graph
21453                //     (hasJoinStructureAnchor()).
21454                // A qualified miss never reaches here — it already aborted
21455                // fatally in the column collector (rejectNonExactBindings with
21456                // allRejectsUnqualified=false). Derived expressions
21457                // (e.g. UPPER('lit')) keep the !derived guard and stay fatal,
21458                // as do scalar bodies (they must carry a column source).
21459                if (!derived && !inScalarBody
21460                        && (provider.getUsingScope().hasMergedKeys()
21461                                || provider.hasJoinStructureAnchor())) {
21462                    String alias = rc.getColumnAlias();
21463                    String name = (alias != null && !alias.isEmpty())
21464                            ? alias
21465                            : rc.getExpr().toString();
21466                    out.add(new OutputColumn(name, /*derived=*/ false,
21467                            /*aggregate=*/ false,
21468                            Collections.<ColumnRef>emptyList(),
21469                            /*windowSpec=*/ null));
21470                    continue;
21471                }
21472                throw new SemanticIRBuildException(
21473                        Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NO_COLUMN_REFS,
21474                        "result column " + rc + " has no column references "
21475                                + "and is not a constant or aggregate expression "
21476                                + "(e.g. UPPER('literal') / CAST / current_date - not supported yet)", rc));
21477            }
21478            // Slice 34: when the slice-33-admitted top-level Oracle / MSSQL
21479            // WITHIN-GROUP-only aggregate has no alias, fall back to the
21480            // parser's expression text. {@code effectiveOutputName} would
21481            // throw "neither alias nor column name" because
21482            // {@code function_t} returns "" from getColumnNameOnly().
21483            // Probe-verified that {@code rc.getExpr().toString()} byte-
21484            // matches dlineage's <select_list> column name attribute on
21485            // Oracle / MSSQL for this shape, so canonical SELECT-edge
21486            // outputName remains in parity with no projector change.
21487            // Gated tightly on slice33TopLevelWG so unrelated unaliased
21488            // shapes (function calls / CASE / expressions outside the
21489            // slice-33 admit set) keep failing loudly via
21490            // effectiveOutputName until each is probed and admitted
21491            // explicitly. See Slice34Test.
21492            String name;
21493            if (slice33TopLevelWG || slice35TopLevelDirectWG) {
21494                String alias = rc.getColumnAlias();
21495                name = (alias != null && !alias.isEmpty())
21496                        ? alias
21497                        : rc.getExpr().toString();
21498            } else {
21499                name = effectiveOutputName(rc);
21500            }
21501            out.add(new OutputColumn(name, derived, aggregate, sources, /*windowSpec=*/ null));
21502        }
21503        return out;
21504    }
21505
21506    /**
21507     * Reject scalar subqueries embedded inside larger projection
21508     * expressions (slice 11). Catches:
21509     *
21510     * <ul>
21511     *   <li>{@code SELECT UPPER((SELECT MAX(salary) AS m FROM employees))
21512     *       AS x FROM ...} — scalar nested inside a function call.</li>
21513     *   <li>{@code SELECT EXISTS (SELECT 1 FROM employees) AS has_emp
21514     *       FROM ...} — EXISTS doesn't surface as
21515     *       {@link EExpressionType#subquery_t} but carries
21516     *       {@code getSubQuery() != null} (slice-9 round-3 lesson).</li>
21517     *   <li>Other in-expression subqueries that
21518     *       {@link #collectColumnRefs} would otherwise descend into.</li>
21519     * </ul>
21520     *
21521     * <p>Only top-level {@code subquery_t} projections are extracted as
21522     * separate statements (handled in
21523     * {@link #extractScalarSubqueriesAsStatements}); embedded subqueries
21524     * remain rejected because the IR doesn't yet model the "expression
21525     * over subquery result" shape.
21526     */
21527    private static void rejectEmbeddedSubqueryInProjection(TExpression expr, TResultColumn rc) {
21528        if (expr == null) return;
21529        final boolean[] found = {false};
21530        expr.acceptChildren(new TParseTreeVisitor() {
21531            @Override
21532            public void preVisit(TExpression e) {
21533                if (found[0]) return;
21534                if (e.getExpressionType() == EExpressionType.subquery_t
21535                        || e.getSubQuery() != null) {
21536                    found[0] = true;
21537                }
21538            }
21539        });
21540        if (!found[0]) {
21541            // Top-level expression itself may carry a subquery (e.g. EXISTS
21542            // at the projection root, where rc.getExpr() is exists_t with
21543            // non-null getSubQuery() but is NOT subquery_t — so the
21544            // top-level subquery_t branch above didn't extract it).
21545            if (expr.getExpressionType() != EExpressionType.subquery_t
21546                    && expr.getSubQuery() != null) {
21547                found[0] = true;
21548            }
21549        }
21550        if (found[0]) {
21551            throw new SemanticIRBuildException(
21552                    Diagnostic.error(DiagnosticCode.RESULT_COLUMN_SCALAR_SUBQUERY_EMBEDDED,
21553                    "result column " + rc + " contains a scalar subquery embedded "
21554                            + "in a larger projection expression; not supported yet "
21555                            + "(only top-level scalar subquery projections are extracted)", rc));
21556        }
21557    }
21558
21559    /**
21560     * Detect whether an expression contains an aggregate function call
21561     * anywhere in its subtree. Slice 6 uses a name whitelist via
21562     * {@link #AGGREGATE_FUNCTION_NAMES}. Walking recursively means
21563     * {@code SUM(salary) + 1} and {@code COUNT(*) + 1} are both classified
21564     * as aggregate (and thus permitted with empty sources via
21565     * {@link #buildOutputColumns}). Wrapped in a helper so slice 7+ can
21566     * swap in deeper detection (e.g. vendor-specific function classification
21567     * on TFunctionCall) without touching call sites.
21568     *
21569     * <p>Note on aggregate literals like {@code COUNT(1)} or {@code SUM(1)}:
21570     * the visitor finds no column refs, so {@code sources=[]}. Slice 6
21571     * permits these as aggregates with no lineage edges; consumers must
21572     * read {@link OutputColumn#isAggregate()} to know the value is
21573     * row-collapsing without column lineage.
21574     */
21575    private static boolean isAggregateFunction(TExpression expr) {
21576        if (expr == null) return false;
21577        // Slice 13: short-circuit for top-level window function. The
21578        // upstream `rejectEmbeddedWindowFunction` has already rejected any
21579        // embedded window functions, but this short-circuit ensures
21580        // `AVG(salary) OVER (...)` is never classified as an aggregate
21581        // even if it somehow slips past the upstream guard.
21582        //
21583        // Slice 31: discriminate WITHIN-GROUP-only windowDef (Oracle /
21584        // MSSQL plain WITHIN GROUP attachment without OVER) so
21585        // `LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY x.region)` stays
21586        // classified as an aggregate. Uses {@link #isWindowDefBearingFunction}
21587        // — only this check and {@link #containsWindowFunction} are
21588        // lifted; every other slice-13 invariant rejecter is unchanged.
21589        if (expr.getExpressionType() == EExpressionType.function_t) {
21590            TFunctionCall rootFn = expr.getFunctionCall();
21591            // Slice 42: hypothetical-set ordered-set aggregate root
21592            // (Oracle / MSSQL {@code RANK(100) WITHIN GROUP (ORDER BY x)})
21593            // — short-circuit aggregate=true. The shape predicate
21594            // {@link #isHypotheticalSetWithinGroupCall} requires WITHIN-
21595            // GROUP-only windowDef AND a name in
21596            // {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES}, so PG direct
21597            // attachment ({@code fn.getWindowDef()==null}) and OVER-
21598            // bearing forms cannot fire it.
21599            if (isHypotheticalSetWithinGroupCall(rootFn)) {
21600                return true;
21601            }
21602            if (isWindowDefBearingFunction(rootFn)) {
21603                return false;
21604            }
21605        }
21606        final boolean[] found = {false};
21607        expr.acceptChildren(new TParseTreeVisitor() {
21608            @Override
21609            public void preVisit(TFunctionCall fn) {
21610                if (found[0]) return;
21611                // Slice 13 codex round-2 SHOULD 3: skip windowed function
21612                // calls inside the visitor too, defensively. Upstream
21613                // rejection should already have fired, but this removes
21614                // overlap risk for `sum/count/avg`.
21615                //
21616                // Slice 31: same WITHIN-GROUP-only carve-out as the root
21617                // short-circuit above so an Oracle / MSSQL plain WITHIN
21618                // GROUP aggregate nested inside CASE/UPPER (slice-27
21619                // admit) is still picked up as aggregate.
21620                //
21621                // Slice 42: hypothetical-set ordered-set aggregate carve-
21622                // out — descendants matching the shape predicate count
21623                // as aggregate (defense-in-depth; the slice-13 embedded-
21624                // window rejecter already fires on inner WG-bearing
21625                // calls, so this branch is mostly unreachable today).
21626                if (isHypotheticalSetWithinGroupCall(fn)) {
21627                    found[0] = true;
21628                    return;
21629                }
21630                if (isWindowDefBearingFunction(fn)) return;
21631                if (fn.getFunctionName() == null) return;
21632                String name = fn.getFunctionName().toString();
21633                if (name == null || name.isEmpty()) return;
21634                if (AGGREGATE_FUNCTION_NAMES.contains(name.toLowerCase(Locale.ROOT))) {
21635                    found[0] = true;
21636                }
21637            }
21638        });
21639        // The root expression itself is not visited by acceptChildren — only
21640        // its children. If the root is the function call (the common case
21641        // for `SUM(salary)` with no enclosing arithmetic), check it too.
21642        if (!found[0] && expr.getExpressionType() == EExpressionType.function_t) {
21643            TFunctionCall fn = expr.getFunctionCall();
21644            if (fn != null && fn.getFunctionName() != null) {
21645                String name = fn.getFunctionName().toString();
21646                if (name != null && !name.isEmpty()
21647                        && AGGREGATE_FUNCTION_NAMES.contains(name.toLowerCase(Locale.ROOT))) {
21648                    found[0] = true;
21649                }
21650            }
21651        }
21652        return found[0];
21653    }
21654
21655    /**
21656     * Reject window-function projections like {@code AVG(salary) OVER (...)}.
21657     * In the GSP AST these still parse as {@code function_t} with a
21658     * non-null {@code TFunctionCall.getWindowDef()}, but their semantics
21659     * are row-preserving (analytic), not row-collapsing (aggregate). Slice
21660     * 6 owns plain GROUP BY aggregation only; window functions deserve
21661     * their own slice.
21662     */
21663    private static void rejectWindowFunctions(TExpression expr, TResultColumn rc) {
21664        if (expr == null) return;
21665        final boolean[] found = {false};
21666        expr.acceptChildren(new TParseTreeVisitor() {
21667            @Override
21668            public void preVisit(TFunctionCall fn) {
21669                if (found[0]) return;
21670                if (fn.getWindowDef() != null) found[0] = true;
21671            }
21672        });
21673        if (!found[0] && expr.getExpressionType() == EExpressionType.function_t) {
21674            TFunctionCall fn = expr.getFunctionCall();
21675            if (fn != null && fn.getWindowDef() != null) found[0] = true;
21676        }
21677        if (found[0]) {
21678            throw new SemanticIRBuildException(
21679                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_USED_NOT_SUPPORTED,
21680                    "result column " + rc + " uses a window function (OVER (...)); not supported yet", rc));
21681        }
21682    }
21683
21684    /**
21685     * Slice 13: detect whether the projection root is a top-level
21686     * window-function call. Returns {@code true} iff
21687     * {@code expr.getExpressionType() == function_t} AND the function
21688     * call carries a non-null {@code TWindowDef}. The result drives
21689     * three things in {@link #buildOutputColumns}:
21690     *
21691     * <ul>
21692     *   <li>The {@code skipTopLevel} arg to
21693     *       {@link #rejectEmbeddedWindowFunction} so the legitimate
21694     *       top-level window call is identity-skipped during embedded
21695     *       detection.</li>
21696     *   <li>The fast-path dispatch into
21697     *       {@link #buildWindowOutputColumn} when window projections
21698     *       are allowed.</li>
21699     *   <li>The scalar-body / future-context rejection when window
21700     *       projections are forbidden in the surrounding context
21701     *       (slice-13 {@code allowWindowProjection=false}).</li>
21702     * </ul>
21703     */
21704    private static boolean isTopLevelWindowProjection(TExpression expr) {
21705        if (expr == null) return false;
21706        if (expr.getExpressionType() != EExpressionType.function_t) return false;
21707        TFunctionCall fn = expr.getFunctionCall();
21708        return fn != null && fn.getWindowDef() != null;
21709    }
21710
21711    /**
21712     * Slice 13: reject window functions embedded inside a larger
21713     * projection expression (mirrors slice 11's
21714     * {@link #rejectEmbeddedSubqueryInProjection}). The {@code skipTopLevel}
21715     * flag is set when the caller has identified
21716     * {@code expr} as a legitimate top-level window-function projection
21717     * — without identity-skipping that exact {@code TFunctionCall} the
21718     * visitor would reject every valid top-level window.
21719     *
21720     * <p>Visitor-only (no post-visitor fallback): unlike
21721     * {@code subquery_t} which can be wrapped in expression types that
21722     * do not surface as {@code subquery_t}, a window function is always
21723     * reachable through {@code TExpression.acceptChildren} →
21724     * {@code TFunctionCall.preVisit}. Codex round-3 MUST 1.
21725     */
21726    private static void rejectEmbeddedWindowFunction(TExpression expr,
21727                                                     TResultColumn rc,
21728                                                     boolean skipTopLevel) {
21729        if (expr == null) return;
21730        final TFunctionCall topLevelFn =
21731                skipTopLevel
21732                        && expr.getExpressionType() == EExpressionType.function_t
21733                        ? expr.getFunctionCall()
21734                        : null;
21735        final boolean[] found = {false};
21736        expr.acceptChildren(new TParseTreeVisitor() {
21737            @Override
21738            public void preVisit(TFunctionCall fn) {
21739                if (found[0]) return;
21740                if (fn == topLevelFn) return;            // identity-skip
21741                if (fn.getWindowDef() != null) found[0] = true;
21742            }
21743        });
21744        if (found[0]) {
21745            throw new SemanticIRBuildException(
21746                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_EMBEDDED_NOT_SUPPORTED,
21747                    "result column " + rc + " contains a window function embedded "
21748                            + "in a larger projection expression; not supported yet "
21749                            + "(only top-level window-function projections are supported)", rc));
21750        }
21751    }
21752
21753    /**
21754     * Lower-cased function names accepted as window functions in slice 13.
21755     * Includes every name in {@link #AGGREGATE_FUNCTION_NAMES} (aggregates
21756     * can be windowed: {@code SUM(...) OVER (...)}, {@code AVG(...) OVER (...)},
21757     * etc.) plus the analytic-only names. New analytic functions must be
21758     * added here explicitly to avoid silent acceptance of an unfamiliar
21759     * window function whose semantics the slice does not yet model.
21760     *
21761     * <p>Slice 30 exception: {@code mode} is added to
21762     * {@link #AGGREGATE_FUNCTION_NAMES} for the WITHIN GROUP path but
21763     * REMOVED from this allowlist via {@code s.remove("mode")} below —
21764     * {@code mode()} has no documented window form in any GSP-supported
21765     * vendor and the explicit removal keeps {@code mode() OVER (...)}
21766     * (which the PostgreSQL parser accepts) rejected by
21767     * {@code buildWindowOutputColumn}.
21768     */
21769    private static final Set<String> WINDOW_FUNCTION_NAMES;
21770    static {
21771        Set<String> s = new HashSet<>();
21772        // Aggregate names that can be windowed.
21773        s.addAll(AGGREGATE_FUNCTION_NAMES);
21774        // Slice 30: mode is an ordered-set-only aggregate; remove from the
21775        // window allowlist (it was added to AGGREGATE_FUNCTION_NAMES for the
21776        // WITHIN GROUP path but never appears as a real window function in
21777        // any GSP-supported vendor — see Slice30Test.pgModeOverStillRejected
21778        // AtOuterProjection for the lock-in).
21779        s.remove("mode");
21780        // Analytic-only window functions.
21781        s.add("row_number");
21782        s.add("rank");
21783        s.add("dense_rank");
21784        s.add("lag");
21785        s.add("lead");
21786        s.add("ntile");
21787        s.add("first_value");
21788        s.add("last_value");
21789        s.add("percent_rank");
21790        s.add("cume_dist");
21791        s.add("nth_value");
21792        WINDOW_FUNCTION_NAMES = Collections.unmodifiableSet(s);
21793    }
21794
21795    /**
21796     * Slice 13: build an {@link OutputColumn} for a top-level
21797     * window-function projection. Caller must have already verified
21798     * {@link #isTopLevelWindowProjection(TExpression)}, run the
21799     * {@link #rejectEmbeddedSubqueryInProjection} and
21800     * {@link #rejectEmbeddedWindowFunction} guards, and confirmed the
21801     * surrounding body permits window projections (i.e., the
21802     * {@code !allowWindowProjection} fast-path in
21803     * {@link #buildOutputColumns} did not fire).
21804     *
21805     * <p>The constructed {@link OutputColumn} carries:
21806     * <ul>
21807     *   <li>{@code derived = true} (window functions are computed)</li>
21808     *   <li>{@code aggregate = false} (window functions are
21809     *       row-preserving — see slice-13 §14)</li>
21810     *   <li>{@code sources} = column refs from the function args only
21811     *       (PARTITION BY / OVER ORDER BY refs are excluded so that
21812     *       canonical SELECT lineage matches dlineage's
21813     *       function-arg-only SELECT BFS)</li>
21814     *   <li>{@code windowSpec = WindowSpec(partitionRefs, orderRefs, frame)}
21815     *       (slice 22 — frame may be null when the SQL has no
21816     *       {@code ROWS}/{@code RANGE}/{@code GROUPS BETWEEN ...} clause)</li>
21817     * </ul>
21818     */
21819    private static OutputColumn buildWindowOutputColumn(TResultColumn rc,
21820                                                        TSelectSqlStatement enclosingSelect,
21821                                                        NameBindingProvider provider) {
21822        TFunctionCall fn = rc.getExpr().getFunctionCall();
21823        TWindowDef wd = fn.getWindowDef();
21824
21825        // 1. Function-name allowlist (codex round-1 MUST 3).
21826        String fnName = fn.getFunctionName() == null ? null : fn.getFunctionName().toString();
21827        if (fnName == null || !WINDOW_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) {
21828            throw new SemanticIRBuildException(
21829                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_UNSUPPORTED,
21830                    "result column " + rc + " uses unsupported window function '"
21831                            + fnName + "'; supported names are " + WINDOW_FUNCTION_NAMES, rc));
21832        }
21833
21834        // 2. Reject vendor-specific function-level surfaces (codex round-1 MUST 4).
21835        if (fn.getFilterClause() != null) {
21836            throw new SemanticIRBuildException(
21837                    Diagnostic.error(DiagnosticCode.WINDOW_FILTER_NOT_SUPPORTED,
21838                    "result column " + rc + " uses FILTER (WHERE ...) on a "
21839                            + "window function; not supported yet", rc));
21840        }
21841        if (fn.getWithinGroup() != null) {
21842            throw new SemanticIRBuildException(
21843                    Diagnostic.error(DiagnosticCode.WINDOW_WITHIN_GROUP_NOT_SUPPORTED,
21844                    "result column " + rc + " uses WITHIN GROUP on a "
21845                            + "window function; not supported yet", rc));
21846        }
21847        if (fn.getOrderByList() != null && fn.getOrderByList().size() > 0) {
21848            throw new SemanticIRBuildException(
21849                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_LEVEL_ORDER_BY_NOT_SUPPORTED,
21850                    "result column " + rc + " uses function-level ORDER BY "
21851                            + "(LISTAGG-style); not supported yet", rc));
21852        }
21853        if (fn.getSortClause() != null) {
21854            throw new SemanticIRBuildException(
21855                    Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_LEVEL_SORT_NOT_SUPPORTED,
21856                    "result column " + rc + " uses function-level SORT clause; "
21857                            + "not supported yet", rc));
21858        }
21859
21860        // 3. Reject vendor-specific window-def surfaces (codex round-1 MUSTs 5, 7).
21861        if (wd.getName() != null) {
21862            throw new SemanticIRBuildException(
21863                    Diagnostic.error(DiagnosticCode.WINDOW_NAMED_WINDOW_DECLARATION_NOT_SUPPORTED,
21864                    "result column " + rc + " declares a named window "
21865                            + "(WINDOW name AS); not supported yet", rc));
21866        }
21867        if (wd.getReferenceName() != null) {
21868            throw new SemanticIRBuildException(
21869                    Diagnostic.error(DiagnosticCode.WINDOW_NAMED_WINDOW_REFERENCE_NOT_SUPPORTED,
21870                    "result column " + rc + " references a named window via "
21871                            + "OVER name; not supported yet", rc));
21872        }
21873        if (wd.getWithinGroup() != null) {
21874            throw new SemanticIRBuildException(
21875                    Diagnostic.error(DiagnosticCode.WINDOW_WITHIN_GROUP_INSIDE_PROJECTION_NOT_SUPPORTED,
21876                    "result column " + rc + " uses WITHIN GROUP inside the "
21877                            + "OVER clause; not supported yet", rc));
21878        }
21879        if (wd.getKeepDenseRankClause() != null) {
21880            throw new SemanticIRBuildException(
21881                    Diagnostic.error(DiagnosticCode.WINDOW_KEEP_DENSE_RANK_NOT_SUPPORTED,
21882                    "result column " + rc + " uses KEEP DENSE_RANK FIRST/LAST; "
21883                            + "not supported yet", rc));
21884        }
21885        if (wd.getDistributeBy() != null) {
21886            throw new SemanticIRBuildException(
21887                    Diagnostic.error(DiagnosticCode.WINDOW_DISTRIBUTE_BY_NOT_SUPPORTED,
21888                    "result column " + rc + " uses Hive DISTRIBUTE BY in window; "
21889                            + "not supported yet", rc));
21890        }
21891        if (wd.getClusterBy() != null) {
21892            throw new SemanticIRBuildException(
21893                    Diagnostic.error(DiagnosticCode.WINDOW_CLUSTER_BY_NOT_SUPPORTED,
21894                    "result column " + rc + " uses Hive CLUSTER BY in window; "
21895                            + "not supported yet", rc));
21896        }
21897        if (wd.getSortBy() != null) {
21898            throw new SemanticIRBuildException(
21899                    Diagnostic.error(DiagnosticCode.WINDOW_SORT_BY_NOT_SUPPORTED,
21900                    "result column " + rc + " uses Hive SORT BY in window; "
21901                            + "not supported yet", rc));
21902        }
21903        // Slice 22: frame clauses are now built into WindowSpec.frame; the
21904        // slice-13 wholesale rejection is gone. Frame build happens AFTER
21905        // empty-OVER reject below so a frame-only OVER (...) fails on
21906        // empty-OVER first (the more user-tuned error message).
21907
21908        // 4. Reject empty OVER () (slice-13 boundary; dlineage parity —
21909        // empty OVER () is byte-identical to a plain aggregate in the XML).
21910        TPartitionClause pc = wd.getPartitionClause();
21911        TOrderBy ob = wd.getOrderBy();
21912        boolean hasPartitionBy = pc != null
21913                && pc.getExpressionList() != null
21914                && pc.getExpressionList().size() > 0;
21915        boolean hasOverOrderBy = ob != null
21916                && ob.getItems() != null
21917                && ob.getItems().size() > 0;
21918        if (!hasPartitionBy && !hasOverOrderBy) {
21919            throw new SemanticIRBuildException(
21920                    Diagnostic.error(DiagnosticCode.WINDOW_EMPTY_OVER_NOT_SUPPORTED,
21921                    "result column " + rc + " uses empty OVER (); not supported yet "
21922                            + "(dlineage XML cannot discriminate from a plain aggregate)", rc));
21923        }
21924
21925        // 5. Reject Hive PARTITION BY ... SORT (...).
21926        if (pc != null && pc.getSortedColumns() != null && pc.getSortedColumns().size() > 0) {
21927            throw new SemanticIRBuildException(
21928                    Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_SORT_NOT_SUPPORTED,
21929                    "result column " + rc + " uses Hive PARTITION BY ... SORT (...); "
21930                            + "not supported yet", rc));
21931        }
21932
21933        // 6. Build PARTITION BY refs.
21934        List<ColumnRef> partitionRefs = hasPartitionBy
21935                ? buildWindowPartitionRefs(pc, rc, enclosingSelect, provider)
21936                : new ArrayList<ColumnRef>();
21937
21938        // 7. Build OVER ORDER BY refs.
21939        List<ColumnRef> orderRefs = hasOverOrderBy
21940                ? buildWindowOrderRefs(ob, rc, enclosingSelect, provider)
21941                : new ArrayList<ColumnRef>();
21942
21943        // 8. Build frame (slice 22). Null when the SQL has no ROWS/RANGE/
21944        // GROUPS clause inside OVER (...).
21945        WindowFrame frame = wd.getWindowFrame() == null
21946                ? null
21947                : buildWindowFrame(wd.getWindowFrame(), rc);
21948
21949        // 9. Build sources from args only — PARTITION BY / OVER ORDER BY
21950        // refs must NOT leak into OutputColumn.sources because canonical
21951        // SELECT lineage on the dlineage side only walks fdd edges
21952        // (function args), not fdr edges (PARTITION BY / OVER ORDER BY).
21953        List<ColumnRef> sources = (fn.getArgs() == null || fn.getArgs().size() == 0)
21954                ? new ArrayList<ColumnRef>()
21955                : collectColumnRefs(fn.getArgs(), provider);
21956
21957        // 10. Construct OutputColumn. aggregate=false ALWAYS for window
21958        // functions (row-preserving). The OutputColumn ctor enforces
21959        // the windowSpec!=null AND aggregate=false invariant.
21960        String name = effectiveOutputName(rc);
21961        return new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ false,
21962                sources, new WindowSpec(partitionRefs, orderRefs, frame));
21963    }
21964
21965    /**
21966     * Slice 22: build a {@link WindowFrame} from a parser
21967     * {@link TWindowFrame}. Frame information is presentation-only
21968     * (dlineage XML harvests no frame data — see
21969     * {@code DataFlowAnalyzer.java:20558-20575}); this helper captures
21970     * the surface shape into the IR for governance consumers without
21971     * touching the canonical lineage model.
21972     *
21973     * <p>Direct field access via {@link TWindowFrame#getStartBoundary()} /
21974     * {@link TWindowFrame#getEndBoundary()}; visitors are NOT used because
21975     * {@code TWindowFrame.acceptChildren()} doesn't recurse into the
21976     * boundaries (codex round-1 SHOULD 3).
21977     *
21978     * <p>Order of guards (codex round-2 SHOULD 2): EXCLUDE first so the
21979     * error message is tuned to the actual surface; then null-guard the
21980     * boundary type (defensive — current parsers always pass it); then
21981     * map the {@code EBoundaryType} via an exhaustive switch
21982     * (slice-14 process lesson #17 — no catch-all); then check the
21983     * {@code boundaryNumber} expression type and reject non-constant
21984     * offsets (codex round-1 SHOULD 1 — PG {@code simple_object_name_t}
21985     * and ANSI {@code parenthesis_t} are reachable).
21986     *
21987     * <p>Null guards on the frame's {@link ELimitRowType} and
21988     * {@code startBoundary} fields are defensive / forward-compat: every
21989     * vendor grammar surveyed (codex round-4 NOTE 1) passes these
21990     * arguments together when constructing a {@code TWindowFrame}, so the
21991     * guards are unexercised by current parsers but protect against
21992     * future parser drift.
21993     */
21994    private static WindowFrame buildWindowFrame(TWindowFrame wf, TResultColumn rc) {
21995        // Defensive null guards (codex round-2 MUST 2; codex round-4
21996        // SHOULD 1 — labelled DEFENSIVE / FORWARD-COMPAT).
21997        if (wf.getLimitRowType() == null) {
21998            throw new SemanticIRBuildException(
21999                    Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_LIMIT_ROW_TYPE,
22000                    "result column " + rc + " has a frame with null limitRowType "
22001                            + "(forward-compat / unexpected parser shape); not supported", rc));
22002        }
22003        if (wf.getStartBoundary() == null) {
22004            throw new SemanticIRBuildException(
22005                    Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_START_BOUNDARY,
22006                    "result column " + rc + " has a frame with null start boundary "
22007                            + "(forward-compat / unexpected parser shape); not supported", rc));
22008        }
22009        WindowFrame.Unit unit = mapFrameUnit(wf.getLimitRowType());
22010        FrameBound start = buildFrameBound(wf.getStartBoundary(), rc, /*end=*/ false);
22011        FrameBound end = wf.getEndBoundary() == null
22012                ? null
22013                : buildFrameBound(wf.getEndBoundary(), rc, /*end=*/ true);
22014        return new WindowFrame(unit, start, end);
22015    }
22016
22017    /**
22018     * Slice 22: map the parser's {@link ELimitRowType} to the IR's
22019     * {@link WindowFrame.Unit}. Exhaustive switch (slice-14 process
22020     * lesson #17 — no catch-all); a future enum addition fails closed.
22021     */
22022    private static WindowFrame.Unit mapFrameUnit(ELimitRowType type) {
22023        switch (type) {
22024            case Rows:
22025                return WindowFrame.Unit.ROWS;
22026            case Range:
22027                return WindowFrame.Unit.RANGE;
22028            case Groups:
22029                return WindowFrame.Unit.GROUPS;
22030            default:
22031                throw new SemanticIRBuildException(
22032                        Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNSUPPORTED_LIMIT_ROW_TYPE,
22033                        "unsupported window frame limitRowType: " + type, null));
22034        }
22035    }
22036
22037    /**
22038     * Slice 22: build a {@link FrameBound} from a parser
22039     * {@link TWindowFrameBoundary}. The {@code end} parameter is for
22040     * error messages only (start vs end disambiguation).
22041     *
22042     * <p>Per-bound check order: EXCLUDE → boundaryType-null → kind switch
22043     * → boundaryNumber shape (codex round-2 SHOULD 2 + slice-22 invariant).
22044     */
22045    private static FrameBound buildFrameBound(TWindowFrameBoundary boundary,
22046                                              TResultColumn rc,
22047                                              boolean end) {
22048        String which = end ? "end" : "start";
22049
22050        // (a) EXCLUDE first (codex round-1 MUST 2 + Netezza probe).
22051        // Netezza populates getExclusionClause() on the END boundary for
22052        // EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS; rejecting here
22053        // surfaces the unsupported clause with a tuned message rather
22054        // than letting the offset-shape check fire on an unrelated
22055        // surface.
22056        if (boundary.getExclusionClause() != null) {
22057            throw new SemanticIRBuildException(
22058                    Diagnostic.error(DiagnosticCode.WINDOW_FRAME_EXCLUDE_NOT_SUPPORTED,
22059                    "result column " + rc + " has a frame " + which
22060                            + " boundary with EXCLUDE clause "
22061                            + "(EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS); "
22062                            + "not supported yet", rc));
22063        }
22064
22065        // (b) Null-guard the boundary type (defensive).
22066        if (boundary.getBoundaryType() == null) {
22067            throw new SemanticIRBuildException(
22068                    Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_BOUNDARY_TYPE,
22069                    "result column " + rc + " has a frame " + which
22070                            + " boundary with null boundaryType "
22071                            + "(forward-compat / unexpected parser shape); not supported", rc));
22072        }
22073
22074        // (c) Map the kind via exhaustive switch.
22075        FrameBound.Kind kind = mapBoundaryKind(boundary.getBoundaryType());
22076
22077        // (d) Capture the optional offset literal. Reject non-constant
22078        // offsets (codex round-1 SHOULD 1 + slice-22 PG/ANSI probe — PG
22079        // accepts simple_object_name_t (column ROWS BETWEEN x PRECEDING ...),
22080        // ANSI accepts parenthesis_t ((x+1))).
22081        String offsetLiteral = null;
22082        TExpression offsetExpr = boundary.getBoundaryNumber();
22083        if (offsetExpr != null) {
22084            // Slice-22 codex impl-review SHOULD 1: when the kind forbids
22085            // an offset (UNBOUNDED_*/CURRENT_ROW), reject with
22086            // SemanticIRBuildException so the failure stays inside the
22087            // builder's error contract — without this guard, a parser
22088            // surfacing a stray boundary number on CURRENT_ROW would
22089            // escape as IllegalArgumentException from
22090            // FrameBound's ctor.
22091            boolean offsetAllowed = (kind == FrameBound.Kind.PRECEDING
22092                    || kind == FrameBound.Kind.FOLLOWING);
22093            if (!offsetAllowed) {
22094                throw new SemanticIRBuildException(
22095                        Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNEXPECTED_OFFSET,
22096                        "result column " + rc + " has a frame " + which
22097                                + " boundary of kind " + kind
22098                                + " carrying an unexpected offset '"
22099                                + offsetExpr + "' (forward-compat / "
22100                                + "unexpected parser shape); not supported", rc));
22101            }
22102            EExpressionType offsetType = offsetExpr.getExpressionType();
22103            if (offsetType != EExpressionType.simple_constant_t) {
22104                throw new SemanticIRBuildException(
22105                        Diagnostic.error(DiagnosticCode.WINDOW_FRAME_OFFSET_NON_CONSTANT,
22106                        "result column " + rc + " has a frame " + which
22107                                + " offset that is not a simple constant "
22108                                + "(got " + offsetType + " '" + offsetExpr + "'); "
22109                                + "not supported yet", rc));
22110            }
22111            offsetLiteral = offsetExpr.toString();
22112        }
22113        return new FrameBound(kind, offsetLiteral);
22114    }
22115
22116    /**
22117     * Slice 22: map the parser's {@link EBoundaryType} to the IR's
22118     * {@link FrameBound.Kind}. Exhaustive switch (slice-14 process
22119     * lesson #17).
22120     */
22121    private static FrameBound.Kind mapBoundaryKind(EBoundaryType type) {
22122        switch (type) {
22123            case ebtUnboundedPreceding:
22124                return FrameBound.Kind.UNBOUNDED_PRECEDING;
22125            case ebtUnboundedFollowing:
22126                return FrameBound.Kind.UNBOUNDED_FOLLOWING;
22127            case ebtCurrentRow:
22128                return FrameBound.Kind.CURRENT_ROW;
22129            case ebtPreceding:
22130                return FrameBound.Kind.PRECEDING;
22131            case ebtFollowing:
22132                return FrameBound.Kind.FOLLOWING;
22133            default:
22134                throw new SemanticIRBuildException(
22135                        Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNSUPPORTED_BOUNDARY_TYPE,
22136                        "unsupported frame boundary type: " + type, null));
22137        }
22138    }
22139
22140    /**
22141     * Slice 13: build the PARTITION BY ref list. Every item must be a
22142     * physical column reference ({@code simple_object_name_t} resolving
22143     * via the provider to {@code EXACT_MATCH}). Other shapes are
22144     * rejected with a tuned message — slice-9 / slice-13
22145     * rejection-over-silent-loss.
22146     */
22147    private static List<ColumnRef> buildWindowPartitionRefs(TPartitionClause pc,
22148                                                             TResultColumn rc,
22149                                                             TSelectSqlStatement enclosingSelect,
22150                                                             NameBindingProvider provider) {
22151        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
22152        TExpressionList list = pc.getExpressionList();
22153        for (int i = 0; i < list.size(); i++) {
22154            TExpression item = list.getExpression(i);
22155            EExpressionType t = item.getExpressionType();
22156            if (t == EExpressionType.simple_constant_t) {
22157                throw new SemanticIRBuildException(
22158                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_LITERAL,
22159                        "result column " + rc + " has PARTITION BY literal '"
22160                                + item + "'; not supported yet", rc));
22161            }
22162            if (t == EExpressionType.subquery_t || item.getSubQuery() != null) {
22163                throw new SemanticIRBuildException(
22164                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_SUBQUERY,
22165                        "result column " + rc + " has PARTITION BY containing "
22166                                + "a subquery; not supported yet", rc));
22167            }
22168            if (t == EExpressionType.function_t) {
22169                throw new SemanticIRBuildException(
22170                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_AGGREGATE,
22171                        "result column " + rc + " has PARTITION BY containing "
22172                                + "a function call '" + item + "'; not supported yet", rc));
22173            }
22174            if (t != EExpressionType.simple_object_name_t) {
22175                throw new SemanticIRBuildException(
22176                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_UNKNOWN_REFERENCE,
22177                        "result column " + rc + " has PARTITION BY using an "
22178                                + "unsupported expression shape (" + t + "): " + item, rc));
22179            }
22180            // Defensive: reject if the parser/resolver has retyped this
22181            // item as a projection alias. Current Oracle parsers leave
22182            // PARTITION BY <alias> as dbType=column even for projection
22183            // aliases; this guard fires for vendors that may behave
22184            // differently. The slice-19 discriminator below catches the
22185            // Oracle case where dbType stays "column" but the binding
22186            // came from the schema-less inferred-from-usage fallback.
22187            TObjectName on = item.getObjectOperand();
22188            if (on != null && on.getDbObjectType() == EDbObjectType.column_alias) {
22189                throw new SemanticIRBuildException(
22190                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_PROJECTION_ALIAS,
22191                        "result column " + rc + " has PARTITION BY referencing "
22192                                + "a projection alias '" + item + "'; not supported yet", rc));
22193            }
22194            // Slice 19: alias-bound discriminator. Reject when the
22195            // resolver's binding lacks definite FROM-scope evidence and
22196            // the name matches a calculated SELECT-list alias of the
22197            // enclosing SELECT. Without schema metadata the resolver
22198            // cannot tell alias from real column; rejection-over-silent-
22199            // guess matches the slice-9/-10/-13 invariant.
22200            if (on != null && provider.isCalculatedProjectionAliasFallback(on, enclosingSelect)) {
22201                throw new SemanticIRBuildException(
22202                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_CALCULATED_ALIAS,
22203                        "result column " + rc + " has PARTITION BY referencing a "
22204                                + "SELECT-list alias on a calculated expression ('" + item
22205                                + "'); not supported yet — requires schema metadata to "
22206                                + "discriminate alias from base column", rc));
22207            }
22208            // Resolve the column ref through the provider. EXACT_MATCH is
22209            // required (slice-1 fail-fast invariant); collectColumnRefs
22210            // does the heavy lifting and rejects anything else.
22211            List<ColumnRef> built = collectColumnRefs(item, provider);
22212            if (built.isEmpty()) {
22213                throw new SemanticIRBuildException(
22214                        Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_ITEM_UNUSABLE,
22215                        "result column " + rc + " has PARTITION BY item '"
22216                                + item + "' with no resolvable column refs", rc));
22217            }
22218            refs.addAll(built);
22219        }
22220        return new ArrayList<>(refs);
22221    }
22222
22223    /**
22224     * Slice 13: build the OVER ORDER BY ref list. Every sort key must
22225     * be a physical column reference (mirrors slice-9 outer ORDER BY
22226     * rejection set). Ordinals, projection aliases, expressions,
22227     * subqueries, window functions, and SIBLINGS / RESET WHEN are
22228     * rejected with tuned messages.
22229     */
22230    private static List<ColumnRef> buildWindowOrderRefs(TOrderBy ob,
22231                                                         TResultColumn rc,
22232                                                         TSelectSqlStatement enclosingSelect,
22233                                                         NameBindingProvider provider) {
22234        // Slice-13 codex impl-review MUST 2: defense in depth, mirror outer
22235        // ORDER BY's slice-9 SIBLINGS / RESET WHEN guards.
22236        if (ob.isSiblings()) {
22237            throw new SemanticIRBuildException(
22238                    Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_SIBLINGS_NOT_SUPPORTED,
22239                    "result column " + rc + " has OVER ORDER BY SIBLINGS; not supported yet "
22240                            + "(Oracle hierarchical-query syntax in window OVER clause)", rc));
22241        }
22242        if (ob.getResetWhenCondition() != null) {
22243            throw new SemanticIRBuildException(
22244                    Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_RESET_WHEN_NOT_SUPPORTED,
22245                    "result column " + rc + " has OVER ORDER BY ... RESET WHEN; not supported yet "
22246                            + "(Teradata window-style restart)", rc));
22247        }
22248        LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
22249        TOrderByItemList items = ob.getItems();
22250        for (int i = 0; i < items.size(); i++) {
22251            TOrderByItem item = items.getOrderByItem(i);
22252            TExpression key = item.getSortKey();
22253            if (key == null) {
22254                throw new SemanticIRBuildException(
22255                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_NULL_SORT_KEY,
22256                        "result column " + rc + " has OVER ORDER BY item with "
22257                                + "null sort key", rc));
22258            }
22259            EExpressionType t = key.getExpressionType();
22260            if (t == EExpressionType.simple_constant_t) {
22261                // Catches both ordinal sort keys and string-literal sort keys.
22262                throw new SemanticIRBuildException(
22263                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_LITERAL,
22264                        "result column " + rc + " has OVER ORDER BY literal/ordinal '"
22265                                + key + "'; not supported yet", rc));
22266            }
22267            if (t == EExpressionType.subquery_t || key.getSubQuery() != null) {
22268                throw new SemanticIRBuildException(
22269                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_SUBQUERY,
22270                        "result column " + rc + " has OVER ORDER BY containing a "
22271                                + "subquery; not supported yet", rc));
22272            }
22273            if (t == EExpressionType.function_t) {
22274                TFunctionCall innerFn = key.getFunctionCall();
22275                if (innerFn != null && innerFn.getWindowDef() != null) {
22276                    throw new SemanticIRBuildException(
22277                            Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_WINDOW_FUNCTION,
22278                            "result column " + rc + " has OVER ORDER BY containing a "
22279                                    + "window function; not supported yet", rc));
22280                }
22281                throw new SemanticIRBuildException(
22282                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_AGGREGATE,
22283                        "result column " + rc + " has OVER ORDER BY containing a "
22284                                + "function call '" + key + "'; not supported yet", rc));
22285            }
22286            if (t != EExpressionType.simple_object_name_t) {
22287                throw new SemanticIRBuildException(
22288                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_UNKNOWN_REFERENCE,
22289                        "result column " + rc + " has OVER ORDER BY using an "
22290                                + "unsupported expression shape (" + t + "): " + key, rc));
22291            }
22292            // NOTE: Oracle's parser DOES retype OVER ORDER BY refs to
22293            // column_alias when they match a SELECT alias (mirrors
22294            // slice-9 outer ORDER BY behaviour). The defensive
22295            // column_alias guard from PARTITION BY is intentionally
22296            // omitted here — `collectColumnRefs` already skips
22297            // column_alias-typed nodes, and the empty-refs guard below
22298            // catches the resulting unresolvable item with a clear
22299            // message. Outer ORDER BY aliases use the same path.
22300            //
22301            // Slice 19: defensive symmetry with PARTITION BY. A future
22302            // vendor whose parser does NOT retype OVER ORDER BY refs to
22303            // column_alias would land here as `simple_object_name_t`
22304            // with an inferred-from-usage resolution; the discriminator
22305            // catches that case before collectColumnRefs descends. As of
22306            // slice 19, every supported vendor retypes (probe in
22307            // §14.21), so this branch is unreachable in current tests
22308            // — kept for forward-compat.
22309            TObjectName on = key.getObjectOperand();
22310            if (on != null && provider.isCalculatedProjectionAliasFallback(on, enclosingSelect)) {
22311                throw new SemanticIRBuildException(
22312                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_CALCULATED_ALIAS,
22313                        "result column " + rc + " has OVER ORDER BY referencing a "
22314                                + "SELECT-list alias on a calculated expression ('" + key
22315                                + "'); not supported yet — requires schema metadata to "
22316                                + "discriminate alias from base column", rc));
22317            }
22318            List<ColumnRef> built = collectColumnRefs(key, provider);
22319            if (built.isEmpty()) {
22320                throw new SemanticIRBuildException(
22321                        Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_ITEM_UNUSABLE,
22322                        "result column " + rc + " has OVER ORDER BY item '"
22323                                + key + "' with no resolvable column refs", rc));
22324            }
22325            refs.addAll(built);
22326        }
22327        return new ArrayList<>(refs);
22328    }
22329
22330    /**
22331     * Slice 13: reject any window function ({@code FUNC(...) OVER (...)})
22332     * appearing in a {@link TParseTreeNode} subtree. Used by the
22333     * WHERE / GROUP BY / JOIN ON guards before the visitor would
22334     * otherwise descend into the OVER clause and leak PARTITION BY /
22335     * OVER ORDER BY refs into the wrong column-ref bucket. Mirrors
22336     * {@link #rejectHavingWindowFunction} (slice 10) and
22337     * {@link #rejectOrderByWindowFunction} (slice 9).
22338     */
22339    /**
22340     * Slice 85 — admit RETURNING (PG / Oracle) and OUTPUT (SQL Server)
22341     * projections on INSERT / UPDATE / DELETE statements. Returns the
22342     * list of {@link OutputColumn}s for the {@code returningColumns}
22343     * slot on the DML's {@link StatementGraph}, and appends one
22344     * {@link LineageEdge} per source column ref to {@code lineage}:
22345     * <pre>
22346     *   from = LineageRef.statementOutput(dmlIdx, returningColumns[i].name)
22347     *   to   = LineageRef.tableColumn(targetQName, sourceColumnName)
22348     * </pre>
22349     * (consumer ← producer direction; mirrors slice-78 INSERT's
22350     * {@code target ← source} convention but with the DML's own output
22351     * as the consumer and the target table's column as the producer.)
22352     *
22353     * <p>At most one of {@code ret} and {@code out} is non-null. When
22354     * both are null (no RETURNING / OUTPUT clause), returns an empty
22355     * list and emits no edges.
22356     *
22357     * <p>Reject ordering (codex round-3 Q2 BLOCKING fix — two-pass):
22358     * <ol>
22359     *   <li>Pass 1, statement-level: empty projection list →
22360     *       {@link DiagnosticCode#RETURNING_EMPTY_PROJECTION}.</li>
22361     *   <li>Pass 1.5, OUTPUT-only DML-kind / pseudo-table mismatch scan:
22362     *       INSERT with any {@code DELETED.col} →
22363     *       {@link DiagnosticCode#OUTPUT_DELETED_ON_INSERT_NOT_SUPPORTED};
22364     *       DELETE with any {@code INSERTED.col} →
22365     *       {@link DiagnosticCode#OUTPUT_INSERTED_ON_DELETE_NOT_SUPPORTED}.
22366     *       Fires on the first matching column regardless of position.</li>
22367     *   <li>Pass 2, per-column (in SQL declaration order):
22368     *     <ul>
22369     *       <li>{@code *} → {@link DiagnosticCode#RETURNING_STAR_NOT_SUPPORTED}</li>
22370     *       <li>any subquery →
22371     *           {@link DiagnosticCode#RETURNING_HAS_SUBQUERY_NOT_SUPPORTED}</li>
22372     *       <li>any window function over a base ref → reuses
22373     *           {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} via
22374     *           {@link #rejectWindowFunctionInScope}</li>
22375     *       <li>any aggregate function over a base ref →
22376     *           {@link DiagnosticCode#RETURNING_HAS_AGGREGATE_NOT_SUPPORTED}
22377     *           (aggregates are not legal in DML RETURNING / OUTPUT per
22378     *           spec — fires defensively when parser admits them)</li>
22379     *     </ul>
22380     *   </li>
22381     * </ol>
22382     *
22383     * <p>OUTPUT_INTO_NOT_SUPPORTED is rejected at the caller (before
22384     * any FROM walk / SET / WHERE processing) so multi-violation shapes
22385     * route through the cheaper structural code first.
22386     *
22387     * @param ret RETURNING clause; null when this DML uses OUTPUT or
22388     *            no projection at all
22389     * @param out OUTPUT clause; null when this DML uses RETURNING or
22390     *            no projection at all
22391     * @param dmlKind "INSERT" / "UPDATE" / "DELETE" — only relevant for
22392     *                the pseudo-table mismatch scan (UPDATE admits both
22393     *                INSERTED and DELETED; INSERT admits only INSERTED;
22394     *                DELETE admits only DELETED)
22395     * @param targetQName the target table's qualified name; used as the
22396     *                    {@code to} endpoint of every emitted LineageEdge
22397     * @param fromSideAliasToStmtIndex slice-123 alias&rarr;statement-index map
22398     *                    keyed lowercase for SUBQUERY-kind FROM-side relations
22399     *                    that own a producing {@link StatementGraph}
22400     *                    (slice-84 USING-(SELECT) sub / slice-106 USING-CTE on
22401     *                    DELETE; slice-82/83/105 FROM-subquery / CTE-as-relation
22402     *                    on UPDATE). When a RETURNING/OUTPUT source ref resolves
22403     *                    to such a relation, the emitted edge's {@code to}
22404     *                    endpoint is the producer's
22405     *                    {@code STATEMENT_OUTPUT(idx, col)} rather than the
22406     *                    fictitious {@code TABLE_COLUMN(<alias>, col)}. Pass an
22407     *                    empty map to keep the slice-85 TABLE_COLUMN behaviour
22408     *                    (INSERT, MERGE)
22409     * @param provider name-binding provider; same instance used for
22410     *                 SET RHS / WHERE / JOIN ON ref collection so
22411     *                 FROM-side relation refs (slice-82 joined UPDATE,
22412     *                 slice-84 joined DELETE) resolve correctly
22413     * @param dmlIdx the DML statement's position in
22414     *               {@link SemanticProgram#getStatements()}; used as the
22415     *               {@code statementIndex} on the {@code from} endpoint
22416     * @param lineage in/out: collected edges are appended here
22417     * @param anchor parse-tree anchor for diagnostics
22418     */
22419    private static List<OutputColumn> buildReturningColumns(
22420            TReturningClause ret,
22421            TOutputClause out,
22422            String dmlKind,
22423            String targetQName,
22424            String targetAlias,
22425            TTable targetTable,
22426            List<RelationSource> fromSideRelations,
22427            Map<String, Integer> fromSideAliasToStmtIndex,
22428            NameBindingProvider provider,
22429            int dmlIdx,
22430            List<LineageEdge> lineage,
22431            TParseTreeNode anchor) {
22432        if (ret == null && out == null) {
22433            return Collections.emptyList();
22434        }
22435        // Oracle host-variable form: `RETURNING col INTO :v` — AST shape
22436        // is columnValueList + variableList populated, resultExprList null.
22437        // Slice 88 admits it: extract column exprs from columnValueList,
22438        // discard variableList (bind sinks have no semantic IR relevance).
22439        // The still-unsupported degenerate case (resultExprList=null AND
22440        // columnValueList=null) keeps RETURNING_INTO_NOT_SUPPORTED so the
22441        // code stays declared-not-unreachable per the slice-71/72/82 precedent.
22442        boolean isOracleInto = (ret != null && ret.getResultExprList() == null
22443                && ret.getColumnValueList() != null);
22444        if (ret != null && ret.getResultExprList() == null && !isOracleInto) {
22445            throw new SemanticIRBuildException(Diagnostic.error(
22446                    DiagnosticCode.RETURNING_INTO_NOT_SUPPORTED,
22447                    "Oracle `RETURNING col INTO :host_var` with no column list "
22448                            + "is not supported; admits the standard INTO form only",
22449                    anchor));
22450        }
22451        // Extract the source column list.
22452        TResultColumnList items = null;
22453        TExpressionList intoExprs = null;
22454        if (isOracleInto) {
22455            intoExprs = ret.getColumnValueList();
22456        } else if (ret != null) {
22457            items = ret.getResultExprList();
22458        } else {
22459            items = out.getSelectItemList();
22460        }
22461        int colCount = isOracleInto
22462                ? (intoExprs == null ? 0 : intoExprs.size())
22463                : (items == null ? 0 : items.size());
22464        // Pass 1: empty projection list (defensive — the parser usually
22465        // refuses to produce an empty list, but a malformed AST should
22466        // surface a clean diagnostic).
22467        if (colCount == 0) {
22468            throw new SemanticIRBuildException(Diagnostic.error(
22469                    DiagnosticCode.RETURNING_EMPTY_PROJECTION,
22470                    dmlKind + (ret != null ? " RETURNING" : " OUTPUT")
22471                            + " clause has no projection columns",
22472                    anchor));
22473        }
22474        // Pass 1.5: OUTPUT-only DML-kind / pseudo-table mismatch scan
22475        // (codex round-1 Q4 BLOCKING — deep-walk all TObjectName leaves
22476        // so compound exprs like `OUTPUT INSERTED.a + DELETED.b` also
22477        // reject deterministically). The parser sets pseudoTableType
22478        // on the fieldAttr for SIMPLE column references but leaves
22479        // it null on the leaf TObjectNames inside compound expressions;
22480        // we detect those by checking the objectToken spelling against
22481        // "INSERTED" / "DELETED".
22482        // The Oracle INTO path skips this scan (no INSERTED/DELETED pseudo-tables).
22483        if (out != null && !isOracleInto) {
22484            final String targetAliasFinal = targetAlias;
22485            final String targetQNameFinal = targetQName;
22486            final List<RelationSource> relsFinal = fromSideRelations;
22487            for (int i = 0; i < items.size(); i++) {
22488                TResultColumn rc = items.getResultColumn(i);
22489                final String dmlKindFinal = dmlKind;
22490                final TResultColumn rcFinal = rc;
22491                scanOutputPseudoTableLeaves(rc.getExpr(),
22492                        new TParseTreeVisitor() {
22493                            @Override
22494                            public void preVisit(TObjectName n) {
22495                                EPseudoTableType pt = detectPseudoTable(
22496                                        n, rcFinal, targetAliasFinal,
22497                                        targetQNameFinal, relsFinal);
22498                                if (pt == EPseudoTableType.deleted
22499                                        && "INSERT".equals(dmlKindFinal)) {
22500                                    throw new SemanticIRBuildException(Diagnostic.error(
22501                                            DiagnosticCode.OUTPUT_DELETED_ON_INSERT_NOT_SUPPORTED,
22502                                            "INSERT OUTPUT references DELETED."
22503                                                    + bareColumnNameOf(n)
22504                                                    + " but there is no deleted-row "
22505                                                    + "image on INSERT; use INSERTED.* instead",
22506                                            rcFinal));
22507                                }
22508                                if (pt == EPseudoTableType.inserted
22509                                        && "DELETE".equals(dmlKindFinal)) {
22510                                    throw new SemanticIRBuildException(Diagnostic.error(
22511                                            DiagnosticCode.OUTPUT_INSERTED_ON_DELETE_NOT_SUPPORTED,
22512                                            "DELETE OUTPUT references INSERTED."
22513                                                    + bareColumnNameOf(n)
22514                                                    + " but there is no inserted-row "
22515                                                    + "image on DELETE; use DELETED.* instead",
22516                                            rcFinal));
22517                                }
22518                            }
22519                        });
22520            }
22521        }
22522        // Pass 2: per-column. Build OutputColumns, emit edges.
22523        List<OutputColumn> outputs = new ArrayList<>(colCount);
22524        for (int i = 0; i < colCount; i++) {
22525            // For the Oracle INTO path rc is null — the INTO column list
22526            // carries bare expressions, not TResultColumn wrappers.
22527            TResultColumn rc = isOracleInto ? null
22528                    : items.getResultColumn(i);
22529            TExpression expr = isOracleInto
22530                    ? intoExprs.getExpression(i)
22531                    : (rc == null ? null : rc.getExpr());
22532            if (expr == null) {
22533                throw new SemanticIRBuildException(Diagnostic.error(
22534                        DiagnosticCode.RESULT_COLUMN_NULL_EXPRESSION,
22535                        dmlKind + (ret != null ? " RETURNING" : " OUTPUT")
22536                                + " column #" + (i + 1) + " has no expression",
22537                        rc != null ? rc : anchor));
22538            }
22539            // Slice 98 — MSSQL MERGE OUTPUT `$action` pseudo-column.
22540            // Returns the merge action string per output row ('INSERT' /
22541            // 'UPDATE' / 'DELETE') — it has no underlying base column.
22542            // Detected case-insensitively because parser tokens come out
22543            // as `$action` regardless of how the user wrote it; bracketed
22544            // `[$action]` is a delimited identifier and is NOT treated
22545            // as the pseudo-column (codex Q1 confirmed YES — slice-98
22546            // detection is literal text equality on the un-bracketed
22547            // spelling). The check is gated on dmlKind="MERGE" so
22548            // INSERT/UPDATE/DELETE OUTPUT (slice 85) are unaffected.
22549            if ("MERGE".equals(dmlKind)
22550                    && isMergeActionPseudoColumn(expr)) {
22551                String actionName = (rc != null && rc.getColumnAlias() != null
22552                        && !rc.getColumnAlias().toString().isEmpty())
22553                        ? rc.getColumnAlias().toString()
22554                        : expr.toString();
22555                outputs.add(new OutputColumn(actionName,
22556                        /*derived=*/ true,
22557                        /*aggregate=*/ false,
22558                        Collections.<ColumnRef>emptyList()));
22559                // No LineageEdge — $action has no producer column.
22560                continue;
22561            }
22562            // STAR check — bare `RETURNING *` parses as
22563            // simple_object_name_t with toString="*"; qualified star
22564            // forms like `RETURNING t.*` / `OUTPUT inserted.*` /
22565            // `OUTPUT deleted.*` parse as simple_object_name_t with
22566            // partToken (and getColumnNameOnly()) equal to "*"
22567            // (codex round-4 BLOCKING fix).
22568            // Slice 90: standard RETURNING star attempts catalog-backed expansion.
22569            // Slice 99: MSSQL MERGE OUTPUT INSERTED.* / DELETED.*
22570            //   attempts catalog-backed expansion against the target table.
22571            // Oracle INTO star and non-MERGE OUTPUT star (and bare /
22572            // target-alias / source-alias MERGE OUTPUT star) remain rejected.
22573            if (isStarReference(expr)) {
22574                if (isOracleInto) {
22575                    // Oracle INTO star: keep existing reject.
22576                    throw new SemanticIRBuildException(Diagnostic.error(
22577                            DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED,
22578                            dmlKind + " RETURNING INTO * star expansion "
22579                                    + "is not yet supported; use explicit column names",
22580                            rc != null ? rc : expr));
22581                }
22582                if (out != null) {
22583                    // Slice 99 / Slice 100 — MSSQL pseudo-table
22584                    // OUTPUT INSERTED.* / DELETED.* routes to catalog-
22585                    // backed expansion against the target table. The
22586                    // pseudo-table discriminator is the parser-set
22587                    // EPseudoTableType.inserted / .deleted flag on the
22588                    // star qualifier (slice-85 primary discriminator).
22589                    // Slice 99 lifted the reject for dmlKind="MERGE";
22590                    // slice 100 generalises to all DML kinds (INSERT /
22591                    // UPDATE / DELETE) — the parser sets pseudoTableType
22592                    // identically on non-MERGE OUTPUT stars, and Pass
22593                    // 1.5 has already rejected cross-direction
22594                    // mismatches (INSERT OUTPUT DELETED.* /
22595                    // DELETE OUTPUT INSERTED.*) before this branch.
22596                    // OUTPUT *, t.*, s.* (no pseudo-table marker) still
22597                    // reject — they're either ambiguous (bare *) or
22598                    // refer to non-pseudo relations.
22599                    EPseudoTableType pseudo = EPseudoTableType.none;
22600                    TObjectName starObj = expr.getObjectOperand();
22601                    if (starObj != null
22602                            && starObj.getPseudoTableType() != null) {
22603                        pseudo = starObj.getPseudoTableType();
22604                    }
22605                    if (pseudo == EPseudoTableType.inserted
22606                            || pseudo == EPseudoTableType.deleted) {
22607                        expandOutputPseudoTableStarColumns(
22608                                expr, rc, pseudo, dmlKind,
22609                                targetTable, targetQName,
22610                                provider, dmlIdx, lineage, anchor, outputs);
22611                        continue;
22612                    }
22613                    // Non-pseudo OUTPUT star (bare *, target-alias *,
22614                    // source-alias *): keep existing reject.
22615                    throw new SemanticIRBuildException(Diagnostic.error(
22616                            DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED,
22617                            dmlKind + " OUTPUT * star expansion is not "
22618                                    + "yet supported; use explicit column names",
22619                            rc != null ? rc : expr));
22620                }
22621                // Standard RETURNING star: attempt catalog-backed expansion.
22622                // On success, the helper adds to `outputs` and `lineage` in place
22623                // and we `continue` past the normal single-column build below.
22624                expandReturningStarColumns(
22625                        expr, rc, dmlKind, targetTable, targetAlias, targetQName,
22626                        fromSideRelations, provider, dmlIdx, lineage, anchor, outputs);
22627                continue;
22628            }
22629            // Subquery / aggregate / window — guarded by !isOracleInto
22630            // because Oracle's INTO column list forbids nested queries and
22631            // aggregates at the grammar level; skip the checks to avoid
22632            // false rejects on unusual AST shapes.
22633            if (!isOracleInto) {
22634                if (containsAnySubqueryExpression(expr)) {
22635                    throw new SemanticIRBuildException(Diagnostic.error(
22636                            DiagnosticCode.RETURNING_HAS_SUBQUERY_NOT_SUPPORTED,
22637                            dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT")
22638                                    + " column #" + (i + 1) + " contains a subquery; "
22639                                    + "slice 85 admits scalar expressions over base columns only",
22640                            rc));
22641                }
22642                rejectWindowFunctionInScope(expr,
22643                        dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT"));
22644                if (isAggregateFunction(expr)) {
22645                    throw new SemanticIRBuildException(Diagnostic.error(
22646                            DiagnosticCode.RETURNING_HAS_AGGREGATE_NOT_SUPPORTED,
22647                            dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT")
22648                                    + " column #" + (i + 1) + " contains an aggregate "
22649                                    + "function; aggregates are not legal in DML "
22650                                    + "RETURNING / OUTPUT projection per SQL spec",
22651                            rc));
22652                }
22653            }
22654            // Name extraction.
22655            // INTO path: no alias possible, use expr.toString() directly.
22656            // Normal path: use the projection-side helper which strips
22657            // INSERTED./DELETED. qualifiers on OUTPUT pseudo-table refs.
22658            String outName = isOracleInto
22659                    ? expr.toString()
22660                    : returningOutputName(rc, expr, dmlKind, ret != null);
22661            if (outName == null || outName.isEmpty()) {
22662                throw new SemanticIRBuildException(Diagnostic.error(
22663                        DiagnosticCode.RESULT_COLUMN_NO_NAME,
22664                        dmlKind + " RETURNING INTO column #" + (i + 1)
22665                                + " has no resolvable name",
22666                        anchor));
22667            }
22668            // Source collection via manual walker (slice-89 fix registers
22669            // RETURNING refs in Resolver2 allColumnReferences for DELETE/UPDATE;
22670            // INSERT RETURNING lacks an InsertScope so Resolver2 path is partial).
22671            // rc=null is safe for the INTO path: synthRefForReturningLeaf
22672            // only dereferences rc inside the `if (isOutput)` guard,
22673            // which is false for all RETURNING (non-OUTPUT) paths.
22674            List<ColumnRef> sources = collectReturningSourceRefs(
22675                    expr, rc, out != null && !isOracleInto,
22676                    targetAlias, targetQName, fromSideRelations);
22677            boolean derived = expr.getExpressionType()
22678                    != EExpressionType.simple_object_name_t;
22679            outputs.add(new OutputColumn(outName, derived,
22680                    /*aggregate=*/ false, sources));
22681            // Emit one LineageEdge per source column ref.
22682            // Edge direction (consumer ← producer; slice-85 convention
22683            // documented on getReturningColumns()):
22684            //   from = STATEMENT_OUTPUT(dmlIdx, returningName)
22685            //   to   = TABLE_COLUMN(<producer-qualified-name>, <colName>)
22686            // The producer qualified-name is:
22687            //   - target table qname when the source ref's relationAlias
22688            //     is INSERTED / DELETED (MSSQL OUTPUT pseudo-tables both
22689            //     ultimately reference the physical target row)
22690            //   - target table qname when the source ref's relationAlias
22691            //     matches the target alias
22692            //   - FROM-side relation's binding qualifiedName when the
22693            //     ref's relationAlias matches a FROM-side relation
22694            //   - the relationAlias verbatim otherwise (defensive)
22695            for (ColumnRef src : sources) {
22696                String srcCol = src.getColumnName();
22697                if (srcCol == null || srcCol.isEmpty()) continue;
22698                String alias = src.getRelationAlias();
22699                // Slice 123 — when the source ref binds to a SUBQUERY-kind
22700                // FROM-side relation that owns its own producing
22701                // StatementGraph (slice-84 USING-(SELECT) sub / slice-106
22702                // USING-CTE on DELETE; slice-82/83/105 FROM-subquery or
22703                // CTE-as-relation on UPDATE), emit the canonical cross-stmt
22704                // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge into the producer
22705                // rather than the fictitious TABLE_COLUMN(<alias>, col).
22706                // Base TABLE-kind FROM-side relations (slice-82/84 joined
22707                // RETURNING, pinned by Slice85Test §B/§C) and target /
22708                // INSERTED / DELETED refs fall through to the slice-85
22709                // TABLE_COLUMN path via resolveReturningEdgeTarget.
22710                Integer subIdx = returningSubquerySourceIndex(alias,
22711                        targetAlias, targetQName, fromSideRelations,
22712                        fromSideAliasToStmtIndex);
22713                if (subIdx != null) {
22714                    lineage.add(new LineageEdge(
22715                            LineageRef.statementOutput(dmlIdx, outName),
22716                            LineageRef.statementOutput(subIdx, srcCol)));
22717                } else {
22718                    String to = resolveReturningEdgeTarget(alias,
22719                            targetAlias, targetQName, fromSideRelations);
22720                    lineage.add(new LineageEdge(
22721                            LineageRef.statementOutput(dmlIdx, outName),
22722                            LineageRef.tableColumn(to, srcCol)));
22723                }
22724            }
22725        }
22726        return outputs;
22727    }
22728
22729    /**
22730     * Slice 98 helper — true when an expression is the MSSQL MERGE
22731     * OUTPUT {@code $action} pseudo-column.
22732     *
22733     * <p>Detection rule: the expression is a
22734     * {@code simple_object_name_t} and its {@code toString()} matches
22735     * {@code "$action"} case-insensitively. Bracketed delimited
22736     * identifiers like {@code [$action]} parse with the brackets in
22737     * the token string, so they are NOT matched here — a column
22738     * actually named {@code $action} (delimited) is treated as a
22739     * normal target column, not the pseudo-column (codex Q1 confirmed).
22740     *
22741     * <p>Caller gates the check on {@code dmlKind == "MERGE"} so the
22742     * slice-85 INSERT/UPDATE/DELETE OUTPUT path is unaffected.
22743     */
22744    private static boolean isMergeActionPseudoColumn(TExpression expr) {
22745        if (expr == null) return false;
22746        if (expr.getExpressionType() != EExpressionType.simple_object_name_t) {
22747            return false;
22748        }
22749        String text = expr.toString();
22750        return text != null && "$action".equalsIgnoreCase(text);
22751    }
22752
22753    /**
22754     * Slice 85 helper — true when an expression is a star reference
22755     * (bare {@code *} or qualified {@code t.*} / {@code INSERTED.*} /
22756     * {@code DELETED.*}). Covers both forms: bare star has
22757     * {@code expr.toString()=="*"}; qualified star is a
22758     * simple_object_name_t whose leaf TObjectName has
22759     * {@code partToken=="*"} (codex round-4 BLOCKING fix —
22760     * qualified stars were previously slipping past the bare-only
22761     * check and producing bogus ColumnRefs).
22762     */
22763    private static boolean isStarReference(TExpression expr) {
22764        if (expr == null) return false;
22765        if (expr.getExpressionType() != EExpressionType.simple_object_name_t) {
22766            return false;
22767        }
22768        if ("*".equals(expr.toString())) return true;
22769        TObjectName n = expr.getObjectOperand();
22770        if (n == null) return false;
22771        if (n.getPartToken() != null && "*".equals(n.getPartToken().toString())) {
22772            return true;
22773        }
22774        String colOnly = n.getColumnNameOnly();
22775        return colOnly != null && "*".equals(colOnly);
22776    }
22777
22778    /**
22779     * Slice 90 helper — expand a standard {@code RETURNING *} /
22780     * {@code RETURNING t.*} star into per-column
22781     * {@link OutputColumn} entries using catalog metadata from
22782     * {@link #lookupRelationColumnNames(TTable, NameBindingProvider)}.
22783     *
22784     * <p>Called only for standard PG/Oracle RETURNING (not MSSQL OUTPUT,
22785     * not Oracle INTO). Adds expanded columns to {@code outputs} and
22786     * matching {@link LineageEdge}s to {@code lineage} in place.
22787     *
22788     * <p>Qualifier matching mirrors Slice 59 SELECT star semantics:
22789     * alias-only — the qualifier must equal the target's effective alias,
22790     * not the schema-qualified name. For INSERT without alias the effective
22791     * alias is the bare table name, so {@code RETURNING employees.*} matches
22792     * {@code INSERT INTO schema.employees}.
22793     *
22794     * <p>Throws {@link SemanticIRBuildException} on any failure:
22795     * <ul>
22796     *   <li>{@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED} — no
22797     *       catalog metadata available for the target relation;</li>
22798     *   <li>{@link DiagnosticCode#RETURNING_STAR_NOT_SUPPORTED} — the
22799     *       qualifier matches a FROM-side relation alias but FROM-side
22800     *       star expansion is deferred to a future slice;</li>
22801     *   <li>{@link DiagnosticCode#RETURNING_STAR_QUALIFIER_UNKNOWN} — the
22802     *       qualifier does not match the target alias or any FROM-side
22803     *       relation alias.</li>
22804     * </ul>
22805     */
22806    private static void expandReturningStarColumns(
22807            TExpression expr,
22808            TResultColumn rc,
22809            String dmlKind,
22810            TTable targetTable,
22811            String targetAlias,
22812            String targetQName,
22813            List<RelationSource> fromSideRelations,
22814            NameBindingProvider provider,
22815            int dmlIdx,
22816            List<LineageEdge> lineage,
22817            TParseTreeNode anchor,
22818            List<OutputColumn> outputs) {
22819        // Extract qualifier: empty for bare `*`, table/alias name for `t.*`.
22820        String qualifier = "";
22821        TObjectName n = (expr != null) ? expr.getObjectOperand() : null;
22822        if (n != null) {
22823            String q = n.getTableString();
22824            if (q != null && !q.isEmpty()) qualifier = q;
22825        }
22826        // Rendered star form for use in diagnostic messages.
22827        String starForm = qualifier.isEmpty() ? "*" : qualifier + ".*";
22828        // Determine which relation to expand.
22829        // Rule (Slice 90, mirrors Slice 59 correlation-name semantics):
22830        //   effective alias only — bare name without alias counts as alias.
22831        boolean matchesTarget = qualifier.isEmpty()
22832                || qualifier.equalsIgnoreCase(targetAlias);
22833        if (matchesTarget) {
22834            // Attempt catalog-backed expansion of the target table.
22835            List<String> cols = lookupRelationColumnNames(targetTable, provider);
22836            if (cols == null || cols.isEmpty()) {
22837                throw new SemanticIRBuildException(Diagnostic.error(
22838                        DiagnosticCode.RETURNING_STAR_CATALOG_REQUIRED,
22839                        dmlKind + " RETURNING " + starForm
22840                                + " requires catalog metadata for target '"
22841                                + targetQName + "' to expand; supply a Catalog via "
22842                                + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)",
22843                        rc != null ? rc : anchor));
22844            }
22845            for (String colName : cols) {
22846                ColumnRef ref = new ColumnRef(targetAlias, colName);
22847                outputs.add(new OutputColumn(colName, /*derived=*/ false,
22848                        /*aggregate=*/ false, Collections.singletonList(ref)));
22849                lineage.add(new LineageEdge(
22850                        LineageRef.statementOutput(dmlIdx, colName),
22851                        LineageRef.tableColumn(targetQName, colName)));
22852            }
22853            return;
22854        }
22855        // Qualifier doesn't match target. Check FROM-side relations.
22856        for (RelationSource rs : fromSideRelations) {
22857            if (qualifier.equalsIgnoreCase(rs.getAlias())) {
22858                // Known FROM-side relation, but expansion is deferred.
22859                throw new SemanticIRBuildException(Diagnostic.error(
22860                        DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED,
22861                        dmlKind + " RETURNING " + starForm + " — "
22862                                + "star expansion for FROM-side/USING relations "
22863                                + "is deferred to a future slice; use explicit "
22864                                + "column names for FROM-side RETURNING refs",
22865                        rc != null ? rc : anchor));
22866            }
22867        }
22868        // Qualifier is truly unknown (doesn't match target or any FROM-side relation).
22869        throw new SemanticIRBuildException(Diagnostic.error(
22870                DiagnosticCode.RETURNING_STAR_QUALIFIER_UNKNOWN,
22871                dmlKind + " RETURNING " + starForm + " — qualifier '"
22872                        + qualifier + "' does not match the DML target alias '"
22873                        + targetAlias + "' or any FROM-side relation; "
22874                        + "use the target's effective alias for RETURNING star expansion",
22875                rc != null ? rc : anchor));
22876    }
22877
22878    /**
22879     * Slice 99 / Slice 100 helper — expand MSSQL pseudo-table
22880     * {@code OUTPUT INSERTED.*} / {@code OUTPUT DELETED.*} into
22881     * per-column {@link OutputColumn} entries using catalog metadata
22882     * from {@link #lookupRelationColumnNames(TTable, NameBindingProvider)}.
22883     *
22884     * <p>Slice 99 originally introduced this helper for MSSQL MERGE
22885     * OUTPUT. Slice 100 generalised it to all DML kinds (INSERT / UPDATE
22886     * / DELETE / MERGE): the parser sets {@code pseudoTableType=inserted/deleted}
22887     * on the star qualifier identically for non-MERGE DML, so the
22888     * expansion is mechanically the same — only the catalog-missing
22889     * message text varies by {@code dmlKind} per the slice-80
22890     * message-text-discrimination contract.
22891     *
22892     * <p>Mirrors the slice-90 standard-RETURNING star design with two
22893     * differences:
22894     * <ul>
22895     *   <li>The pseudo-table qualifier ({@code INSERTED} / {@code DELETED})
22896     *       is normalized to UPPERCASE on
22897     *       {@link ColumnRef#getRelationAlias()} regardless of the SQL
22898     *       case, matching slice-85
22899     *       {@code synthRefForReturningLeaf}'s
22900     *       {@code new ColumnRef("INSERTED", ...)} convention.</li>
22901     *   <li>The catalog lookup target is the DML target table (the
22902     *       pseudo-table rows physically reference target rows), not a
22903     *       FROM-side relation.</li>
22904     * </ul>
22905     *
22906     * <p>Catalog miss reuses {@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED}
22907     * (slice-90 code) with a discriminating message text formatted as
22908     * {@code "<dmlKind> OUTPUT <pseudoLabel>.* requires catalog metadata
22909     * for target '<qname>' ..."}.
22910     *
22911     * <p>Adds expanded columns to {@code outputs} and matching
22912     * {@link LineageEdge}s to {@code lineage} in place.
22913     *
22914     * @param dmlKind one of {@code "INSERT"}, {@code "UPDATE"},
22915     *                {@code "DELETE"}, {@code "MERGE"} — feeds the
22916     *                catalog-missing message text only; expansion is
22917     *                identical regardless of kind because the parser
22918     *                sets {@code pseudoTableType} on the star qualifier
22919     *                uniformly.
22920     */
22921    private static void expandOutputPseudoTableStarColumns(
22922            TExpression expr,
22923            TResultColumn rc,
22924            EPseudoTableType pseudoTable,
22925            String dmlKind,
22926            TTable targetTable,
22927            String targetQName,
22928            NameBindingProvider provider,
22929            int dmlIdx,
22930            List<LineageEdge> lineage,
22931            TParseTreeNode anchor,
22932            List<OutputColumn> outputs) {
22933        // Slice-85 convention: relationAlias is normalized to UPPERCASE.
22934        String pseudoLabel = (pseudoTable == EPseudoTableType.inserted)
22935                ? "INSERTED"
22936                : "DELETED";
22937        List<String> cols = lookupRelationColumnNames(targetTable, provider);
22938        if (cols == null || cols.isEmpty()) {
22939            throw new SemanticIRBuildException(Diagnostic.error(
22940                    DiagnosticCode.RETURNING_STAR_CATALOG_REQUIRED,
22941                    dmlKind + " OUTPUT " + pseudoLabel + ".* requires catalog "
22942                            + "metadata for target '" + targetQName
22943                            + "' to expand; supply a Catalog via "
22944                            + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)",
22945                    rc != null ? rc : anchor));
22946        }
22947        for (String colName : cols) {
22948            ColumnRef ref = new ColumnRef(pseudoLabel, colName);
22949            outputs.add(new OutputColumn(colName, /*derived=*/ false,
22950                    /*aggregate=*/ false, Collections.singletonList(ref)));
22951            // INSERTED / DELETED both physically reference the target
22952            // row image; lineage edge target is the target qname.
22953            lineage.add(new LineageEdge(
22954                    LineageRef.statementOutput(dmlIdx, colName),
22955                    LineageRef.tableColumn(targetQName, colName)));
22956        }
22957    }
22958
22959    /**
22960     * Slice 85 helper — pull the pseudo-table type for an OUTPUT
22961     * column (SQL Server). Returns {@link EPseudoTableType#none} for
22962     * RETURNING (PG / Oracle) columns and for OUTPUT columns whose
22963     * top-level expression doesn't carry an INSERTED / DELETED
22964     * qualifier. For compound expressions, the parser sets the
22965     * pseudo-table type on the fieldAttr only for SIMPLE column
22966     * references; compound shapes must be deep-walked separately
22967     * via {@link #scanOutputPseudoTableLeaves}.
22968     */
22969    private static EPseudoTableType pseudoTableOf(TResultColumn rc) {
22970        if (rc == null) return EPseudoTableType.none;
22971        TObjectName fa = rc.getFieldAttr();
22972        if (fa == null) return EPseudoTableType.none;
22973        EPseudoTableType pt = fa.getPseudoTableType();
22974        return (pt == null) ? EPseudoTableType.none : pt;
22975    }
22976
22977    /**
22978     * Slice 85 helper — walk an OUTPUT projection expression and
22979     * invoke {@code visitor} on every TObjectName leaf for the
22980     * pseudo-table mismatch scan. Skips function-name TObjectNames
22981     * via the dbObjectType filter so {@code OUTPUT FUNC(inserted.x)}
22982     * still surfaces the leaf inserted.x ref.
22983     */
22984    private static void scanOutputPseudoTableLeaves(
22985            TExpression expr, final TParseTreeVisitor visitor) {
22986        if (expr == null) return;
22987        // Collect function-name identities first (codex round-2 Q1
22988        // BLOCKING — dialect-portable structural filter).
22989        final java.util.Set<TObjectName> fnLeaves =
22990                collectFunctionNameLeaves(expr);
22991        // Fast path: leaf simple_object_name_t.
22992        if (expr.getExpressionType() == EExpressionType.simple_object_name_t) {
22993            TObjectName n = expr.getObjectOperand();
22994            if (n != null && !isFunctionNameObjectName(n, fnLeaves)) {
22995                visitor.preVisit(n);
22996            }
22997            return;
22998        }
22999        // Compound expression — walk for TObjectName leaves.
23000        expr.acceptChildren(new TParseTreeVisitor() {
23001            int nestedSelectDepth = 0;
23002            @Override
23003            public void preVisit(TSelectSqlStatement s) { nestedSelectDepth++; }
23004            @Override
23005            public void postVisit(TSelectSqlStatement s) { nestedSelectDepth--; }
23006            @Override
23007            public void preVisit(TObjectName node) {
23008                if (nestedSelectDepth > 0) return;
23009                if (isFunctionNameObjectName(node, fnLeaves)) return;
23010                visitor.preVisit(node);
23011            }
23012        });
23013    }
23014
23015    /**
23016     * Slice 85 helper — detect the pseudo-table type (INSERTED /
23017     * DELETED) for a TObjectName leaf in an OUTPUT projection,
23018     * honouring both the parser-set fieldAttr.pseudoTableType (for
23019     * simple leaf refs where the parser ran its qualifier swap) AND
23020     * the objectToken spelling (for compound expressions where the
23021     * parser left pseudoTableType=none on leaf TObjectNames).
23022     *
23023     * <p>Codex round-2 Q3 BLOCKING fix — MSSQL permits "INSERTED" /
23024     * "DELETED" as real identifiers via ColId, so the text-match
23025     * fallback fires ONLY when no FROM-side relation alias /
23026     * qualifiedName / bare-component matches the qualifier. With a
23027     * real table named "INSERTED" in scope, the text-match is
23028     * suppressed and the leaf surfaces as a normal column ref.
23029     */
23030    private static EPseudoTableType detectPseudoTable(TObjectName n,
23031                                                       TResultColumn rc,
23032                                                       String targetAlias,
23033                                                       String targetQName,
23034                                                       List<RelationSource> fromSideRelations) {
23035        if (n == null) return EPseudoTableType.none;
23036        // Direct: parser-set pseudoTableType.
23037        if (n.getPseudoTableType() != null
23038                && n.getPseudoTableType() != EPseudoTableType.none) {
23039            return n.getPseudoTableType();
23040        }
23041        // Indirect: if this leaf is the result column's fieldAttr,
23042        // read from the fieldAttr's pseudoTableType (slice-78
23043        // TOutputClause.doParse sets it there for simple refs).
23044        if (rc != null && rc.getFieldAttr() == n) {
23045            EPseudoTableType pt = pseudoTableOf(rc);
23046            if (pt != null && pt != EPseudoTableType.none) return pt;
23047        }
23048        // Compound expression leaf — the parser leaves
23049        // pseudoTableType=none on the leaf TObjectNames but the
23050        // objectToken spelling is preserved. Text-match
23051        // INSERTED / DELETED case-insensitively, but only when no
23052        // real FROM-side relation shadows the pseudo name (codex
23053        // round-2 Q3 BLOCKING).
23054        if (n.getObjectToken() != null && n.getPartToken() != null) {
23055            String obj = n.getObjectToken().toString();
23056            if (obj == null) return EPseudoTableType.none;
23057            boolean shadowedByRealRelation =
23058                    qualifierMatchesAnyRelation(obj, targetAlias,
23059                            targetQName, fromSideRelations);
23060            if (shadowedByRealRelation) return EPseudoTableType.none;
23061            if ("INSERTED".equalsIgnoreCase(obj)) return EPseudoTableType.inserted;
23062            if ("DELETED".equalsIgnoreCase(obj)) return EPseudoTableType.deleted;
23063        }
23064        return EPseudoTableType.none;
23065    }
23066
23067    /**
23068     * Slice 85 helper — true when {@code qualifier} matches some
23069     * real relation in scope (target alias / qualified name / bare
23070     * component, or any FROM-side relation alias / qualified name /
23071     * bare component). Used by {@link #detectPseudoTable} to
23072     * suppress the INSERTED / DELETED text-match when a real table
23073     * by that name is in scope.
23074     */
23075    private static boolean qualifierMatchesAnyRelation(String qualifier,
23076            String targetAlias, String targetQName,
23077            List<RelationSource> fromSideRelations) {
23078        if (qualifier == null || qualifier.isEmpty()) return false;
23079        if (targetAlias != null && targetAlias.equalsIgnoreCase(qualifier)) {
23080            return true;
23081        }
23082        if (targetQName != null
23083                && (targetQName.equalsIgnoreCase(qualifier)
23084                        || bareLastDotComponent(targetQName)
23085                                .equalsIgnoreCase(qualifier))) {
23086            return true;
23087        }
23088        if (fromSideRelations != null) {
23089            for (RelationSource rs : fromSideRelations) {
23090                String a = rs.getAlias();
23091                if (a != null && a.equalsIgnoreCase(qualifier)) return true;
23092                String qn = rs.getBinding() == null ? null
23093                        : rs.getBinding().getQualifiedName();
23094                if (qn != null
23095                        && (qn.equalsIgnoreCase(qualifier)
23096                                || bareLastDotComponent(qn)
23097                                        .equalsIgnoreCase(qualifier))) {
23098                    return true;
23099                }
23100            }
23101        }
23102        return false;
23103    }
23104
23105    /**
23106     * Slice 85 helper — collect identities of every TObjectName that
23107     * is a function-name in the given expression tree (codex round-2
23108     * Q1 BLOCKING — {@code EDbObjectType.function} is unreliable
23109     * across dialects: Oracle builtins surface as {@code constant},
23110     * MSSQL XML methods as {@code method}). The walker uses
23111     * {@link IdentityHashMap}-style reference equality so the
23112     * column-ref walker can structurally skip function-name leaves
23113     * regardless of dbType.
23114     */
23115    private static java.util.Set<TObjectName> collectFunctionNameLeaves(
23116            TExpression expr) {
23117        final java.util.Set<TObjectName> set = java.util.Collections.newSetFromMap(
23118                new IdentityHashMap<TObjectName, Boolean>());
23119        if (expr == null) return set;
23120        expr.acceptChildren(new TParseTreeVisitor() {
23121            @Override
23122            public void preVisit(TFunctionCall fn) {
23123                TObjectName name = fn.getFunctionName();
23124                if (name != null) set.add(name);
23125            }
23126        });
23127        return set;
23128    }
23129
23130    /**
23131     * Slice 85 helper — true when this TObjectName is in the
23132     * function-name set collected for the current expression
23133     * (codex round-2 Q1 BLOCKING fix — structural identity rather
23134     * than dbType). {@code functionNameLeaves} may be null (empty
23135     * set semantics) when the caller doesn't have the set in hand.
23136     */
23137    private static boolean isFunctionNameObjectName(TObjectName n,
23138            java.util.Set<TObjectName> functionNameLeaves) {
23139        if (n == null) return false;
23140        if (functionNameLeaves != null && functionNameLeaves.contains(n)) {
23141            return true;
23142        }
23143        // Best-effort fallback: dbType check catches the
23144        // single-leaf simple_object_name_t function case (rare —
23145        // those normally arrive as TFunctionCall). Kept defensively.
23146        EDbObjectType t = n.getDbObjectType();
23147        return t == EDbObjectType.function;
23148    }
23149
23150    /**
23151     * Slice 85 helper — best-effort spelling of the bare column name
23152     * for an OUTPUT pseudo-table ref like INSERTED.foo. Used only in
23153     * diagnostic message text.
23154     */
23155    private static String safePseudoColumn(TResultColumn rc) {
23156        if (rc == null) return "<unknown>";
23157        TObjectName fa = rc.getFieldAttr();
23158        if (fa == null) return "<unknown>";
23159        if (fa.getPartToken() != null) return fa.getPartToken().toString();
23160        if (fa.getPropertyToken() != null) return fa.getPropertyToken().toString();
23161        return rc.toString();
23162    }
23163
23164    /**
23165     * Slice 85 helper — derive the OutputColumn name for a RETURNING /
23166     * OUTPUT projection column. Uses the explicit alias when present,
23167     * else the bare column name (for OUTPUT INSERTED.col / DELETED.col
23168     * the bare partToken spelling, stripping the pseudo-table
23169     * qualifier). Falls back to {@code expr.toString()} for derived
23170     * expressions without alias (e.g. {@code RETURNING a + 1} → name
23171     * = "a + 1").
23172     */
23173    private static String returningOutputName(TResultColumn rc,
23174                                              TExpression expr,
23175                                              String dmlKind,
23176                                              boolean isReturning) {
23177        String alias = rc.getColumnAlias();
23178        if (alias != null && !alias.isEmpty()) {
23179            return alias;
23180        }
23181        // OUTPUT pseudo-table ref without alias — strip the qualifier
23182        // so the OutputColumn.name carries the bare column spelling.
23183        if (!isReturning) {
23184            EPseudoTableType pt = pseudoTableOf(rc);
23185            if (pt != EPseudoTableType.none) {
23186                TObjectName fa = rc.getFieldAttr();
23187                if (fa != null && fa.getPartToken() != null) {
23188                    return fa.getPartToken().toString();
23189                }
23190            }
23191        }
23192        // RETURNING bare column or derived expression — fall back to
23193        // the expression's toString(). For simple_object_name_t this is
23194        // the verbatim bare or qualified column spelling; for
23195        // arithmetic / function expressions it is the rendered text.
23196        String s = expr.toString();
23197        if (s == null || s.isEmpty()) {
23198            throw new SemanticIRBuildException(Diagnostic.error(
23199                    DiagnosticCode.RESULT_COLUMN_NO_NAME,
23200                    dmlKind + (isReturning ? " RETURNING" : " OUTPUT")
23201                            + " column has no resolvable name",
23202                    rc));
23203        }
23204        return s;
23205    }
23206
23207    /**
23208     * Slice 85 helper — collect ColumnRefs from a RETURNING / OUTPUT
23209     * projection expression. Slice 89 fixed TReturningClause.acceptChildren()
23210     * to descend into children so Resolver2 now registers RETURNING refs in
23211     * allColumnReferences for DELETE/UPDATE (INSERT RETURNING lacks InsertScope
23212     * so Resolver2 coverage there is partial). This walker remains the
23213     * authoritative source for Semantic IR because it maps qualifier tokens
23214     * directly onto the DML's known relation set:
23215     *
23216     * <ul>
23217     *   <li>OUTPUT pseudo-table ref ({@code INSERTED.col} /
23218     *       {@code DELETED.col}, detected via
23219     *       {@code fieldAttr.pseudoTableType}) → ColumnRef with
23220     *       uppercase {@code "INSERTED"} / {@code "DELETED"} as the
23221     *       relationAlias, preserving temporal phase (codex round-2
23222     *       Q2 BLOCKING).</li>
23223     *   <li>Qualified ref matching a FROM-side relation's alias
23224     *       (slice-82 joined UPDATE / slice-84 joined DELETE)
23225     *       → ColumnRef with that alias.</li>
23226     *   <li>Qualified ref matching the target table's effective alias
23227     *       or its qualified name → ColumnRef with the target alias.</li>
23228     *   <li>Unqualified ref → ColumnRef with the target alias
23229     *       (default scope).</li>
23230     *   <li>Qualified ref matching nothing known → ColumnRef with the
23231     *       parser's qualifier verbatim. Lineage consumers can spot
23232     *       the unresolved relation via the relationAlias they see.</li>
23233     * </ul>
23234     *
23235     * <p>Match policy: case-insensitive on alias / qualified name,
23236     * to match the slice-83 codex Q3 advisory and stay forgiving of
23237     * dialect-specific identifier casing.
23238     */
23239    private static List<ColumnRef> collectReturningSourceRefs(
23240            TExpression expr,
23241            TResultColumn rc,
23242            boolean isOutput,
23243            String targetAlias,
23244            String targetQName,
23245            List<RelationSource> fromSideRelations) {
23246        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
23247        // Collect function-name identities first (codex round-2 Q1
23248        // BLOCKING — dialect-portable structural filter; see
23249        // {@link #collectFunctionNameLeaves} javadoc for the
23250        // dbObjectType unreliability rationale).
23251        final java.util.Set<TObjectName> fnLeaves = collectFunctionNameLeaves(expr);
23252        // Fast path: simple_object_name_t leaf — handle directly so
23253        // OUTPUT INSERTED.col / DELETED.col resolves through fieldAttr.
23254        if (expr.getExpressionType() == EExpressionType.simple_object_name_t) {
23255            ColumnRef r = synthRefForReturningLeaf(
23256                    rc, expr.getObjectOperand(), isOutput,
23257                    targetAlias, targetQName, fromSideRelations, fnLeaves);
23258            if (r != null) refs.add(r);
23259            return new ArrayList<>(refs);
23260        }
23261        // Compound expression — walk for TObjectName leaves. We don't
23262        // descend into nested subqueries (already rejected upstream)
23263        // and don't try to resolve fieldAttr for compound expressions
23264        // (pseudo-table qualifier inside arithmetic / function args is
23265        // a rare shape; slice 85 surfaces the bare column ref against
23266        // the target alias as a best-effort).
23267        expr.acceptChildren(new TParseTreeVisitor() {
23268            int nestedSelectDepth = 0;
23269            @Override
23270            public void preVisit(TSelectSqlStatement nested) {
23271                nestedSelectDepth++;
23272            }
23273            @Override
23274            public void postVisit(TSelectSqlStatement nested) {
23275                nestedSelectDepth--;
23276            }
23277            @Override
23278            public void preVisit(TObjectName node) {
23279                if (nestedSelectDepth > 0) return;
23280                // Codex round-1 Q1 / round-2 Q1 BLOCKING — skip
23281                // function-name leaves via structural identity.
23282                if (isFunctionNameObjectName(node, fnLeaves)) return;
23283                ColumnRef r = synthRefForReturningLeaf(
23284                        rc, node, isOutput,
23285                        targetAlias, targetQName, fromSideRelations, fnLeaves);
23286                if (r != null) refs.add(r);
23287            }
23288        });
23289        return new ArrayList<>(refs);
23290    }
23291
23292    /**
23293     * Slice 85 helper — build one ColumnRef for a TObjectName leaf in
23294     * a RETURNING / OUTPUT projection. Returns null when the node is
23295     * not a column-name reference (e.g. a function name token).
23296     */
23297    private static ColumnRef synthRefForReturningLeaf(
23298            TResultColumn rc,
23299            TObjectName node,
23300            boolean isOutput,
23301            String targetAlias,
23302            String targetQName,
23303            List<RelationSource> fromSideRelations,
23304            java.util.Set<TObjectName> functionNameLeaves) {
23305        if (node == null) return null;
23306        // Codex round-1 Q1 / round-2 Q1 BLOCKING — skip function-name
23307        // TObjectNames via structural identity (the "UPPER" leaf in
23308        // `RETURNING UPPER(name)`).
23309        if (isFunctionNameObjectName(node, functionNameLeaves)) return null;
23310        String colName = bareColumnNameOf(node);
23311        if (colName == null || colName.isEmpty()) return null;
23312        // OUTPUT pseudo-table ref (INSERTED / DELETED). Detect via
23313        // fieldAttr on the result column (parser surfaces it there
23314        // for simple refs) OR objectToken spelling (compound exprs;
23315        // codex round-1 Q4 BLOCKING — same deep detection as
23316        // detectPseudoTable). Both INSERTED and DELETED ultimately
23317        // reference the target table's row; only the temporal phase
23318        // surfaces on ColumnRef.relationAlias.
23319        if (isOutput) {
23320            EPseudoTableType pt = detectPseudoTable(node, rc,
23321                    targetAlias, targetQName, fromSideRelations);
23322            if (pt == EPseudoTableType.inserted) {
23323                return new ColumnRef("INSERTED", partColumnNameOf(node, colName));
23324            }
23325            if (pt == EPseudoTableType.deleted) {
23326                return new ColumnRef("DELETED", partColumnNameOf(node, colName));
23327            }
23328        }
23329        // Qualifier resolution: bare or qualified.
23330        String qualifier = qualifierOf(node);
23331        if (qualifier == null || qualifier.isEmpty()) {
23332            return new ColumnRef(targetAlias, colName);
23333        }
23334        // Codex round-2 Q2 + round-3 BLOCKING fix — single-pass
23335        // count-all-candidates matcher. A relation "matches" the
23336        // qualifier if any of (alias, qualifiedName, bare-last-dot
23337        // component of qualifiedName) compares case-insensitive-
23338        // equal. Multiple matches (e.g. unaliased `FROM s1.t s2.t`
23339        // both have effectiveAlias "t" via TTable.getName() fallback,
23340        // both have bareComponent "t") are ambiguous and fall through
23341        // to the verbatim qualifier path so consumers see the
23342        // ambiguity rather than a silent order-dependent pick.
23343        int totalMatches = 0;
23344        // Single-match accumulators (one each — only valid when
23345        // totalMatches == 1):
23346        RelationSource matchedRelation = null;
23347        boolean matchedIsTarget = false;
23348        if (fromSideRelations != null) {
23349            for (RelationSource rs : fromSideRelations) {
23350                if (relationCandidateMatch(rs, qualifier)) {
23351                    totalMatches++;
23352                    matchedRelation = rs;
23353                }
23354            }
23355        }
23356        if (targetCandidateMatch(targetAlias, targetQName, qualifier)) {
23357            totalMatches++;
23358            matchedIsTarget = true;
23359        }
23360        if (totalMatches == 1) {
23361            if (matchedIsTarget) {
23362                return new ColumnRef(
23363                        targetAlias != null ? targetAlias : targetQName, colName);
23364            }
23365            String a = matchedRelation.getAlias();
23366            String qn = matchedRelation.getBinding() == null ? null
23367                    : matchedRelation.getBinding().getQualifiedName();
23368            return new ColumnRef((a != null && !a.isEmpty()) ? a : qn, colName);
23369        }
23370        // Zero matches or ambiguous — pass through verbatim. Lineage
23371        // consumers can spot the unresolved / ambiguous relation.
23372        return new ColumnRef(qualifier, colName);
23373    }
23374
23375    /**
23376     * Slice 85 helper — true when a FROM-side relation is a match
23377     * candidate for the qualifier under any of: alias, full
23378     * qualifiedName, or bare last-dot component of qualifiedName
23379     * (case-insensitive). Caller uses {@link #synthRefForReturningLeaf}'s
23380     * single-pass count-then-pick policy to disambiguate.
23381     */
23382    private static boolean relationCandidateMatch(RelationSource rs,
23383                                                   String qualifier) {
23384        if (rs == null || qualifier == null || qualifier.isEmpty()) return false;
23385        String a = rs.getAlias();
23386        if (a != null && a.equalsIgnoreCase(qualifier)) return true;
23387        String qn = rs.getBinding() == null ? null
23388                : rs.getBinding().getQualifiedName();
23389        if (qn == null) return false;
23390        return qn.equalsIgnoreCase(qualifier)
23391                || bareLastDotComponent(qn).equalsIgnoreCase(qualifier);
23392    }
23393
23394    /**
23395     * Slice 85 helper — true when the target table is a candidate
23396     * match for the qualifier (alias / qualifiedName / bare
23397     * component, case-insensitive).
23398     */
23399    private static boolean targetCandidateMatch(String targetAlias,
23400                                                 String targetQName,
23401                                                 String qualifier) {
23402        if (qualifier == null || qualifier.isEmpty()) return false;
23403        if (targetAlias != null && targetAlias.equalsIgnoreCase(qualifier)) {
23404            return true;
23405        }
23406        if (targetQName != null
23407                && (targetQName.equalsIgnoreCase(qualifier)
23408                        || bareLastDotComponent(targetQName)
23409                                .equalsIgnoreCase(qualifier))) {
23410            return true;
23411        }
23412        return false;
23413    }
23414
23415    /**
23416     * Slice 85 helper — strip everything up to and including the
23417     * last dot in a qualified name. Returns the input unchanged when
23418     * no dot is present.
23419     */
23420    private static String bareLastDotComponent(String qname) {
23421        if (qname == null) return "";
23422        int dot = qname.lastIndexOf('.');
23423        return (dot < 0) ? qname : qname.substring(dot + 1);
23424    }
23425
23426    /**
23427     * Slice 85 helper — map a ColumnRef.relationAlias to the qualified
23428     * table name used on the LineageEdge {@code to} endpoint. INSERTED
23429     * / DELETED both collapse to the target's qualified name; FROM-side
23430     * aliases route to their bound qualifiedName; target alias / qname
23431     * stays on the target; unknown aliases pass through verbatim.
23432     */
23433    private static String resolveReturningEdgeTarget(
23434            String alias, String targetAlias, String targetQName,
23435            List<RelationSource> fromSideRelations) {
23436        if ("INSERTED".equals(alias) || "DELETED".equals(alias)) {
23437            return targetQName;
23438        }
23439        if (targetAlias != null && targetAlias.equalsIgnoreCase(alias)) {
23440            return targetQName;
23441        }
23442        if (targetQName != null && targetQName.equalsIgnoreCase(alias)) {
23443            return targetQName;
23444        }
23445        if (fromSideRelations != null) {
23446            for (RelationSource rs : fromSideRelations) {
23447                if (rs.getAlias() != null
23448                        && rs.getAlias().equalsIgnoreCase(alias)) {
23449                    String qn = rs.getBinding() == null ? null
23450                            : rs.getBinding().getQualifiedName();
23451                    return (qn != null && !qn.isEmpty()) ? qn : alias;
23452                }
23453            }
23454        }
23455        return alias != null ? alias : targetQName;
23456    }
23457
23458    /**
23459     * Slice 123 — resolve a RETURNING/OUTPUT source ref's relation alias to
23460     * the statement index of a SUBQUERY-kind FROM-side relation that owns
23461     * its own producing {@link StatementGraph}, so the slice-85 walker can
23462     * emit a cross-stmt {@code STATEMENT_OUTPUT(dmlIdx, ret) →
23463     * STATEMENT_OUTPUT(subOrCteIdx, col)} edge instead of the fictitious
23464     * {@code TABLE_COLUMN(<alias>, col)} edge.
23465     *
23466     * <p>Returns the producing statement index iff ALL of:
23467     * <ul>
23468     *   <li>{@code fromSideAliasToStmtIndex} is non-empty and {@code alias}
23469     *       is non-empty;</li>
23470     *   <li>{@code alias} is NOT an OUTPUT pseudo-table ({@code INSERTED} /
23471     *       {@code DELETED}) and does NOT match the target alias or qname —
23472     *       those stay on the slice-85 {@code TABLE_COLUMN(targetQName)}
23473     *       path (mirrors {@link #resolveReturningEdgeTarget}'s priority);</li>
23474     *   <li>a {@link RelationSource} in {@code fromSideRelations} matches the
23475     *       alias (case-insensitive) AND is {@link RelationKind#SUBQUERY}-kind
23476     *       (mirrors the slice-83 {@link #emitUpdateSubquerySourceEdges}
23477     *       filter — base TABLE-kind FROM relations are never promoted);</li>
23478     *   <li>the alias has an entry in {@code fromSideAliasToStmtIndex}
23479     *       (keyed lowercase).</li>
23480     * </ul>
23481     * Otherwise returns {@code null} and the caller keeps the TABLE_COLUMN
23482     * edge. No producer-column publication check (consistent with
23483     * {@link #emitUpdateSubquerySourceEdges}).
23484     *
23485     * <p>Invariant — the {@link RelationKind#SUBQUERY} filter and the map are
23486     * consistent by construction, so the filter never spuriously suppresses a
23487     * real producer. Every alias in {@code fromSideAliasToStmtIndex} comes from
23488     * one of three sources, all of which add only SUBQUERY-kind relations for
23489     * the matching alias:
23490     * <ul>
23491     *   <li>{@link #buildDeleteCombinedAliasToSubIdx} /
23492     *       {@link #buildUpdateCombinedAliasToSubIdx} (DELETE / UPDATE), whose
23493     *       two entry sources are (a) slice-84/83 FROM-subquery aliases —
23494     *       always SUBQUERY-kind relations — and (b) slice-106/105
23495     *       CTE-as-FROM-relation aliases, which {@code buildDeleteRelation} /
23496     *       {@code buildUpdateRelation} record as SUBQUERY-kind (NOT
23497     *       {@link RelationKind#CTE});</li>
23498     *   <li>slice-124 MERGE {@code aliasToSubIdx}, whose entries are the
23499     *       USING alias for the USING-(SELECT) subquery branch and for the
23500     *       USING-as-CTE branch — both add a SUBQUERY-kind relation. The
23501     *       USING-as-CTE branch also registers an inert bare-CTE-name key with
23502     *       no matching relation in {@code fromSideRelations}, so it is never
23503     *       reached by the per-alias relation match below.</li>
23504     * </ul>
23505     * The filter is kept as defense-in-depth (a stale TABLE-kind entry would
23506     * never be promoted) and for parity with the slice-83
23507     * {@code emitUpdateSubquerySourceEdges}, which carries the identical
23508     * SUBQUERY-kind guard.
23509     */
23510    private static Integer returningSubquerySourceIndex(
23511            String alias, String targetAlias, String targetQName,
23512            List<RelationSource> fromSideRelations,
23513            Map<String, Integer> fromSideAliasToStmtIndex) {
23514        if (alias == null || alias.isEmpty()) return null;
23515        if (fromSideAliasToStmtIndex == null
23516                || fromSideAliasToStmtIndex.isEmpty()) {
23517            return null;
23518        }
23519        // Pseudo-tables and target self-refs are never cross-stmt sources.
23520        if ("INSERTED".equals(alias) || "DELETED".equals(alias)) return null;
23521        if (targetAlias != null && targetAlias.equalsIgnoreCase(alias)) {
23522            return null;
23523        }
23524        if (targetQName != null && targetQName.equalsIgnoreCase(alias)) {
23525            return null;
23526        }
23527        if (fromSideRelations == null) return null;
23528        for (RelationSource rs : fromSideRelations) {
23529            if (rs.getAlias() == null
23530                    || !rs.getAlias().equalsIgnoreCase(alias)) {
23531                continue;
23532            }
23533            if (rs.getBinding() == null
23534                    || rs.getBinding().getKind() != RelationKind.SUBQUERY) {
23535                return null;
23536            }
23537            return fromSideAliasToStmtIndex.get(
23538                    alias.toLowerCase(Locale.ROOT));
23539        }
23540        return null;
23541    }
23542
23543    /**
23544     * Slice 85 helper — extract the bare column name from a TObjectName
23545     * leaf, honouring partToken / propertyToken / objectToken in the
23546     * order set by the parser. Returns null when no column-name token
23547     * is present.
23548     */
23549    private static String bareColumnNameOf(TObjectName node) {
23550        // partToken is the column name in `qualifier.col` form;
23551        // for bare `col`, the parser may put it on objectToken.
23552        if (node.getPartToken() != null) {
23553            return node.getPartToken().toString();
23554        }
23555        if (node.getColumnNameOnly() != null
23556                && !node.getColumnNameOnly().isEmpty()) {
23557            return node.getColumnNameOnly();
23558        }
23559        if (node.getObjectToken() != null) {
23560            return node.getObjectToken().toString();
23561        }
23562        return null;
23563    }
23564
23565    /**
23566     * Slice 85 helper — qualifier (table alias or schema-table) of a
23567     * column-name reference. Returns null for bare references.
23568     */
23569    private static String qualifierOf(TObjectName node) {
23570        // For `t.col`, parser populates objectToken=t, partToken=col.
23571        if (node.getPartToken() != null && node.getObjectToken() != null) {
23572            return node.getObjectToken().toString();
23573        }
23574        return null;
23575    }
23576
23577    /**
23578     * Slice 85 helper — pseudo-table partToken column name for OUTPUT
23579     * INSERTED.col / DELETED.col. Falls back to the bare column name
23580     * when partToken is null (e.g. raw bare reference).
23581     */
23582    private static String partColumnNameOf(TObjectName node, String fallbackColName) {
23583        if (node.getPartToken() != null) {
23584            return node.getPartToken().toString();
23585        }
23586        return fallbackColName;
23587    }
23588
23589    private static void rejectWindowFunctionInScope(gudusoft.gsqlparser.nodes.TParseTreeNode root,
23590                                                    String clauseLabel) {
23591        if (root == null) return;
23592        final boolean[] found = {false};
23593        root.acceptChildren(new TParseTreeVisitor() {
23594            @Override
23595            public void preVisit(TFunctionCall fn) {
23596                if (found[0]) return;
23597                if (fn.getWindowDef() != null) found[0] = true;
23598            }
23599        });
23600        if (found[0]) {
23601            throw new SemanticIRBuildException(
23602                    Diagnostic.error(DiagnosticCode.CLAUSE_WINDOW_FUNCTION_LEAK,
23603                    clauseLabel + " contains a window function (OVER (...)); "
23604                            + "window functions are not allowed in " + clauseLabel
23605                            + " per standard SQL", root));
23606        }
23607    }
23608
23609    private static String effectiveOutputName(TResultColumn rc) {
23610        String alias = rc.getColumnAlias();
23611        if (alias != null && !alias.isEmpty()) {
23612            return alias;
23613        }
23614        String colName = rc.getColumnNameOnly();
23615        if (colName != null && !colName.isEmpty()) {
23616            return colName;
23617        }
23618        throw new SemanticIRBuildException(
23619                Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NO_NAME,
23620                "result column " + rc + " has neither alias nor column name", rc));
23621    }
23622
23623    private static List<ColumnRef> buildFilterColumnRefs(TSelectSqlStatement select,
23624                                                         NameBindingProvider provider,
23625                                                         boolean allowPredicateSubqueries,
23626                                                         List<StatementGraph> stmtsForExtraction,
23627                                                         List<LineageEdge> lineageForExtraction,
23628                                                         Map<String, Integer> cteMapForExtraction,
23629                                                         PredicateClauseContext whereClauseContext,
23630                                                         List<SemiJoinFact> semiFactsOut,
23631                                                         Set<String> outerRelationAliases) {
23632        TWhereClause where = select.getWhereClause();
23633        if (where == null || where.getCondition() == null) {
23634            return new ArrayList<>();
23635        }
23636        Set<TExpression> extractedWhereRoots =
23637                Collections.<TExpression>emptySet();
23638        if (containsAnySubquery(where)) {
23639            if (!allowPredicateSubqueries) {
23640                // Slice 112 — non-outer SELECTs (FROM-subquery, scalar
23641                // projection subquery body, predicate body) keep the
23642                // slice-80 blanket reject. The outermost SELECT path
23643                // (slice 112) and set-op branch path (slice 113) thread
23644                // {@code allowPredicateSubqueries=true} plus the live
23645                // extraction context so the slice-23+ walker can lift
23646                // uncorrelated predicate-subquery wrappers. Inner
23647                // contexts also have earlier preflight rejecters
23648                // ({@code rejectSubqueriesInFromSubqueryBodyClauses} for
23649                // FROM-subquery bodies, {@code rejectSubqueriesInPredicateBodyClauses}
23650                // for slice-23 predicate bodies); this remains the
23651                // fallback path for any unanticipated nested SELECTs
23652                // that bypass those preflights.
23653                throw new SemanticIRBuildException(
23654                        Diagnostic.error(DiagnosticCode.WHERE_HAS_SUBQUERY_NOT_SUPPORTED,
23655                        "WHERE clause contains a subquery; subqueries in WHERE "
23656                                + "are not supported yet in nested SELECTs",
23657                        select));
23658            }
23659            // Slice 112 / 113 — outer SELECT WHERE and set-op branch
23660            // WHERE lift the slice-80 blanket subquery reject by
23661            // routing uncorrelated predicate-subquery wrappers
23662            // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison /
23663            // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction
23664            // pipeline refactored by slice 110 to take a
23665            // PredicateClauseContext. Slice 112 added the SELECT_WHERE
23666            // constant for outer SELECT WHERE; slice 113 adds the
23667            // SET_OP_BRANCH_WHERE constant for nested set-op branch
23668            // WHERE — both reuse the same SELECT_WHERE_* DiagnosticCode
23669            // family (a branch IS a SELECT, only nested) and differ
23670            // only in the {@code clauseLabel} for diagnostic messages.
23671            // Each extracted wrapper lands as its own
23672            // <predicate_subquery_<i>> StatementGraph BEFORE the host
23673            // outer SELECT or set-op branch in {@code stmts} (selectIdx
23674            // = stmts.size() naturally accounts for them — slice-83
23675            // dynamic-index pattern, slice 110/111 precedent).
23676            //
23677            // Remaining non-subquery refs flow into filterColumnRefs
23678            // via collectColumnRefsSkipping. Window functions in
23679            // non-subquery subtrees still reject via
23680            // rejectWindowFunctionInScopeSkipping. The {@code provider}
23681            // already carries withCteContext / withInScopeRelationColumns
23682            // from the outer build chain (slice-65 withUsingScope
23683            // preserves both facets), so the predicate body's inner
23684            // FROM cte routes through RelationKind.CTE and the body's
23685            // own lineage edge becomes STATEMENT_OUTPUT(predicateIdx,
23686            // col) -> STATEMENT_OUTPUT(cteIdx, col) instead of
23687            // TABLE_COLUMN (slice 110/111 precedent).
23688            extractedWhereRoots =
23689                    extractUncorrelatedPredicateSubqueriesFromClause(
23690                            where.getCondition(), provider,
23691                            stmtsForExtraction, lineageForExtraction,
23692                            cteMapForExtraction,
23693                            whereClauseContext,
23694                            /*correlationScope=*/ null,
23695                            semiFactsOut,
23696                            outerRelationAliases);
23697            rejectAnyRemainingSubqueriesFromClause(
23698                    where.getCondition(), extractedWhereRoots,
23699                    whereClauseContext);
23700        }
23701        // Slice 13: reject window functions in WHERE before
23702        // collectColumnRefs descends into OVER (...) and leaks
23703        // PARTITION BY / OVER ORDER BY refs into filterColumnRefs.
23704        // Slice 112 — skip extracted predicate-subquery subtrees so
23705        // inner window functions do not leak into the outer reject
23706        // (mirrors the slice-110/111 UPDATE/DELETE WHERE behaviour).
23707        rejectWindowFunctionInScopeSkipping(where, "WHERE clause",
23708                extractedWhereRoots);
23709        return collectColumnRefsSkipping(where, provider, extractedWhereRoots);
23710    }
23711
23712    /**
23713     * Slice 65 — shared visitor body that emits either the merged-key
23714     * source list (when {@code node} is an unqualified reference to a
23715     * USING merged key in the current SELECT's
23716     * {@link UsingScope}) or the resolver2-bound {@link ColumnRef}.
23717     *
23718     * <p>Used by every visitor that walks expression subtrees and
23719     * collects column refs ({@link #collectColumnRefs},
23720     * {@link #collectColumnRefsSkippingExtended}, the derived FILTER /
23721     * WITHIN-GROUP-excluding variants). Each visitor remains
23722     * responsible for its own skip-depth and nested-SELECT-depth
23723     * tracking; only the column-emit body is shared.
23724     *
23725     * <p>Behavior:
23726     * <ul>
23727     *   <li>If the node is not a column, name is null/empty/star → no-op.</li>
23728     *   <li>If the node is unqualified AND its name matches a USING
23729     *       key in {@code provider.getUsingScope()} AND that scope
23730     *       reports the reference as ambiguous (two disconnected
23731     *       classes, or a catalog-proven out-of-class same-named
23732     *       relation) → throw {@link SemanticIRBuildException}.</li>
23733     *   <li>Otherwise if the unqualified name matches a USING key
23734     *       unambiguously → emit each {@link ColumnRef} from the
23735     *       merged source list (FROM-ordered, deduped per relation).</li>
23736     *   <li>Otherwise → delegate to
23737     *       {@link NameBindingProvider#bindColumn} and emit the bound
23738     *       {@link ColumnRef}; any non-EXACT_MATCH binding records a
23739     *       reject (caller throws after collecting all rejects).</li>
23740     * </ul>
23741     *
23742     * <p>The qualifier check is the SQL-written prefix
23743     * ({@link TObjectName#getTableString()}); when present the
23744     * merged-key path is skipped so {@code a.k} continues to resolve
23745     * to {@code (a, k)} regardless of {@code k}'s USING-key status.
23746     */
23747    private static void appendMergedOrBoundColumnRef(
23748            TObjectName node,
23749            NameBindingProvider provider,
23750            LinkedHashSet<ColumnRef> refsOut,
23751            List<String> rejectsOut,
23752            boolean[] sawQualifiedRejectOut) {
23753        if (node.getDbObjectType() != EDbObjectType.column) return;
23754        String name = node.getColumnNameOnly();
23755        if (name == null || "*".equals(name)) return;
23756        UsingScope scope = provider.getUsingScope();
23757        String qualifier = node.getTableString();
23758        if ((qualifier == null || qualifier.isEmpty()) && scope.has(name)) {
23759            if (scope.isAmbiguous(name)) {
23760                throw new SemanticIRBuildException(
23761                        Diagnostic.error(DiagnosticCode.UNQUALIFIED_COLUMN_AMBIGUOUS,
23762                        "unqualified reference to '" + name + "' is ambiguous: "
23763                                + scope.ambiguityReason(name)
23764                                + "; qualify with a table alias", null));
23765            }
23766            for (ColumnRef ref : scope.mergedSourcesFor(name)) {
23767                refsOut.add(ref);
23768            }
23769            return;
23770        }
23771        ColumnBinding binding = provider.bindColumn(node);
23772        boolean unqualified = (qualifier == null || qualifier.isEmpty());
23773        // Catalog-less NATURAL JOIN degrade (GSP R6): an unqualified
23774        // reference that fails to bind exactly, when the FROM clause carries
23775        // a catalog-less NATURAL join, is a reference to the merged NATURAL
23776        // key — unambiguous under NATURAL semantics. Resolve it to the
23777        // deterministic left-side relation instead of failing the whole
23778        // analysis. Only fires for NATURAL FROM clauses; plain-join
23779        // unqualified ambiguity still rejects below.
23780        if (unqualified
23781                && (binding == null || binding.getStatus() != ResolutionStatus.EXACT_MATCH)
23782                && scope.hasNaturalDegrade()) {
23783            refsOut.add(new ColumnRef(scope.naturalDegradeFallbackAlias(), name));
23784            return;
23785        }
23786        if (binding == null) {
23787            rejectsOut.add(node + "[no binding]");
23788            if (!unqualified && sawQualifiedRejectOut != null) {
23789                sawQualifiedRejectOut[0] = true;
23790            }
23791            return;
23792        }
23793        if (binding.getStatus() != ResolutionStatus.EXACT_MATCH) {
23794            rejectsOut.add(node + "[" + binding.getStatus() + "]");
23795            if (!unqualified && sawQualifiedRejectOut != null) {
23796                sawQualifiedRejectOut[0] = true;
23797            }
23798            return;
23799        }
23800        // Slice 164 (S3): attach the source-text span of the originating
23801        // TObjectName. Slice 165 (S4): attach the GAP-5 resolution from the
23802        // binding's final table. Both are excluded from ColumnRef identity,
23803        // so the LinkedHashSet dedupe and emission order are unaffected.
23804        refsOut.add(new ColumnRef(binding.getRelationAlias(), binding.getColumnName(),
23805                null, SourceSpan.of(node), resolutionOf(binding)));
23806    }
23807
23808    /**
23809     * Slice 165 (S4) — map a {@link ColumnBinding}'s final table to an
23810     * explicit {@link ColumnResolution} (GAP 5). A non-null final-table
23811     * qualified name yields RESOLVED; a null one yields UNRESOLVED (the
23812     * resolver did not produce a base table — never fabricated). This path
23813     * only sees EXACT_MATCH bindings; non-exact / null bindings are
23814     * rejected upstream, so strict diagnostics are not loosened.
23815     */
23816    static ColumnResolution resolutionOf(ColumnBinding binding) {
23817        String finalTableQn = binding.getFinalTableQualifiedName();
23818        return (finalTableQn != null && !finalTableQn.isEmpty())
23819                ? ColumnResolution.resolved(finalTableQn)
23820                : ColumnResolution.UNRESOLVED;
23821    }
23822
23823    /**
23824     * Visit every column-typed {@link TObjectName} reachable from the given
23825     * subtree, ask the provider to bind it, and return de-duplicated
23826     * {@link ColumnRef}s. Any non-EXACT_MATCH binding aborts the build.
23827     *
23828     * <p>Slice 65: when the provider carries a non-empty
23829     * {@link UsingScope}, unqualified references that match a USING
23830     * merged key are expanded to the merged source list (one ref per
23831     * relation in the equivalence class) before delegating to
23832     * {@link NameBindingProvider#bindColumn}. See
23833     * {@link #appendMergedOrBoundColumnRef}.
23834     */
23835    private static List<ColumnRef> collectColumnRefs(gudusoft.gsqlparser.nodes.TParseTreeNode root,
23836                                                     final NameBindingProvider provider) {
23837        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
23838        final List<String> rejects = new ArrayList<>();
23839        // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at
23840        // the wrong side). Such a miss is a genuine error and must stay fatal;
23841        // only all-unqualified rejects are eligible for the join-graph degrade.
23842        final boolean[] sawQualifiedReject = {false};
23843        root.acceptChildren(new TParseTreeVisitor() {
23844            int nestedSelectDepth = 0;
23845
23846            @Override
23847            public void preVisit(TSelectSqlStatement nested) {
23848                nestedSelectDepth++;
23849            }
23850
23851            @Override
23852            public void postVisit(TSelectSqlStatement nested) {
23853                nestedSelectDepth--;
23854            }
23855
23856            @Override
23857            public void preVisit(TObjectName node) {
23858                if (nestedSelectDepth > 0) return;
23859                appendMergedOrBoundColumnRef(node, provider, refs, rejects,
23860                        sawQualifiedReject);
23861            }
23862        });
23863        if (!rejects.isEmpty()) {
23864            rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]);
23865        }
23866        return new ArrayList<>(refs);
23867    }
23868
23869    /**
23870     * Tolerant variant of {@link #collectColumnRefs} for the MySQL
23871     * self-reference DELETE path (slice 92 Codex P1 fix).
23872     *
23873     * <p>The MySQL parser populates {@code stmt.tables} with 3 entries for
23874     * {@code DELETE T1 FROM T1 WHERE id = 1} (target + {@code joins[0]} +
23875     * {@code referenceJoins[0]}). Resolver2's {@code inferredCandidates}
23876     * then sees 3 candidates for any unqualified column ref and marks the
23877     * binding as NOT_FOUND, which {@link #collectColumnRefs} rejects as
23878     * {@code COLUMN_BINDING_NON_EXACT}.
23879     *
23880     * <p>This variant emits EXACT_MATCH bindings verbatim and falls back
23881     * to the SQL-written qualifier (or {@code null} for unqualified refs)
23882     * for non-exact bindings instead of throwing. Subquery children are
23883     * not descended into (matches the strict collector's behaviour).
23884     */
23885    /**
23886     * @param fallbackRelationAlias used when the binding is non-exact and the
23887     *        SQL-written qualifier is absent; for the MySQL self-reference path
23888     *        this is {@code targetQName} so unqualified refs like
23889     *        {@code WHERE id = 1} emit {@code ColumnRef(targetName, "id")}
23890     *        instead of crashing on the non-null constraint on
23891     *        {@link ColumnRef#ColumnRef(String, String)}.
23892     */
23893    private static List<ColumnRef> collectColumnRefsTolerant(
23894            gudusoft.gsqlparser.nodes.TParseTreeNode root,
23895            final NameBindingProvider provider,
23896            final String fallbackRelationAlias) {
23897        return collectColumnRefsTolerant(root, provider, fallbackRelationAlias,
23898                Collections.<TExpression>emptySet());
23899    }
23900
23901    /**
23902     * Slice 111 — variant of the slice-92 tolerant collector that also
23903     * skips any descendants of {@code skipRoots} (extracted predicate
23904     * subquery wrappers). Mirrors the
23905     * {@link #collectColumnRefsSkipping} skipping behavior so DELETE
23906     * WHERE-side IN-SELECT / EXISTS / scalar-comparison wrappers
23907     * extracted by
23908     * {@link #extractUncorrelatedPredicateSubqueriesFromClause} are
23909     * not double-collected as outer filter refs on the MySQL self-ref
23910     * DELETE path. For the non-self-ref DELETE path the
23911     * {@link #collectColumnRefsSkipping} helper handles the same job;
23912     * this helper exists only for the slice-92 path which needs the
23913     * tolerant binding behavior to survive Resolver2's
23914     * NOT_FOUND / NON_EXACT bindings on unqualified self-ref refs.
23915     */
23916    private static List<ColumnRef> collectColumnRefsTolerant(
23917            gudusoft.gsqlparser.nodes.TParseTreeNode root,
23918            final NameBindingProvider provider,
23919            final String fallbackRelationAlias,
23920            final Set<TExpression> skipRoots) {
23921        final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>();
23922        // Root fast path: if root IS a skipped TExpression subtree, return empty.
23923        if (root instanceof TExpression && skipRoots.contains(root)) {
23924            return new ArrayList<>(refs);
23925        }
23926        root.acceptChildren(new TParseTreeVisitor() {
23927            int nestedSelectDepth = 0;
23928            int skipDepth = 0;
23929
23930            @Override
23931            public void preVisit(TExpression e) {
23932                if (skipRoots.contains(e)) skipDepth++;
23933            }
23934
23935            @Override
23936            public void postVisit(TExpression e) {
23937                if (skipRoots.contains(e) && skipDepth > 0) skipDepth--;
23938            }
23939
23940            @Override
23941            public void preVisit(TSelectSqlStatement nested) {
23942                nestedSelectDepth++;
23943            }
23944
23945            @Override
23946            public void postVisit(TSelectSqlStatement nested) {
23947                nestedSelectDepth--;
23948            }
23949
23950            @Override
23951            public void preVisit(TObjectName node) {
23952                if (skipDepth > 0) return;
23953                if (nestedSelectDepth > 0) return;
23954                if (node.getDbObjectType() != EDbObjectType.column) return;
23955                String name = node.getColumnNameOnly();
23956                if (name == null || "*".equals(name)) return;
23957                ColumnBinding binding = provider.bindColumn(node);
23958                if (binding != null
23959                        && binding.getStatus() == ResolutionStatus.EXACT_MATCH) {
23960                    refs.add(new ColumnRef(
23961                            binding.getRelationAlias(), binding.getColumnName()));
23962                } else {
23963                    // Non-exact or null binding: prefer SQL-written qualifier;
23964                    // fall back to the single delete-target name so the
23965                    // ColumnRef non-null constraint is satisfied.
23966                    String qualifier = node.getTableString();
23967                    String alias = (qualifier != null && !qualifier.isEmpty())
23968                            ? qualifier : fallbackRelationAlias;
23969                    refs.add(new ColumnRef(alias, name));
23970                }
23971            }
23972        });
23973        return new ArrayList<>(refs);
23974    }
23975
23976    /**
23977     * Thrown when the input falls outside current builder scope or a
23978     * binding fails. Slice 67 attached a {@link Diagnostic} to every
23979     * throw site so external callers can pattern-match on
23980     * {@link DiagnosticCode} rather than parsing message text. The
23981     * legacy {@code (String)} constructor was removed in slice 67;
23982     * use one of the {@link Diagnostic#error} factories and the
23983     * {@link #SemanticIRBuildException(Diagnostic)} constructor.
23984     */
23985    public static final class SemanticIRBuildException extends RuntimeException {
23986        private final List<Diagnostic> diagnostics;
23987
23988        public SemanticIRBuildException(Diagnostic diagnostic) {
23989            this(Collections.singletonList(
23990                            java.util.Objects.requireNonNull(diagnostic, "diagnostic")),
23991                    diagnostic.getMessage());
23992        }
23993
23994        /**
23995         * Construct a rejection with its primary diagnostic first, followed by
23996         * any related diagnostics emitted atomically by the same build step.
23997         */
23998        public static SemanticIRBuildException withDiagnostics(
23999                List<Diagnostic> diagnostics) {
24000            return new SemanticIRBuildException(
24001                    diagnostics, primaryMessage(diagnostics));
24002        }
24003
24004        private SemanticIRBuildException(List<Diagnostic> diagnostics,
24005                                         String primaryMessage) {
24006            super(primaryMessage);
24007            List<Diagnostic> copy = new ArrayList<>(diagnostics);
24008            for (Diagnostic diagnostic : copy) {
24009                java.util.Objects.requireNonNull(diagnostic,
24010                        "diagnostics must not contain null");
24011            }
24012            this.diagnostics = Collections.unmodifiableList(copy);
24013        }
24014
24015        /**
24016         * @return the structured diagnostic for this rejection. Always
24017         *         non-null after slice 67.
24018         */
24019        public Diagnostic getDiagnostic() {
24020            for (Diagnostic diagnostic : diagnostics) {
24021                if (diagnostic.getSeverity() == Severity.ERROR) {
24022                    return diagnostic;
24023                }
24024            }
24025            return diagnostics.get(0);
24026        }
24027
24028        /** @return immutable complete diagnostic list; never empty. */
24029        public List<Diagnostic> getDiagnostics() {
24030            return diagnostics;
24031        }
24032
24033        private static String primaryMessage(List<Diagnostic> diagnostics) {
24034            java.util.Objects.requireNonNull(diagnostics, "diagnostics");
24035            if (diagnostics.isEmpty()) {
24036                throw new IllegalArgumentException("diagnostics must not be empty");
24037            }
24038            Diagnostic first = null;
24039            for (Diagnostic diagnostic : diagnostics) {
24040                Diagnostic nonNull = java.util.Objects.requireNonNull(
24041                        diagnostic, "diagnostics must not contain null");
24042                if (first == null) {
24043                    first = nonNull;
24044                }
24045                if (nonNull.getSeverity() == Severity.ERROR) {
24046                    return nonNull.getMessage();
24047                }
24048            }
24049            return first.getMessage();
24050        }
24051    }
24052}