001package gudusoft.gsqlparser.ir.semantic.builder; 002 003import gudusoft.gsqlparser.EBoundaryType; 004import gudusoft.gsqlparser.EDbObjectType; 005import gudusoft.gsqlparser.EDbVendor; 006import gudusoft.gsqlparser.EExpressionType; 007import gudusoft.gsqlparser.EGroupingSetType; 008import gudusoft.gsqlparser.EJoinType; 009import gudusoft.gsqlparser.ELimitRowType; 010import gudusoft.gsqlparser.EPseudoTableType; 011import gudusoft.gsqlparser.ESetOperatorType; 012import gudusoft.gsqlparser.ETableSource; 013import gudusoft.gsqlparser.EUniqueRowFilterType; 014import gudusoft.gsqlparser.TSourceToken; 015import gudusoft.gsqlparser.ir.semantic.ColumnRef; 016import gudusoft.gsqlparser.ir.semantic.Diagnostic; 017import gudusoft.gsqlparser.ir.semantic.DiagnosticCode; 018import gudusoft.gsqlparser.ir.semantic.Severity; 019import gudusoft.gsqlparser.ir.semantic.SourceSpan; 020import gudusoft.gsqlparser.ir.semantic.FrameBound; 021import gudusoft.gsqlparser.ir.semantic.GroupingElement; 022import gudusoft.gsqlparser.nodes.TParseTreeNode; 023import gudusoft.gsqlparser.ir.semantic.LineageEdge; 024import gudusoft.gsqlparser.ir.semantic.LineageRef; 025import gudusoft.gsqlparser.ir.semantic.OutputColumn; 026import gudusoft.gsqlparser.ir.semantic.RelationKind; 027import gudusoft.gsqlparser.ir.semantic.RelationSource; 028import gudusoft.gsqlparser.ir.semantic.RowLimit; 029import gudusoft.gsqlparser.ir.semantic.RowLimitKind; 030import gudusoft.gsqlparser.ir.semantic.SemanticProgram; 031import gudusoft.gsqlparser.ir.semantic.SetOperator; 032import gudusoft.gsqlparser.ir.semantic.StatementGraph; 033import gudusoft.gsqlparser.ir.semantic.TargetRelation; 034import gudusoft.gsqlparser.ir.semantic.WindowFrame; 035import gudusoft.gsqlparser.ir.semantic.WindowSpec; 036import gudusoft.gsqlparser.ir.semantic.binding.ColumnBinding; 037import gudusoft.gsqlparser.ir.semantic.joinanalysis.ColumnResolution; 038import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinAnalysisFacts; 039import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpoint; 040import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpointKind; 041import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEntity; 042import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinGraph; 043import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinSourceSyntax; 044import gudusoft.gsqlparser.ir.semantic.joinanalysis.Predicate; 045import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateKind; 046import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateOperand; 047import gudusoft.gsqlparser.ir.semantic.joinanalysis.QueryBlockScope; 048import gudusoft.gsqlparser.ir.semantic.joinanalysis.ScopeKind; 049import gudusoft.gsqlparser.ir.semantic.joinanalysis.SemanticJoinType; 050import gudusoft.gsqlparser.ir.semantic.binding.FromSubqueryNaming; 051import gudusoft.gsqlparser.ir.semantic.binding.NameBindingProvider; 052import gudusoft.gsqlparser.ir.semantic.binding.RelationBinding; 053import gudusoft.gsqlparser.ir.semantic.binding.UsingScope; 054import gudusoft.gsqlparser.nodes.TCTE; 055import gudusoft.gsqlparser.nodes.TCTEList; 056import gudusoft.gsqlparser.nodes.TExpression; 057import gudusoft.gsqlparser.nodes.TExpressionList; 058import gudusoft.gsqlparser.nodes.TFetchFirstClause; 059import gudusoft.gsqlparser.nodes.TFunctionCall; 060import gudusoft.gsqlparser.nodes.TLimitClause; 061import gudusoft.gsqlparser.nodes.TOffsetClause; 062import gudusoft.gsqlparser.nodes.TTopClause; 063import gudusoft.gsqlparser.nodes.TGroupBy; 064import gudusoft.gsqlparser.nodes.TGroupByItem; 065import gudusoft.gsqlparser.nodes.TGroupByItemList; 066import gudusoft.gsqlparser.nodes.TGroupingExpressionItem; 067import gudusoft.gsqlparser.nodes.TGroupingSet; 068import gudusoft.gsqlparser.nodes.TGroupingSetItem; 069import gudusoft.gsqlparser.nodes.TGroupingSetItemList; 070import gudusoft.gsqlparser.nodes.TRollupCube; 071import gudusoft.gsqlparser.nodes.TJoin; 072import gudusoft.gsqlparser.nodes.TJoinItem; 073import gudusoft.gsqlparser.nodes.TJoinItemList; 074import gudusoft.gsqlparser.nodes.TJoinList; 075import gudusoft.gsqlparser.nodes.TObjectName; 076import gudusoft.gsqlparser.nodes.TObjectNameList; 077import gudusoft.gsqlparser.nodes.TOrderBy; 078import gudusoft.gsqlparser.nodes.TOutputClause; 079import gudusoft.gsqlparser.nodes.TReturningClause; 080import gudusoft.gsqlparser.nodes.TOrderByItem; 081import gudusoft.gsqlparser.nodes.TOrderByItemList; 082import gudusoft.gsqlparser.nodes.TParseTreeVisitor; 083import gudusoft.gsqlparser.nodes.TPartitionClause; 084import gudusoft.gsqlparser.nodes.TPivotClause; 085import gudusoft.gsqlparser.nodes.TPivotInClause; 086import gudusoft.gsqlparser.nodes.TPivotedTable; 087import gudusoft.gsqlparser.nodes.TUnpivotInClause; 088import gudusoft.gsqlparser.nodes.TUnpivotInClauseItem; 089import gudusoft.gsqlparser.nodes.TResultColumn; 090import gudusoft.gsqlparser.nodes.TResultColumnList; 091import gudusoft.gsqlparser.nodes.TSelectDistinct; 092import gudusoft.gsqlparser.nodes.TTable; 093import gudusoft.gsqlparser.nodes.TWhereClause; 094import gudusoft.gsqlparser.nodes.TWindowDef; 095import gudusoft.gsqlparser.nodes.TWithinGroup; 096import gudusoft.gsqlparser.nodes.TWindowFrame; 097import gudusoft.gsqlparser.nodes.TWindowFrameBoundary; 098import gudusoft.gsqlparser.resolver2.ResolutionStatus; 099import gudusoft.gsqlparser.EInsertSource; 100import gudusoft.gsqlparser.nodes.TColumnDefinition; 101import gudusoft.gsqlparser.nodes.TColumnDefinitionList; 102import gudusoft.gsqlparser.nodes.TViewAliasClause; 103import gudusoft.gsqlparser.nodes.TViewAliasItem; 104import gudusoft.gsqlparser.nodes.TViewAliasItemList; 105import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement; 106import gudusoft.gsqlparser.stmt.TCreateViewSqlStatement; 107import gudusoft.gsqlparser.stmt.TDeleteSqlStatement; 108import gudusoft.gsqlparser.stmt.TInsertSqlStatement; 109import gudusoft.gsqlparser.stmt.TMergeSqlStatement; 110import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 111import gudusoft.gsqlparser.stmt.TUpdateSqlStatement; 112import gudusoft.gsqlparser.nodes.TMergeWhenClause; 113import gudusoft.gsqlparser.nodes.TMergeUpdateClause; 114import gudusoft.gsqlparser.nodes.TMergeInsertClause; 115 116import java.util.ArrayDeque; 117import java.util.ArrayList; 118import java.util.Collections; 119import java.util.Deque; 120import java.util.EnumSet; 121import java.util.HashMap; 122import java.util.HashSet; 123import java.util.IdentityHashMap; 124import java.util.LinkedHashMap; 125import java.util.LinkedHashSet; 126import java.util.List; 127import java.util.Locale; 128import java.util.Map; 129import java.util.Set; 130 131/** 132 * Builds a {@link SemanticProgram} from a parsed and resolved 133 * {@link TSelectSqlStatement}. 134 * 135 * <p>Current scope (after slice 9): SELECT with one or more base-table or 136 * CTE sources, optional WHERE, optional JOIN of base tables with ON 137 * conditions, optional GROUP BY (slice 6), optional WITH clause including 138 * chained CTEs (each CTE sees the ones declared strictly before it), 139 * optional FROM-clause subquery (slice 5), optional row-deduplication via 140 * {@code SELECT DISTINCT} or Oracle's {@code SELECT UNIQUE} synonym 141 * (slice 8 — see {@link StatementGraph#isDistinct()}), optional ORDER BY 142 * over physical column references or column-bearing expressions 143 * (slice 9 — see {@link StatementGraph#getOrderByColumnRefs()}). 144 * Expression projections like {@code salary * 2 AS doubled} or 145 * {@code a.x + a.y} are accepted and marked 146 * {@link OutputColumn#isDerived()}; aggregate function calls (slice 6) 147 * are flagged via {@link OutputColumn#isAggregate()}. 148 * 149 * <p>Slice 9 lifts {@code ORDER BY} for sort keys that are physical 150 * column references or expressions over them. The collected references 151 * surface as {@link StatementGraph#getOrderByColumnRefs()}. Sort 152 * direction ({@code ASC}/{@code DESC}) and null placement 153 * ({@code NULLS FIRST}/{@code NULLS LAST}) are presentation metadata 154 * and are not modelled. Ordinal forms ({@code ORDER BY 1}) and 155 * projection-alias forms ({@code SELECT id AS x ... ORDER BY x}) are 156 * rejected so the dependency information is never silently lost; a 157 * later slice can model output-position references explicitly. The 158 * canonical lineage model (slice 7) deliberately ignores ORDER BY — 159 * sort order changes presentation, not column dependency or row-set 160 * membership. 161 * 162 * <p>Row-limit clauses ({@code LIMIT}, {@code TOP}, {@code OFFSET}, 163 * {@code FETCH FIRST}) are rejected statement-wide, including the 164 * SQL Server-style {@code ORDER BY ... OFFSET ... FETCH NEXT}. With 165 * a row-limit present, {@code ORDER BY} ceases to be presentation-only 166 * and starts deciding which rows survive — the canonical-model 167 * exclusion would no longer be sound, so the entire statement is out 168 * of scope until a future slice models row-limit semantics. 169 * 170 * <p>Slice 10 lifts {@code HAVING}: the predicate's column references 171 * are collected into {@link StatementGraph#getHavingColumnRefs()} via 172 * {@link #buildHavingColumnRefs}. The same visitor pattern as projection 173 * and ORDER BY rejects subqueries (scalar, EXISTS, IN-SELECT, ANY/ALL/ 174 * SOME) and window functions before {@link #collectColumnRefs} runs, so 175 * inner-scope refs never leak. HAVING without GROUP BY is supported (the 176 * parser still attaches a {@link TGroupBy} node with empty items). 177 * HAVING is row-influence semantically but does not contribute to the 178 * canonical lineage model — see 179 * {@link StatementGraph#getHavingColumnRefs()} for why. 180 * 181 * <p>Slice 11 lifted uncorrelated scalar subqueries in projection; 182 * scalar bodies are extracted as their own statements via 183 * {@link #extractScalarSubqueriesAsStatements} with the synthetic-name 184 * convention {@code <scalar_subquery_<index>>}. 185 * 186 * <p>Slice 12 lifts set operations (UNION / UNION ALL / INTERSECT / 187 * INTERSECT ALL / MINUS / MINUS ALL / EXCEPT / EXCEPT ALL) at the top 188 * level and as CTE bodies. Each branch becomes its own 189 * {@link StatementGraph} with synthetic name 190 * {@code <set_op_branch_<index>>}; the outer set-op statement carries 191 * empty {@code relations} and lineage edges fan out per-position to 192 * each branch. The flatten descends the left-leaning AST iteratively 193 * (per CLAUDE.md — no recursion on {@code leftStmt}/{@code rightStmt}). 194 * See {@link #buildSetOpProgram}. 195 * 196 * <p>Slice 22 lifts window-function frame clauses 197 * ({@code ROWS}/{@code RANGE}/{@code GROUPS BETWEEN ...}); the frame 198 * unit, start bound, and optional end bound are captured in 199 * {@link WindowFrame} hung off 200 * {@link WindowSpec#getFrame()}. Frame info is presentation-only 201 * (dlineage XML harvests no frame information) and does NOT contribute 202 * to the canonical lineage model — same status as slice-13's 203 * PARTITION BY / OVER ORDER BY refs. Per-bound EXCLUDE clauses 204 * (Netezza-reachable) and non-constant offsets (PG 205 * {@code simple_object_name_t}, ANSI {@code parenthesis_t}) are still 206 * rejected. 207 * 208 * <p>Still rejected: {@code WITH RECURSIVE}, {@code DISTINCT ON (...)} 209 * and other non-{@code DISTINCT}/{@code UNIQUE} row-filters, 210 * scalar-body constant-only projections (zero column refs), 211 * correlated scalar subqueries, scalar bodies with 212 * subqueries in WHERE/JOIN ON/GROUP BY, multi-column scalar inner, 213 * scalar subqueries embedded in larger projection expressions including 214 * EXISTS-in-projection, embedded window functions in larger projection 215 * expressions, window functions in scalar-subquery bodies, window 216 * functions in WHERE/JOIN ON/GROUP BY/HAVING/ORDER BY, empty 217 * {@code OVER ()}, frame clauses with non-constant offsets (PG 218 * {@code simple_object_name_t}, ANSI {@code parenthesis_t}), frame 219 * {@code EXCLUDE} clauses (Netezza-reachable), named windows, 220 * vendor-specific window extensions ({@code FILTER (WHERE ...)}, 221 * {@code WITHIN GROUP}, 222 * {@code KEEP DENSE_RANK}, Hive {@code DISTRIBUTE BY}/{@code CLUSTER BY}/ 223 * {@code SORT BY}/{@code PARTITION BY ... SORT (...)}), non-physical 224 * {@code PARTITION BY} / OVER {@code ORDER BY} refs (literals, 225 * subqueries, function calls, expressions, expression-alias references), 226 * window function names outside the slice-13 allowlist, 227 * (slice 63 lifts explicit {@code CROSS JOIN}, slice 64 lifts 228 * {@code JOIN ... USING (...)}, and slice 66 lifts {@code NATURAL JOIN} 229 * at outer / CTE-body / FROM-subquery-body call sites; all three stay 230 * rejected inside scalar / set-op-branch / set-op-CTE / predicate bodies; 231 * NATURAL additionally requires resolvable catalog metadata on both 232 * sides, with a side-specific reject otherwise), duplicate aliases, 233 * Oracle 234 * {@code ORDER SIBLINGS BY}, Teradata {@code ORDER BY ... RESET WHEN}, 235 * row-limit clauses, ORDER BY ordinals/aliases, Teradata {@code QUALIFY} 236 * clause, set operations nested in FROM-subquery / scalar bodies, 237 * mixed-operator and mixed-{@code _ALL} set-op chains, set-op outer 238 * ORDER BY / row-limit clauses, set-op internal-node modifiers, branch 239 * column-count mismatch, set-op branches with FROM-subquery / scalar 240 * projection / their own CTE list, nested WITH on set-op CTE body. The 241 * builder fails fast outside this scope so callers see the unsupported 242 * case immediately rather than receiving a half-built IR. 243 */ 244public final class SemanticIRBuilder { 245 246 /** 247 * Reserved name prefix for synthetic scalar-subquery body 248 * statements (slice 11). Names take the form 249 * {@code "<scalar_subquery_<index>>"}; the angle brackets ensure 250 * no collision with real CTE names or FROM-clause aliases. 251 * {@link #isScalarSyntheticName(String)} is the only authorised 252 * detector — both this builder and 253 * {@code SemanticIRProjector.BodyIndexes} use it so the convention 254 * lives in one place. 255 */ 256 public static final String SCALAR_BODY_PREFIX = "<scalar_subquery_"; 257 258 /** 259 * Strict regex for synthetic scalar-subquery-body names. Format is 260 * exactly {@code <scalar_subquery_<digits>>} — pinning the digits 261 * suffix and the closing angle bracket prevents a real (quoted) 262 * CTE alias that happens to begin with the prefix from being 263 * misclassified as a synthetic name and silently skipped by 264 * {@code BodyIndexes}. 265 */ 266 private static final java.util.regex.Pattern SCALAR_NAME_PATTERN = 267 java.util.regex.Pattern.compile("<scalar_subquery_\\d+>"); 268 269 /** 270 * True iff {@code name} is a synthetic scalar-subquery-body name 271 * created by this builder (slice 11). Used by 272 * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when 273 * building the CTE/FROM-subquery name lookup tables — scalar 274 * bodies are reached only via lineage edges, never via relations. 275 * 276 * <p>The match is strict: the name must be the full reserved 277 * pattern {@code <scalar_subquery_<digits>>}. A real CTE alias 278 * that happens to start with {@code <scalar_subquery_} but 279 * doesn't match the digits-and-closing-bracket suffix is NOT 280 * skipped (codex impl-review round-1 SHOULD 2). 281 */ 282 public static boolean isScalarSyntheticName(String name) { 283 return name != null && SCALAR_NAME_PATTERN.matcher(name).matches(); 284 } 285 286 /** 287 * Reserved name prefix for synthetic set-op-branch body statements 288 * (slice 12). Names take the form {@code "<set_op_branch_<index>>"}; 289 * the angle brackets ensure no collision with real CTE names or 290 * FROM-clause aliases. {@link #isSetOpBranchSyntheticName(String)} is 291 * the only authorised detector — both this builder and 292 * {@code SemanticIRProjector.BodyIndexes} use it so the convention 293 * lives in one place (slice-11 process lesson #10 generalised). 294 */ 295 public static final String SET_OP_BRANCH_PREFIX = "<set_op_branch_"; 296 297 /** 298 * Strict regex for synthetic set-op-branch-body names. Format is 299 * exactly {@code <set_op_branch_<digits>>} — pinning the digits 300 * suffix and the closing angle bracket prevents a real (quoted) CTE 301 * alias that happens to begin with the prefix from being 302 * misclassified as a synthetic name and silently skipped by 303 * {@code BodyIndexes}. 304 */ 305 private static final java.util.regex.Pattern SET_OP_BRANCH_NAME_PATTERN = 306 java.util.regex.Pattern.compile("^<set_op_branch_\\d+>$"); 307 308 /** 309 * True iff {@code name} is a synthetic set-op-branch-body name 310 * created by this builder (slice 12). Used by 311 * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when 312 * building the CTE/FROM-subquery name lookup tables — set-op 313 * branches are reached only via lineage edges, never via relations. 314 * 315 * <p>The match is strict: the name must be the full reserved 316 * pattern {@code <set_op_branch_<digits>>}. 317 */ 318 public static boolean isSetOpBranchSyntheticName(String name) { 319 return name != null && SET_OP_BRANCH_NAME_PATTERN.matcher(name).matches(); 320 } 321 322 /** 323 * Reserved name prefix for synthetic predicate-subquery body statements 324 * (slice 23 — uncorrelated EXISTS extracted from outer-SELECT JOIN ON). 325 * Names take the form {@code "<predicate_subquery_<index>>"}; the angle 326 * brackets ensure no collision with real CTE names or FROM-clause aliases. 327 * {@link #isPredicateSubquerySyntheticName(String)} is the only authorised 328 * detector — both this builder and {@code SemanticIRProjector.BodyIndexes} 329 * use it so the convention lives in one place. 330 */ 331 public static final String PREDICATE_BODY_PREFIX = "<predicate_subquery_"; 332 333 /** 334 * Strict regex for synthetic predicate-subquery-body names. Format is 335 * exactly {@code <predicate_subquery_<digits>>}; pinning the digit suffix 336 * and the closing angle bracket prevents a real (quoted) CTE alias that 337 * happens to begin with the prefix from being misclassified as a synthetic 338 * name and silently skipped by {@code BodyIndexes}. 339 */ 340 private static final java.util.regex.Pattern PREDICATE_BODY_NAME_PATTERN = 341 java.util.regex.Pattern.compile("<predicate_subquery_\\d+>"); 342 343 /** 344 * True iff {@code name} is a synthetic predicate-subquery-body name 345 * created by this builder (slice 23). Used by 346 * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when 347 * building the CTE/FROM-subquery name lookup tables — predicate-subquery 348 * bodies are unreachable from outer (no relation edge, no lineage edge). 349 */ 350 public static boolean isPredicateSubquerySyntheticName(String name) { 351 return name != null && PREDICATE_BODY_NAME_PATTERN.matcher(name).matches(); 352 } 353 354 /** 355 * Aggregate function names recognized by the builder's per-output 356 * aggregate flag detection (slice 6 originated; slice 29 / slice 30 357 * extended). Treated as case-insensitive. Callers should go through 358 * {@link #isAggregateFunction(TExpression)} rather than reading this 359 * set directly. 360 * 361 * <p>Slice-29 extensions: dialect aggregates {@code listagg}, 362 * {@code string_agg}, {@code group_concat}, {@code array_agg}. 363 * Slice-30 extension: {@code mode} (PostgreSQL ordered-set aggregate; 364 * admitted via the slice-29 WITHIN GROUP path under 365 * {@code findUnsupportedWithinGroupFunctionName}). Slice 30 also 366 * removes {@code mode} from {@link #WINDOW_FUNCTION_NAMES} via an 367 * explicit {@code s.remove("mode")} so the slice-13 window allowlist 368 * isn't widened — see {@link #WINDOW_FUNCTION_NAMES} JavaDoc and 369 * {@code DlineageXmlProjector.ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} for 370 * the matching window-vs-aggregate discriminator override. 371 */ 372 private static final Set<String> AGGREGATE_FUNCTION_NAMES; 373 static { 374 Set<String> s = new HashSet<>(); 375 s.add("count"); 376 s.add("sum"); 377 s.add("avg"); 378 s.add("min"); 379 s.add("max"); 380 s.add("stddev"); 381 s.add("variance"); 382 s.add("var_samp"); 383 s.add("var_pop"); 384 s.add("stddev_samp"); 385 s.add("stddev_pop"); 386 // Common dialect-specific aggregates so the flag has fewer false negatives. 387 s.add("listagg"); // Oracle, PostgreSQL 16+ 388 s.add("string_agg"); // PostgreSQL, SQL Server 389 s.add("group_concat"); // MySQL 390 s.add("array_agg"); // PostgreSQL, Snowflake, BigQuery 391 // Slice 30: PostgreSQL ordered-set aggregate. Unlike percentile_cont / 392 // percentile_disc / rank-family, mode() has no documented window form 393 // in any GSP-supported vendor; admitting it lets the WITHIN GROUP path 394 // accept it in JOIN ON predicate subqueries (slice 29 lift extension) 395 // AND lets DlineageXmlProjector mark its output aggregate=true. 396 // Defensive: WINDOW_FUNCTION_NAMES below subtracts mode after 397 // s.addAll(AGGREGATE_FUNCTION_NAMES) so mode() OVER (...) stays 398 // rejected by the slice-13 window allowlist. 399 s.add("mode"); // PostgreSQL ordered-set aggregate (slice 30) 400 AGGREGATE_FUNCTION_NAMES = Collections.unmodifiableSet(s); 401 } 402 403 /** 404 * Slice 42: hypothetical-set ordered-set aggregate function names that 405 * are ALSO valid window functions. Unlike {@link #AGGREGATE_FUNCTION_NAMES} 406 * these names are admitted as aggregates ONLY when the call carries a 407 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} 408 * {@link TWindowDef} attachment (Oracle / SQL Server parser style — 409 * {@code RANK(100) WITHIN GROUP (ORDER BY x.id)} produces 410 * {@code fn.getWindowDef()!=null}, {@code wd.isIncludingOverClause()== 411 * false}, {@code wd.getWithinGroup()!=null}). Any other shape — direct 412 * {@code fn.getWithinGroup()} (PG / Snowflake style), 413 * {@code fn.getWindowDef()} with {@code OVER (...)}, or no attachment 414 * at all — keeps the existing window-function classification. 415 * 416 * <p>The set is intentionally NOT merged into 417 * {@link #AGGREGATE_FUNCTION_NAMES} because that would also lift the PG 418 * direct-attachment hypothetical-set form ({@code rank(0.5) WITHIN GROUP 419 * (ORDER BY x.salary)}). Pre-plan probe ({@code /tmp/probe42/Probe42.java}) 420 * confirmed PG dlineage XML for hypothetical-set is structurally 421 * indistinguishable from {@code rank() OVER (ORDER BY x)} (both emit 422 * {@code clauseType="orderby"} fdr) — admitting PG hypothetical-set 423 * would manufacture an {@code AGGREGATION_MISMATCH} divergence on the 424 * windowed form because the projector's 425 * {@code DlineageXmlProjector.isWindowFunctionResultset} cannot tell 426 * the two forms apart on PG. 427 * 428 * <p>The Oracle / MSSQL hypothetical-set form, by contrast, emits 429 * neither a {@code clauseType="orderby"} fdr nor a 430 * {@code clauseType="selectList"} fdr (probe-confirmed) — so the 431 * projector's slice-13 windowed-vs-aggregate discriminator returns 432 * {@code false} and the matching projector-side 433 * {@code AGGREGATE_FUNCTION_NAMES} entry marks the output aggregate. 434 * Their OVER form ({@code RANK() OVER (ORDER BY x.id)}) emits 435 * {@code clauseType="orderby"} as expected and stays correctly 436 * classified as windowed. 437 * 438 * <p>Vendor-gated to Oracle / MSSQL inside 439 * {@link #isAdmittedTopLevelWithinGroupAggregate} and 440 * {@link #findUnsupportedWithinGroupFunctionName}; the PG 441 * direct-attachment shape never satisfies the 442 * {@link #isWithinGroupOnlyWindowDef} predicate (because PG sets 443 * {@code fn.getWindowDef()==null}) so the carve-out cannot accidentally 444 * fire on PG. 445 */ 446 private static final Set<String> HYPOTHETICAL_SET_AGGREGATE_NAMES; 447 static { 448 Set<String> s = new HashSet<>(); 449 s.add("rank"); 450 s.add("dense_rank"); 451 s.add("percent_rank"); 452 s.add("cume_dist"); 453 HYPOTHETICAL_SET_AGGREGATE_NAMES = Collections.unmodifiableSet(s); 454 } 455 456 /** 457 * Slice 42: true iff {@code fn} is an Oracle / MSSQL hypothetical-set 458 * ordered-set aggregate call shape — {@code RANK} / {@code DENSE_RANK} / 459 * {@code PERCENT_RANK} / {@code CUME_DIST} with 460 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} 461 * {@link TWindowDef} attachment. Used both as a name-whitelist 462 * discriminator (so PG direct {@code fn.getWithinGroup()} cannot 463 * accidentally pass through, since PG sets {@code fn.getWindowDef()== 464 * null}) and as the {@link #isAggregateFunction} carve-out trigger. 465 */ 466 private static boolean isHypotheticalSetWithinGroupCall(TFunctionCall fn) { 467 if (fn == null) return false; 468 if (!isWithinGroupOnlyWindowDef(fn.getWindowDef())) return false; 469 if (fn.getFunctionName() == null) return false; 470 String name = fn.getFunctionName().toString(); 471 if (name == null || name.isEmpty()) return false; 472 return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains( 473 name.toLowerCase(Locale.ROOT)); 474 } 475 476 /** 477 * Predicate-bearing join types accepted by the current builder. 478 * Slice 64: each must carry either an ON condition or a USING 479 * clause; the per-key {@code joinColumnRefs} emission happens in 480 * {@link #buildRelations} for USING and via 481 * {@link #collectColumnRefs} for ON. NATURAL, semi/anti, 482 * vendor-specific joins, and nested-join sources stay rejected so 483 * the IR cannot quietly drop a row-set predicate. The unqualified 484 * output-naming case for USING merged keys is deferred to S65. 485 */ 486 private static final EnumSet<EJoinType> ALLOWED_PREDICATE_JOIN_TYPES = EnumSet.of( 487 EJoinType.inner, 488 EJoinType.left, 489 EJoinType.right, 490 EJoinType.full, 491 EJoinType.fullouter, 492 EJoinType.leftouter, 493 EJoinType.rightouter, 494 EJoinType.join, 495 // MySQL STRAIGHT_JOIN is an optimizer join-order hint with no 496 // effect on result semantics; it carries a normal ON/USING 497 // clause and mapSemanticJoinType() degrades it to INNER. Admit 498 // it on the predicate path so it emits a standard INNER join 499 // entity instead of aborting analysis with UNSUPPORTED_JOIN_TYPE. 500 EJoinType.straight 501 ); 502 503 /** 504 * Slice 63 — join types admitted by the builder but that must NOT 505 * carry an ON or USING clause. Currently just {@code CROSS}; the 506 * tier exists so that future ON-less shapes can join the same path 507 * with the same shape contract. Slice 66 added a separate 508 * {@link #NATURAL_JOIN_TYPES} tier because NATURAL has its own 509 * catalog-required reject path that CROSS does not. 510 */ 511 private static final EnumSet<EJoinType> ALLOWED_ON_LESS_JOIN_TYPES = EnumSet.of( 512 EJoinType.cross 513 ); 514 515 /** 516 * Slice 66 — NATURAL join types. Each MUST NOT carry an ON or USING 517 * clause. Each MUST have resolvable catalog metadata on BOTH sides; 518 * a missing-catalog reject fires inside {@link #buildRelations} 519 * with a side-specific diagnostic. The shared-column list is 520 * inferred from the running {@link LeftOutputState} ∩ right's 521 * catalog and feeds into {@link #emitMergedJoinRefs} the same way 522 * a syntactically-declared USING list does. 523 */ 524 private static final EnumSet<EJoinType> NATURAL_JOIN_TYPES = EnumSet.of( 525 EJoinType.natural, 526 EJoinType.natural_inner, 527 EJoinType.natural_left, 528 EJoinType.natural_right, 529 EJoinType.natural_leftouter, 530 EJoinType.natural_rightouter, 531 EJoinType.natural_full, 532 EJoinType.natural_fullouter 533 ); 534 535 private static boolean isNaturalJoinType(EJoinType jt) { 536 return jt != null && NATURAL_JOIN_TYPES.contains(jt); 537 } 538 539 /** 540 * SQL Server lateral joins: {@code CROSS APPLY} / {@code OUTER APPLY}. 541 * The right operand is a correlated derived table (subquery or 542 * table-valued function) whose correlation lives <em>inside</em> the 543 * right operand (its WHERE clause for the subquery form, its function 544 * arguments for the TVF form) rather than on an ON/USING clause. So 545 * APPLY is admitted on the ON-less tier (like {@code CROSS}); it MUST 546 * NOT carry ON/USING. {@code mapSemanticJoinType} degrades 547 * {@code crossapply -> INNER} and {@code outerapply -> LEFT}; the 548 * lateral nature is carried by {@link JoinEntity#isLateral()} (mirrors 549 * how NATURAL is carried by {@code isNatural()} rather than its own 550 * join type). The correlation is preserved by the normal child 551 * StatementGraph the right derived table already produces, so it is 552 * NOT lifted onto the join edge (the predicate references the inner 553 * relation and the outer scope, not the right endpoint). 554 */ 555 private static final EnumSet<EJoinType> LATERAL_JOIN_TYPES = EnumSet.of( 556 EJoinType.crossapply, 557 EJoinType.outerapply 558 ); 559 560 private static boolean isLateralJoinType(EJoinType jt) { 561 return jt != null && LATERAL_JOIN_TYPES.contains(jt); 562 } 563 564 /** 565 * True when a join operand is an ANSI / PostgreSQL {@code LATERAL} derived 566 * table or table-valued function. Unlike the SQL Server APPLY forms (which 567 * are encoded in the join <em>type</em> and matched by 568 * {@link #isLateralJoinType}), the {@code LATERAL} keyword sits on the right 569 * operand itself: the parser captures it as that {@link TTable}'s start 570 * token (covers {@code , LATERAL (...)}, {@code CROSS JOIN LATERAL (...)}, 571 * {@code JOIN LATERAL (...) ON ...}, and {@code LATERAL func(...)}). A plain 572 * derived table starts with {@code (} and a base relation with its name, so 573 * this never false-matches a non-lateral operand. 574 */ 575 private static boolean isLateralTable(TTable t) { 576 if (t == null) { 577 return false; 578 } 579 // LATERAL always precedes a derived table / table-valued function / 580 // xmltable, never a base relation. So a plain object-name table that 581 // happens to be named `lateral` (legal where LATERAL is unreserved, or 582 // when quoted) is NOT a lateral join even though its start token reads 583 // `lateral` — exclude the base-relation shape before the token check. 584 if (t.getTableType() == ETableSource.objectname) { 585 return false; 586 } 587 TSourceToken st = t.getStartToken(); 588 return st != null && "LATERAL".equalsIgnoreCase(st.toString()); 589 } 590 591 private SemanticIRBuilder() {} 592 593 /** 594 * Thread-local sink for non-fatal {@link Severity#WARN} diagnostics 595 * raised <em>during</em> a build that should not abort the analysis. 596 * 597 * <p>The builder is fully static, so there is no instance field to 598 * accumulate warnings on and {@link SemanticProgram} is a pure data 599 * container. {@link SqlSemanticAnalyzer} is the single production 600 * consumer: it {@link #clearBuildDiagnostics() clears} this sink 601 * immediately before invoking a {@code build*} method and 602 * {@link #pendingBuildDiagnostics() drains} it immediately after a 603 * successful build, merging the result into the 604 * {@link AnalysisResult} diagnostics. The clear-before contract keeps 605 * the sink from leaking warnings across statements on the same thread. 606 * 607 * <p>Currently used only by the catalog-less {@code NATURAL JOIN} 608 * degrade path (see {@link #recordNaturalDegradeWarning}): rather than 609 * failing the whole analysis when shared-column expansion has no 610 * catalog, the builder degrades to a bare NATURAL join entity and 611 * records a {@link DiagnosticCode#NATURAL_CATALOG_REQUIRED} warning 612 * here. 613 */ 614 private static final ThreadLocal<List<Diagnostic>> BUILD_DIAGNOSTICS = 615 new ThreadLocal<>(); 616 617 /** 618 * Record a non-fatal build-time warning into the thread-local sink, 619 * lazily creating the backing list on first use. No-ops are 620 * impossible: the warning is always retained until the next 621 * {@link #clearBuildDiagnostics()} on this thread. 622 */ 623 private static void recordBuildWarning(Diagnostic warning) { 624 List<Diagnostic> sink = BUILD_DIAGNOSTICS.get(); 625 if (sink == null) { 626 sink = new ArrayList<>(); 627 BUILD_DIAGNOSTICS.set(sink); 628 } 629 sink.add(warning); 630 } 631 632 /** 633 * Reset the thread-local warning sink. Consumers MUST call this before 634 * a {@code build*} invocation whose warnings they intend to read, so 635 * stale warnings from a prior build on the same thread are not 636 * observed. 637 */ 638 public static void clearBuildDiagnostics() { 639 BUILD_DIAGNOSTICS.remove(); 640 } 641 642 /** 643 * @return a copy of the non-fatal warnings accumulated since the last 644 * {@link #clearBuildDiagnostics()} on this thread; never 645 * {@code null}, possibly empty. Non-draining: the sink is left 646 * intact (use {@link #drainBuildDiagnostics()} to read-and-clear). 647 */ 648 public static List<Diagnostic> pendingBuildDiagnostics() { 649 List<Diagnostic> sink = BUILD_DIAGNOSTICS.get(); 650 if (sink == null || sink.isEmpty()) { 651 return java.util.Collections.emptyList(); 652 } 653 return new ArrayList<>(sink); 654 } 655 656 /** 657 * Read-and-clear: returns a copy of the accumulated warnings and removes 658 * the thread-local sink in one step. Production consumers 659 * ({@link SqlSemanticAnalyzer}) drain here so warnings never linger on a 660 * pooled thread and a later direct {@code build*} caller can't observe 661 * stale analyzer-internal warnings. 662 * 663 * @return a copy of the warnings (never {@code null}, possibly empty). 664 */ 665 public static List<Diagnostic> drainBuildDiagnostics() { 666 List<Diagnostic> pending = pendingBuildDiagnostics(); 667 BUILD_DIAGNOSTICS.remove(); 668 return pending; 669 } 670 671 /** 672 * Catalog-less {@code NATURAL JOIN} degrade — record the 673 * {@link DiagnosticCode#NATURAL_CATALOG_REQUIRED} reject as a non-fatal 674 * {@link Severity#WARN} instead of throwing. The join still appears in 675 * the join graph as a bare NATURAL entity (empty conditions / USING 676 * columns, both endpoints populated); only the shared-column predicates 677 * are absent. Shared between the SELECT-side {@link #buildRelations} and 678 * the joined-UPDATE FROM path so the wording stays identical to the 679 * former fatal diagnostic. 680 */ 681 private static void recordNaturalDegradeWarning(NaturalKeyResult r, TJoinItem item) { 682 recordBuildWarning(Diagnostic.warn( 683 DiagnosticCode.NATURAL_CATALOG_REQUIRED, 684 formatNaturalCatalogReject(r), item)); 685 } 686 687 /** 688 * Terminate a clause column-collection pass that accumulated non-exact 689 * (unresolvable) column bindings. Normally fatal 690 * ({@link DiagnosticCode#COLUMN_BINDING_NON_EXACT}); but two structural 691 * anchors degrade it to a non-fatal warning (GSP R6) — record the warning 692 * and let the build continue (the unresolved refs are simply omitted, 693 * never fabricated) instead of discarding the whole statement graph: 694 * 695 * <ol> 696 * <li><b>USING join anchor</b> — the FROM clause carries a 697 * {@code JOIN ... USING} (the scope has merged keys). The join 698 * structure is fully known and only the unqualified non-key SELECT/ 699 * WHERE columns are unplaceable without a catalog.</li> 700 * <li><b>Join-graph anchor</b> — the FROM clause resolved a fully-built 701 * join graph (two or more endpoints from explicit ON / CROSS / comma 702 * predicates; {@link NameBindingProvider#hasJoinStructureAnchor()}). 703 * An explicit ON / CROSS join graph is just as fully determined as a 704 * USING join — the only thing missing without a catalog is 705 * <em>which side</em> an unqualified column belongs to, which is 706 * exactly the non-fatal case the USING branch already tolerates.</li> 707 * </ol> 708 * 709 * <p>BOTH anchors fire ONLY when every reject is an <em>unqualified</em> 710 * column miss ({@code allRejectsUnqualified}). A qualified reference that 711 * fails to bind (e.g. {@code b.id} pointing at the wrong side, or an 712 * unregistered catalog column under {@code JOIN ... USING}) is a genuine 713 * error, not a which-side ambiguity, and stays fatal under either anchor. 714 * 715 * <p>Catalog-less NATURAL joins are handled earlier by the 716 * {@code hasNaturalDegrade()} fallback. The fatal path remains for the 717 * genuinely structureless case (no resolvable join graph) and for any 718 * qualified-reference miss. 719 */ 720 private static void rejectNonExactBindings(List<String> rejects, 721 NameBindingProvider provider, 722 boolean allRejectsUnqualified) { 723 if (provider != null && allRejectsUnqualified 724 && provider.getUsingScope().hasMergedKeys()) { 725 recordBuildWarning(Diagnostic.warn(DiagnosticCode.COLUMN_BINDING_NON_EXACT, 726 "non-exact column bindings (USING join, no aliases): " + rejects, null)); 727 return; 728 } 729 if (provider != null && provider.hasJoinStructureAnchor() 730 && allRejectsUnqualified) { 731 recordBuildWarning(Diagnostic.warn(DiagnosticCode.COLUMN_BINDING_NON_EXACT, 732 "non-exact column bindings (join graph resolved, no aliases/catalog): " 733 + rejects, null)); 734 return; 735 } 736 throw new SemanticIRBuildException(Diagnostic.error( 737 DiagnosticCode.COLUMN_BINDING_NON_EXACT, 738 "non-exact column bindings: " + rejects, null)); 739 } 740 741 public static SemanticProgram build(TSelectSqlStatement select, NameBindingProvider provider) { 742 if (select == null) { 743 throw new IllegalArgumentException("select must not be null"); 744 } 745 if (provider == null) { 746 throw new IllegalArgumentException("provider must not be null"); 747 } 748 List<StatementGraph> stmts = new ArrayList<>(); 749 List<LineageEdge> lineage = new ArrayList<>(); 750 Map<String, Integer> cteNameToStatementIndex = new HashMap<>(); 751 Map<String, List<String>> ctePublishedColumns = new HashMap<>(); 752 int outerIndex = buildSelectProgramInto(select, provider, stmts, lineage, 753 cteNameToStatementIndex, ctePublishedColumns); 754 // Slice 170 (S9): attach query-block scope metadata (GAP 4). 755 attachQueryBlockScopes(stmts, outerIndex, cteNameToStatementIndex); 756 return new SemanticProgram(stmts, lineage); 757 } 758 759 /** 760 * Slice 170 (S9) — populate {@link QueryBlockScope} on each emitted 761 * SELECT-program block (GAP 4): a stable {@code statementIndex}, the 762 * {@link ScopeKind}, the CTE/derived name when present, and a 763 * {@code parentStatementIndex} pointing at the <em>lexical owner</em>. 764 * 765 * <p>The flat list order is preserved (mutates entries in place via the 766 * immutable {@code withJoinAnalysisFacts} copier). The outer statement 767 * ({@code outerIndex}) is the {@link ScopeKind#MAIN} block with a null 768 * parent. CTE bodies (known via {@code cteNameToStatementIndex}) and 769 * other bodies emitted before the outer take the outer as their 770 * top-level lexical owner. 771 * 772 * <p><strong>V1 scope (plan Q-CTE):</strong> {@code parentStatementIndex} 773 * is the top-level owning statement. Precise multi-level nested-body 774 * parent edges (e.g. a subquery inside a CTE body) are a documented 775 * follow-up; for the common single-level case (CTEs / FROM-subqueries 776 * directly under one SELECT) this is exact. 777 */ 778 private static void attachQueryBlockScopes(List<StatementGraph> stmts, 779 int outerIndex, 780 Map<String, Integer> cteNameToStatementIndex) { 781 if (stmts.isEmpty()) return; 782 java.util.Set<Integer> cteIndices = new java.util.HashSet<Integer>( 783 cteNameToStatementIndex.values()); 784 for (int i = 0; i < stmts.size(); i++) { 785 StatementGraph sg = stmts.get(i); 786 ScopeKind kind; 787 Integer parent; 788 if (i == outerIndex) { 789 kind = ScopeKind.MAIN; 790 parent = null; 791 } else if (cteIndices.contains(Integer.valueOf(i))) { 792 kind = ScopeKind.CTE; 793 parent = Integer.valueOf(outerIndex); 794 } else { 795 // A FROM/scalar/predicate body emitted before the outer. 796 kind = ScopeKind.DERIVED_TABLE; 797 parent = Integer.valueOf(outerIndex); 798 } 799 // Slice 179 (R5): the block's own source span, set at the SELECT 800 // construction site, follows the existing 1-based/end-exclusive 801 // SourceSpan convention. Null when not threaded (e.g. DML). 802 QueryBlockScope scope = new QueryBlockScope( 803 i, parent, kind, sg.getName(), sg.getSourceSpan()); 804 stmts.set(i, sg.withJoinAnalysisFacts( 805 sg.getJoinAnalysisFacts().withQueryBlockScope(scope))); 806 } 807 } 808 809 /** 810 * Slice 122 — build a SELECT program into a CALLER-OWNED 811 * {@code stmts}/{@code lineage} pair, seeded with a caller-supplied 812 * outer CTE context. {@link #build} delegates here with fresh empty 813 * maps (behaviour-preserving extraction of its former inline body). 814 * 815 * <p>The MERGE USING-subquery branch in {@link #buildMerge} delegates 816 * here with a COPY of the outer MERGE's {@code cteNameToStatementIndex} 817 * (and {@code ctePublishedColumns}) so a USING body that references an 818 * outer MERGE CTE 819 * ({@code WITH cte AS (...) MERGE INTO t USING (SELECT ... FROM cte) s}) 820 * resolves the reference as CTE-kind and emits the cross-statement 821 * {@code STATEMENT_OUTPUT(usingIdx, col) -> STATEMENT_OUTPUT(cteIdx, col)} 822 * edge into the already-built CTE body. Building directly into the 823 * shared lists means the inner statements land at their final absolute 824 * indices with NO rebase — so edges to the pre-seeded outer CTE 825 * reference the correct index (the pre-slice-122 826 * {@code build()+rebaseLineageEdge} path could not, because the rebase 827 * offset would wrongly shift an outer-CTE index too). This closes the 828 * slice-110 documented parity gap. 829 * 830 * <p>{@code hasOuterCteListAlreadyProcessed} is derived from THIS 831 * select's own CTE list only — the seeded outer CTEs are not declared 832 * by {@code select.getCteList()}, so they must not trigger the 833 * nested-WITH reject in {@code buildSelectStatementImpl}. 834 * 835 * <p>Slice 108 Phase 0 — the two helper calls (CTE walker + 836 * outer-SELECT body) were extracted from {@link #build} into 837 * {@link #buildSelectCteList} / {@link #buildSelectBodyAfterCteWalk}; 838 * calling them with {@code allowShadowOverride=false} / 839 * {@code additionalAllCteNames=null} reproduces the prior inline walker. 840 * 841 * @return the index, into the supplied {@code stmts} list, of the 842 * program's final (outer / merged-set-op) statement — i.e. the 843 * statement a consumer should treat as this SELECT's result. 844 */ 845 private static int buildSelectProgramInto( 846 TSelectSqlStatement select, 847 NameBindingProvider provider, 848 List<StatementGraph> stmts, 849 List<LineageEdge> lineage, 850 Map<String, Integer> cteNameToStatementIndex, 851 Map<String, List<String>> ctePublishedColumns) { 852 TCTEList cteList = select.getCteList(); 853 boolean hasOuterCteList = cteList != null && cteList.size() > 0; 854 buildSelectCteList(cteList, provider, stmts, lineage, 855 cteNameToStatementIndex, ctePublishedColumns, 856 /*allowShadowOverride=*/ false, 857 /*additionalAllCteNames=*/ null); 858 buildSelectBodyAfterCteWalk(select, provider, stmts, lineage, 859 cteNameToStatementIndex, ctePublishedColumns, 860 /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteList); 861 return stmts.size() - 1; 862 } 863 864 /** 865 * Slice 108 — walk a SELECT-side WITH clause and append each CTE body 866 * to {@code stmts} as a preceding statement. Extracted from the inline 867 * walker that previously lived in {@link #build} (lines ~516–663 pre- 868 * slice-108). Mirrors the slice-101 {@link #buildMergeCteList}, 869 * slice-105 {@link #buildUpdateCteList}, and slice-106 870 * {@link #buildDeleteCteList} helpers. 871 * 872 * <p>Phase 0 (behaviour-preserving refactor): {@code allowShadowOverride 873 * = false} and {@code additionalAllCteNames = null} reproduce the 874 * pre-slice-108 inline walker byte-for-byte. 875 * 876 * <p>Phase 1 (shadow admit): {@code allowShadowOverride = true} enables 877 * the mixed outer+inner WITH on INSERT shadow case (slice 108). When 878 * called from {@link #buildInsert}, the OUTER pass runs with 879 * {@code allowShadowOverride=false}, populating 880 * {@code cteNameToStatementIndex} and {@code ctePublishedColumns} with 881 * outer CTE bindings. The INNER pass then runs with 882 * {@code allowShadowOverride=true} and 883 * {@code additionalAllCteNames=outerAllNames}. The inner pass: 884 * <ul> 885 * <li>uses a fresh local {@code localVisibleSoFar} for intra-list 886 * duplicate detection (so {@code DUPLICATE_CTE_NAME} still 887 * fires for inner {@code x, x} even when outer also declares 888 * {@code x});</li> 889 * <li>snapshots {@code cteNameToStatementIndex.keySet()} at entry 890 * into {@code outerKeysSnapshot}; the union 891 * {@code outerKeysSnapshot ∪ localVisibleSoFar} drives BOTH 892 * {@link #rejectForwardCteReferences} AND 893 * {@link NameBindingProvider#withCteContext} (round-2 codex 894 * BLOCKER 3 fix — keeps inner-y references to outer-x from 895 * being falsely flagged as forward references);</li> 896 * <li>on collision with an outer entry (after a successful body 897 * build), {@link Map#put} overrides the 898 * {@code cteNameToStatementIndex} and {@code ctePublishedColumns} 899 * entries so the source SELECT sees the INNER body. The OUTER 900 * body stays in {@code stmts[]} at its earlier position; its 901 * cteMap entry is just no longer referenced by name (PG nested- 902 * WITH inner-shadows-outer semantics).</li> 903 * </ul> 904 * 905 * <p>{@code additionalAllCteNames} is unioned into the per-call 906 * {@code allCteNames} that {@link #rejectForwardCteReferences} consults 907 * (round-1 codex BLOCKER 2 fix — keeps each scope's forward-ref check 908 * narrow so an outer CTE body referencing a base-table whose name 909 * happens to coincide with an inner CTE name does NOT falsely flag). 910 */ 911 private static void buildSelectCteList( 912 TCTEList cteList, 913 NameBindingProvider provider, 914 List<StatementGraph> stmts, 915 List<LineageEdge> lineage, 916 Map<String, Integer> cteNameToStatementIndex, 917 Map<String, List<String>> ctePublishedColumns, 918 boolean allowShadowOverride, 919 Set<String> additionalAllCteNames) { 920 if (cteList == null || cteList.size() == 0) { 921 return; 922 } 923 rejectRecursiveCtes(cteList); 924 925 // Per-call allCteNames for rejectForwardCteReferences. The optional 926 // additionalAllCteNames extends this scope (Phase 1: outer names 927 // visible to inner CTE body forward-ref checks). Phase 0 path passes 928 // null, so this is just collectCteNames(cteList). 929 Set<String> allCteNames; 930 if (additionalAllCteNames != null && !additionalAllCteNames.isEmpty()) { 931 allCteNames = new HashSet<>(collectCteNames(cteList)); 932 allCteNames.addAll(additionalAllCteNames); 933 } else { 934 allCteNames = collectCteNames(cteList); 935 } 936 937 // Phase 1: snapshot outer-scope CTE names at entry so subsequent 938 // iterations of this list always see the FULL outer scope for 939 // forward-ref classification and withCteContext, even if a shadow 940 // override later overwrites a name's cteMap entry. 941 Set<String> outerKeysSnapshot = allowShadowOverride 942 ? new HashSet<>(cteNameToStatementIndex.keySet()) 943 : null; 944 945 // Build each CTE body left-to-right. Each CTE sees CTEs declared 946 // strictly before it (standard SQL chain semantics, slice 4). 947 // Slice 18: CTE bodies accept FROM-subqueries (mirroring the 948 // outer-SELECT extraction path) AND scalar-subquery projections 949 // (slice 11): for each CTE body, FROM-subqueries are extracted 950 // first, then scalar bodies, then the CTE body is built/appended. 951 // The per-CTE-body subqueryAliasToIndex is local to the iteration 952 // so different CTE bodies cannot collide on FROM-subquery aliases. 953 // Slice 60: running map of "CTE name → published column names" 954 // for star expansion. Each CTE's published columns are added 955 // AFTER its body is built so a CTE cannot self-reference and 956 // forward references (rejected earlier) cannot leak through. 957 // Set-op CTE bodies use the merged StatementGraph.outputColumns. 958 // For non-set-op CTE bodies the column names also come from 959 // StatementGraph.outputColumns. Explicit CTE column lists are 960 // rejected at the star expander, not at populate time. 961 Set<String> localVisibleSoFar = new HashSet<>(); 962 for (int i = 0; i < cteList.size(); i++) { 963 TCTE cte = cteList.getCTE(i); 964 String cteName = cte.getTableName().toString(); 965 String cteNameLower = cteName.toLowerCase(Locale.ROOT); 966 // Slice 15 MUST 9 / round-4 MUST 1: reject duplicate CTE 967 // names BEFORE rejectForwardCteReferences so duplicate-name 968 // diagnostics are not preempted by forward-reference 969 // diagnostics. cteNameToStatementIndex is keyed lower-case; 970 // a duplicate entry would silently overwrite the earlier 971 // body and leave OUTER_REFERENCE-of-CTE pointing at the 972 // wrong statement. 973 // 974 // Slice 108: intra-list duplicate check uses localVisibleSoFar 975 // (NOT outerKeysSnapshot) so an inner CTE shadowing an outer 976 // CTE is admitted while inner-x, inner-x stays rejected (round-1 977 // codex BLOCKER 1 fix). 978 if (localVisibleSoFar.contains(cteNameLower)) { 979 throw new SemanticIRBuildException( 980 Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME, 981 "duplicate CTE name '" + cteName 982 + "' in WITH clause; CTE names must be unique", cte)); 983 } 984 // Slice 108: effectiveVisible = outerKeysSnapshot ∪ localVisibleSoFar. 985 // Drives BOTH rejectForwardCteReferences AND 986 // bodyProvider.withCteContext so inner-y body referencing outer-x 987 // is admitted (round-2 codex BLOCKER 3 fix). 988 Set<String> effectiveVisible; 989 if (outerKeysSnapshot != null) { 990 if (outerKeysSnapshot.isEmpty()) { 991 effectiveVisible = localVisibleSoFar; 992 } else if (localVisibleSoFar.isEmpty()) { 993 effectiveVisible = outerKeysSnapshot; 994 } else { 995 effectiveVisible = new HashSet<>(outerKeysSnapshot); 996 effectiveVisible.addAll(localVisibleSoFar); 997 } 998 } else { 999 effectiveVisible = localVisibleSoFar; 1000 } 1001 rejectForwardCteReferences(cte, allCteNames, effectiveVisible); 1002 // Slice 60: bodyProvider gets the CTE-context narrowing 1003 // first; the effective-alias-keyed in-scope map is 1004 // applied LATER, after the body's own FROM-subqueries 1005 // are extracted (so we can walk the body's FROM clause 1006 // and resolve each relation to its effective alias). 1007 // This deferred narrowing replaces the slice-60 v1 path 1008 // that put the running ctePublishedColumns map (CTE-name 1009 // keyed) directly on the provider — that keying class 1010 // could collide when a subquery alias matched a CTE 1011 // name (codex diff-review). 1012 NameBindingProvider bodyProvider = provider.withCteContext(effectiveVisible); 1013 TSelectSqlStatement cteBody = cte.getSubquery(); 1014 // Slice 103 — snapshot lineage size BEFORE the body branch so 1015 // the slice-102 rename helper can rewrite outgoing 1016 // STATEMENT_OUTPUT refs in [lineageSize0, lineage.size()) 1017 // without touching prior CTE bodies' edges. Covers BOTH the 1018 // set-op and non-set-op branches (mirrors slice-102 1019 // buildMergeCteList at line ~5820). 1020 int lineageSize0 = lineage.size(); 1021 int bodyIdx; 1022 if (cteBody != null 1023 && cteBody.getSetOperatorType() != null 1024 && cteBody.getSetOperatorType() != ESetOperatorType.none) { 1025 // Slice 12: set-op CTE body. The outer set-op statement 1026 // carries the CTE name so BodyIndexes.cteByConsumerAndName 1027 // resolves it (slice-18 consumer-keyed projector lookup). 1028 // The CTE body's CTE list (if any) is rejected as a 1029 // nested-WITH inside buildSetOpProgram. 1030 bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, lineage, 1031 cteNameToStatementIndex, cteName, 1032 /*hasOuterCteListAlreadyProcessed=*/ false); 1033 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 1034 } else { 1035 // Slice 18: snapshot/rollback around recursive 1036 // FROM-subquery extraction inside this CTE body. 1037 // Mirrors the outer-SELECT wrapper below and the 1038 // slice-16 set-op wrapper. Currently defensive: a 1039 // thrown exception in a deeper level would otherwise 1040 // leak siblings/ancestors at this CTE's level into 1041 // stmts/lineage. The wrapper truncates back to the 1042 // pre-extraction boundary and rethrows. Per-CTE 1043 // granularity: earlier CTE bodies in the same WITH 1044 // list are NOT rolled back (they're already complete). 1045 int cteStmtsSize0 = stmts.size(); 1046 int cteLineageSize0 = lineage.size(); 1047 Map<String, Integer> cteSubqueryAliasToIndex; 1048 try { 1049 // Slice 60: pass the running ctePublishedColumns 1050 // so the body's own FROM-subqueries see earlier 1051 // CTEs at every recursion level. 1052 cteSubqueryAliasToIndex = 1053 extractFromSubqueriesAsStatements(cteBody, bodyProvider, 1054 stmts, lineage, cteNameToStatementIndex, 1055 ctePublishedColumns); 1056 } catch (RuntimeException ex) { 1057 while (stmts.size() > cteStmtsSize0) stmts.remove(stmts.size() - 1); 1058 while (lineage.size() > cteLineageSize0) lineage.remove(lineage.size() - 1); 1059 throw ex; 1060 } 1061 EnclosingScope cteEnclosing = buildEnclosingScope(cteBody, 1062 cteNameToStatementIndex, cteSubqueryAliasToIndex, 1063 /*parent=*/ null); 1064 Map<Integer, ScalarInfo> cteScalarMap = 1065 extractScalarSubqueriesAsStatements(cteBody, 1066 bodyProvider, stmts, lineage, 1067 cteNameToStatementIndex, cteEnclosing, 1068 /*allowRecursiveScalarSubqueryExtraction=*/ true); 1069 // Slice 60 (codex diff-review): build the per-CTE 1070 // effective-alias-keyed in-scope map by walking the 1071 // CTE body's FROM list. CTE references and 1072 // FROM-subquery aliases live in the same FROM 1073 // namespace (preflight rejects duplicates), so 1074 // effective-alias keying makes a name collision 1075 // physically impossible. 1076 Map<String, List<String>> cteBodyInScope = 1077 buildEffectiveAliasInScopeMap(cteBody, bodyProvider, 1078 ctePublishedColumns, cteSubqueryAliasToIndex, 1079 stmts); 1080 NameBindingProvider cteBodyProviderWithStar = bodyProvider 1081 .withInScopeRelationColumns(cteBodyInScope); 1082 // Slice 114 — switch from the 7-arg buildSelectStatement 1083 // to the 14-arg buildSelectStatementImpl so the CTE 1084 // body's WHERE clause can extract uncorrelated predicate 1085 // subqueries (IN-SELECT / EXISTS / NOT EXISTS / scalar 1086 // comparison / ANY-ALL-SOME) as their own statements. 1087 // The wrapper mirrors the outer-SELECT entry pattern in 1088 // {@link #build}: if the build appends predicate bodies 1089 // and then a later post-extraction reject fires, the 1090 // try/catch truncates stmts/lineage back to the 1091 // pre-call boundary so a partial extraction doesn't 1092 // leak into the program. The slice-113 set-op branch 1093 // call site is itself enclosed by the slice-16 1094 // SET-OP-WIDE rollback at {@link #buildSetOpProgram}; 1095 // the CTE-body call sites do NOT inherit a similar 1096 // enclosing wrapper, which is why slice 114 adds one 1097 // here. The from-subquery / scalar-subquery 1098 // extractions above this point have their own 1099 // slice-17/18 wrappers, so the pre-CALL snapshot 1100 // bounds the truncate exactly to whatever 1101 // buildSelectStatementImpl appended. 1102 int cteBodyStmtsSnapshot = stmts.size(); 1103 int cteBodyLineageSnapshot = lineage.size(); 1104 StatementGraph body; 1105 try { 1106 if (isPivotSelect(cteBody)) { 1107 // Slice 139 — a nested PIVOT/UNPIVOT as a CTE body. 1108 // The slice-129 pivot router in 1109 // buildSelectStatementImpl is gated to the OUTER 1110 // SELECT (name == null && !isPredicateBody), so a 1111 // pivot built here (name == cteName) would otherwise 1112 // fall to the normal path and reject with the 1113 // misleading TABLE_BINDING_UNRESOLVED 1114 // ("null(piviot_table)"). Route it to buildPivotSelect 1115 // with the CTE name as the statement name — mirroring 1116 // the slice-138 processDirectSubqueryTable branch for a 1117 // FROM-subquery body. The CTE becomes a proper pivot 1118 // StatementGraph; the outer query references the CTE 1119 // name (CTE-kind) so the standard cross-stmt CTE path 1120 // emits STATEMENT_OUTPUT(outer) → STATEMENT_OUTPUT(cte), 1121 // and the emitLineageForStatement(body, ...) call below 1122 // wires the pivot's own output → source edges 1123 // (base-table TABLE_COLUMN, or — when the pivot's source 1124 // is itself a subquery, slice 136 — STATEMENT_OUTPUT via 1125 // cteSubqueryAliasToIndex). All pivot deferrals are 1126 // preserved by buildPivotSelect's own guards 1127 // (PIVOT_WITH_QUERY_CLAUSE / MULTIPLE_CLAUSES / 1128 // UNPIVOT / STAR_CATALOG_REQUIRED). 1129 body = buildPivotSelect(cteBody, 1130 cteBodyProviderWithStar, cteName); 1131 } else { 1132 body = buildSelectStatementImpl(cteBody, 1133 cteBodyProviderWithStar, cteName, 1134 /*hasOuterCteListAlreadyProcessed=*/ false, 1135 /*allowFromSubqueries=*/ true, 1136 /*allowScalarProjectionSubqueries=*/ true, 1137 /*allowWindowProjection=*/ true, 1138 // Slice 114 — keep JOIN-ON predicate 1139 // subqueries rejected inside CTE bodies 1140 // (preserve slice 23/26 contract; the lift 1141 // is WHERE-only; the two flags are 1142 // independent per slice 113 split). 1143 /*allowJoinOnPredicateSubqueries=*/ false, 1144 /*stmtsForExtraction=*/ stmts, 1145 /*lineageForExtraction=*/ lineage, 1146 /*cteMapForExtraction=*/ cteNameToStatementIndex, 1147 /*isPredicateBody=*/ false, 1148 /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE, 1149 /*allowWherePredicateSubqueries=*/ true); 1150 } 1151 } catch (RuntimeException ex) { 1152 while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 1153 while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 1154 throw ex; 1155 } 1156 bodyIdx = stmts.size(); 1157 stmts.add(body); 1158 // Slice 108 — emit lineage BEFORE the cteMap.put so that 1159 // in the shadow case (allowShadowOverride=true with 1160 // cteNameLower already in cteMap from outer pass), the 1161 // body's column refs to <cteNameLower> still resolve to 1162 // the OUTER body (PG inner-x body sees outer-x via the 1163 // closer-enclosing-not-yet-shadowed fallback). Non-shadow 1164 // cases are unaffected because cteMap does not yet contain 1165 // cteNameLower at this point and the body cannot reference 1166 // its own name without going through the recursive-CTE 1167 // path (already rejected upstream). 1168 emitLineageForStatement(body, bodyIdx, lineage, 1169 cteNameToStatementIndex, cteSubqueryAliasToIndex, 1170 cteScalarMap); 1171 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 1172 } 1173 // Slice 103 — apply the slice-102 rename helper if the CTE 1174 // declares an explicit column list (no-op otherwise). The 1175 // helper returns the published column list (renamed if 1176 // explicit, else body's inner names). Slice-60's 1177 // `ctePublishedColumns.put` is collapsed into this single 1178 // call site (covers both branches above). 1179 List<String> publishedCols = applyExplicitCteColumnListRename( 1180 cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT"); 1181 ctePublishedColumns.put(cteNameLower, publishedCols); 1182 localVisibleSoFar.add(cteNameLower); 1183 } 1184 } 1185 1186 /** 1187 * Slice 108 — outer-SELECT processing extracted from the previous inline 1188 * body of {@link #build} (lines ~665–763 pre-slice-108). 1189 * 1190 * <p>{@code hasOuterCteListAlreadyProcessed} is an EXPLICIT boolean 1191 * parameter (round-2 codex BLOCKER 4 fix). Previously this was inferred 1192 * from {@code select.getCteList() != null && size > 0}; after the 1193 * slice-108 buildInsert shadow path nulls {@code source.getCteList()} 1194 * before calling, that inference would be wrong. The caller passes the 1195 * truth. 1196 * 1197 * <p>The post-walk {@code cteNameToStatementIndex.keySet()} replaces the 1198 * pre-walk {@code allCteNames} because the walker has populated every 1199 * declared CTE name by lowercase key — they are equal sets. 1200 */ 1201 private static void buildSelectBodyAfterCteWalk( 1202 TSelectSqlStatement select, 1203 NameBindingProvider provider, 1204 List<StatementGraph> stmts, 1205 List<LineageEdge> lineage, 1206 Map<String, Integer> cteNameToStatementIndex, 1207 Map<String, List<String>> ctePublishedColumns, 1208 boolean hasOuterCteListAlreadyProcessed) { 1209 Set<String> allCteNames = cteNameToStatementIndex.isEmpty() 1210 ? Collections.<String>emptySet() 1211 : new HashSet<>(cteNameToStatementIndex.keySet()); 1212 1213 // Slice 12: top-level set-op dispatch. CTE list (if any) was 1214 // already processed above; pass hasOuterCteListAlreadyProcessed=true 1215 // so buildSetOpProgram doesn't re-flag it as a nested WITH. 1216 if (select.getSetOperatorType() != null 1217 && select.getSetOperatorType() != ESetOperatorType.none) { 1218 NameBindingProvider outerProvider = provider.withCteContext(allCteNames); 1219 buildSetOpProgram(select, outerProvider, stmts, lineage, 1220 cteNameToStatementIndex, /*setOpName=*/ null, 1221 /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteListAlreadyProcessed); 1222 return; 1223 } 1224 1225 // Outer statement: pre-extract any FROM-clause subqueries as their 1226 // own statements, then any scalar-subquery projections, then build 1227 // the outer body, then emit lineage with the global CTE map, the 1228 // outer-local subquery alias map, AND the scalar-projection map. 1229 // Slice 60: outerProvider gets the CTE-context narrowing here; 1230 // the effective-alias-keyed in-scope map is applied LATER, after 1231 // outer FROM-subqueries are extracted. The same deferred 1232 // narrowing pattern as the CTE-body branch — see the codex 1233 // diff-review note on alias/CTE-name collision. 1234 NameBindingProvider outerProvider = provider.withCteContext(allCteNames); 1235 // Slice 17: snapshot/rollback around recursive FROM-subquery 1236 // extraction. The recursive extractor mutates stmts/lineage as 1237 // each level's bodies land; if a deeper-level rejection fires 1238 // after sibling/ancestor mutations, this wrapper truncates the 1239 // lists back to the pre-call boundary and rethrows. Mirrors the 1240 // slice-16 buildSetOpProgram wrapper (§14.18 process lesson #21: 1241 // when a class of mutation-free checks can fire after partial 1242 // mutation, close it transactionally instead of point-fixing). 1243 // 1244 // The rollback is currently defensive: build() allocates fresh 1245 // stmts/lineage per invocation, so a thrown exception's caller 1246 // cannot directly observe leaked state. The wrapper is kept 1247 // because (a) the slice-17 preflight closes the most direct 1248 // partial-mutation classes BEFORE the recursive extraction 1249 // runs, but recursive levels can still fail at deeper rejection 1250 // points (e.g. a nested set-op-in-FROM-subquery body inside a 1251 // sibling that succeeds at the preflight); (b) consistency with 1252 // slice 16's pattern means a future refactor that lifts the 1253 // build() per-call list allocation does not silently re-open 1254 // the partial-mutation class. 1255 int stmtsSize0 = stmts.size(); 1256 int lineageSize0 = lineage.size(); 1257 Map<String, Integer> outerSubqueryAliasToIndex; 1258 try { 1259 outerSubqueryAliasToIndex = 1260 extractFromSubqueriesAsStatements(select, outerProvider, 1261 stmts, lineage, cteNameToStatementIndex, 1262 ctePublishedColumns); 1263 } catch (RuntimeException ex) { 1264 while (stmts.size() > stmtsSize0) stmts.remove(stmts.size() - 1); 1265 while (lineage.size() > lineageSize0) lineage.remove(lineage.size() - 1); 1266 throw ex; 1267 } 1268 EnclosingScope outerEnclosing = buildEnclosingScope(select, 1269 cteNameToStatementIndex, outerSubqueryAliasToIndex, 1270 /*parent=*/ null); 1271 Map<Integer, ScalarInfo> outerScalarMap = 1272 extractScalarSubqueriesAsStatements(select, outerProvider, 1273 stmts, lineage, cteNameToStatementIndex, outerEnclosing, 1274 /*allowRecursiveScalarSubqueryExtraction=*/ true); 1275 // Slice 60 (codex diff-review): build the outer's 1276 // effective-alias-keyed in-scope map by walking the outer 1277 // SELECT's FROM list. Effective-alias keying eliminates the 1278 // CTE-name vs subquery-alias collision class. 1279 Map<String, List<String>> outerInScope = buildEffectiveAliasInScopeMap( 1280 select, outerProvider, ctePublishedColumns, 1281 outerSubqueryAliasToIndex, stmts); 1282 NameBindingProvider outerProviderWithStar = outerProvider 1283 .withInScopeRelationColumns(outerInScope); 1284 // Slice 23: outer-SELECT path uses buildSelectStatementImpl directly so 1285 // the slice-23 EXISTS-extraction can append predicate-body statements 1286 // to `stmts`/`lineage`. Snapshot/rollback wrapper around the call 1287 // matches the slice-16/17/20 pattern: a partial extraction (e.g. third 1288 // EXISTS rejected after first two extracted) truncates the lists. 1289 int outerStmtsSnapshot = stmts.size(); 1290 int outerLineageSnapshot = lineage.size(); 1291 StatementGraph outer; 1292 try { 1293 outer = buildSelectStatementImpl(select, outerProviderWithStar, null, 1294 /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteListAlreadyProcessed, 1295 /*allowFromSubqueries=*/ true, 1296 /*allowScalarProjectionSubqueries=*/ true, 1297 /*allowWindowProjection=*/ true, 1298 /*allowJoinOnPredicateSubqueries=*/ true, 1299 stmts, lineage, 1300 /*cteMapForExtraction=*/ cteNameToStatementIndex, 1301 /*isPredicateBody=*/ false, 1302 /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE, 1303 /*allowWherePredicateSubqueries=*/ true); 1304 } catch (RuntimeException e) { 1305 while (stmts.size() > outerStmtsSnapshot) stmts.remove(stmts.size() - 1); 1306 while (lineage.size() > outerLineageSnapshot) lineage.remove(lineage.size() - 1); 1307 throw e; 1308 } 1309 int outerIndex = stmts.size(); 1310 stmts.add(outer); 1311 emitLineageForStatement(outer, outerIndex, lineage, 1312 cteNameToStatementIndex, outerSubqueryAliasToIndex, outerScalarMap); 1313 } 1314 1315 /** 1316 * Slice 78 — admit a single {@code INSERT INTO target SELECT ...} 1317 * statement. Builds the source SELECT via {@link #build} (reusing 1318 * the existing pipeline unchanged), then appends an {@code "INSERT"}- 1319 * kind {@link StatementGraph} carrying the target relation and 1320 * cross-statement lineage edges. 1321 * 1322 * <p>Admitted shape: {@code INSERT INTO <target> [(c1, c2, ...)] 1323 * <subquery-SELECT>}. Rejections: 1324 * <ul> 1325 * <li>{@link EInsertSource#values}, {@code values_empty}, 1326 * {@code default_values}, {@code execute}, 1327 * {@code values_function}, {@code values_multi_table}, 1328 * {@code hive_query}, {@code values_oracle_record}, 1329 * {@code set_column_value}, {@code value_table} → 1330 * {@link DiagnosticCode#INSERT_SOURCE_NOT_SUPPORTED}.</li> 1331 * <li>Oracle {@code INSERT ALL} / {@code INSERT FIRST} → 1332 * {@link DiagnosticCode#INSERT_MULTI_TABLE_NOT_SUPPORTED}. 1333 * Hive multi-insert ({@code multiInsertStatements} non-empty) is 1334 * routed to {@link #buildHiveMultiInsert} instead of rejected.</li> 1335 * <li>Missing target table (defensive — the parser usually rejects 1336 * first) → {@link DiagnosticCode#INSERT_TARGET_MISSING}.</li> 1337 * <li>Explicit column list arity ≠ source SELECT output count → 1338 * {@link DiagnosticCode#INSERT_COLUMN_COUNT_MISMATCH}.</li> 1339 * </ul> 1340 * 1341 * <p>The source SELECT is built first via {@code build()} and its 1342 * full {@link SemanticProgram} (CTE bodies + scalar bodies + 1343 * FROM-subquery bodies + outer SELECT + cross-stmt lineage) is 1344 * appended verbatim to the returned program. The INSERT 1345 * {@link StatementGraph} is appended LAST; its 1346 * {@link StatementGraph#getRelations() relations} lists the source 1347 * SELECT as a single {@link RelationKind#SUBQUERY} entry whose 1348 * {@code qualifiedName} is the source SELECT's outer-statement name 1349 * (synthesised when needed). All other column-ref lists stay empty 1350 * on the INSERT — an INSERT has no projection of its own. 1351 * 1352 * <p>Cross-statement {@link LineageEdge}s for the INSERT are 1353 * {@code from = TABLE_COLUMN(target_qname, target_col_i_name)} 1354 * and {@code to = STATEMENT_OUTPUT(selectIdx, source_output_i_name)}. 1355 * Target column names are the explicit INSERT column-list spellings 1356 * when supplied, else the source SELECT's positional output names. 1357 */ 1358 public static SemanticProgram buildInsert(TInsertSqlStatement insert, 1359 NameBindingProvider provider) { 1360 if (insert == null) { 1361 throw new IllegalArgumentException("insert must not be null"); 1362 } 1363 if (provider == null) { 1364 throw new IllegalArgumentException("provider must not be null"); 1365 } 1366 1367 // Oracle INSERT ALL / FIRST rejects: their multi-value AST shape 1368 // is fundamentally different from the Hive multi-insert path. 1369 // Slice 78 scopes single-target INSERT SELECT; slice 93 lifts 1370 // the Hive multi-insert case via buildHiveMultiInsert. 1371 if (insert.isInsertAll() || insert.isInsertFirst()) { 1372 throw new SemanticIRBuildException(Diagnostic.error( 1373 DiagnosticCode.INSERT_MULTI_TABLE_NOT_SUPPORTED, 1374 "multi-table INSERT (INSERT ALL / INSERT FIRST) is not " 1375 + "supported by SemanticIRBuilder.buildInsert; " 1376 + "slice 78 admits single-target INSERT INTO <target> SELECT ...", 1377 insert)); 1378 } 1379 // Slice 109 — outer-WITH on Hive multi-insert 1380 // (`WITH x AS (...) FROM x INSERT INTO t1 SELECT ... INSERT INTO t2 1381 // SELECT ...`) is now admitted via buildHiveMultiInsert's CTE-aware 1382 // path. The slice-104 early reject for this shape is removed; the 1383 // helper builds the outer CTE bodies ONCE upfront and reuses the 1384 // shared cteMap/publishedMap across every sub-SELECT. 1385 // INSERT_OUTER_WITH_ON_HIVE_MULTI_INSERT_NOT_SUPPORTED stays declared- 1386 // but-unreached for API stability (slice 71/72/82/86/95/96/97/98/108 1387 // retain-for-documentation precedent). 1388 // 1389 // Hive multi-insert: FROM src INSERT INTO t1 SELECT ... INSERT INTO t2 SELECT ... 1390 // Each sub-SELECT already carries the shared FROM source in its fromClause. 1391 if (!insert.getMultiInsertStatements().isEmpty()) { 1392 return buildHiveMultiInsert(insert, provider); 1393 } 1394 1395 EInsertSource src = insert.getInsertSource(); 1396 if (src != EInsertSource.subquery) { 1397 throw new SemanticIRBuildException(Diagnostic.error( 1398 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 1399 "INSERT source '" + src + "' is not supported by " 1400 + "SemanticIRBuilder.buildInsert; slice 78 admits " 1401 + "subquery-source INSERT only (INSERT INTO <target> SELECT ...)", 1402 insert)); 1403 } 1404 1405 // Slice 85 — cheap statement-level OUTPUT_INTO reject runs 1406 // BEFORE the source SELECT is built so a multi-violation 1407 // shape (e.g. `INSERT INTO t OUTPUT INSERTED.x INTO #log 1408 // SELECT ... FROM bad_join`) routes to the cheaper structural 1409 // code first. 1410 if (insert.getOutputClause() != null 1411 && insert.getOutputClause().getIntoTable() != null) { 1412 throw new SemanticIRBuildException(Diagnostic.error( 1413 DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED, 1414 "INSERT OUTPUT ... INTO <target> writes a second target; " 1415 + "slice 85 admits projection-only OUTPUT", 1416 insert)); 1417 } 1418 1419 TTable targetTable = insert.getTargetTable(); 1420 if (targetTable == null || targetTable.getTableName() == null) { 1421 throw new SemanticIRBuildException(Diagnostic.error( 1422 DiagnosticCode.INSERT_TARGET_MISSING, 1423 "INSERT statement has no resolvable target table", 1424 insert)); 1425 } 1426 String targetQName = targetTable.getTableName().toString(); 1427 if (targetQName.isEmpty()) { 1428 throw new SemanticIRBuildException(Diagnostic.error( 1429 DiagnosticCode.INSERT_TARGET_MISSING, 1430 "INSERT target table name is empty", 1431 insert)); 1432 } 1433 1434 TSelectSqlStatement source = insert.getSubQuery(); 1435 if (source == null) { 1436 // Defensive: getInsertSource() == subquery but subQuery is 1437 // null. Surface as INSERT_TARGET_MISSING's source half. 1438 throw new SemanticIRBuildException(Diagnostic.error( 1439 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 1440 "INSERT source is declared as subquery but no SELECT " 1441 + "statement was attached", 1442 insert)); 1443 } 1444 1445 // Slice 104 — outer-WITH on INSERT. The parser attaches the outer 1446 // WITH clause to insert.getCteList(), NOT to source.getCteList(). 1447 // Before slice 104 buildInsert ignored insert.getCteList(), which 1448 // silently mis-bound CTE references in the source SELECT as 1449 // TABLE-kind relations with phantom columns. The slice-104 fix is 1450 // an AST handoff: move insert.getCteList() onto source.getCteList() 1451 // for the duration of the inner build(source) call so the 1452 // slice-103 SELECT-side CTE walker handles construction, rename, 1453 // and rejects (recursive / duplicate / forward-reference / arity 1454 // mismatch). Restore in finally so the AST is observably 1455 // unchanged to the caller (Java field references — token-chain 1456 // state is perturbed by setCteList(null)'s removeTokens() but 1457 // observably benign for downstream Semantic IR). 1458 // 1459 // Slice 107 / 108 — mixed outer-WITH + inner-WITH on INSERT. PG / 1460 // Oracle / Snowflake admit at parse. Three sub-cases: 1461 // (a) Only outer WITH populated. AST handoff (slice 104): move 1462 // insert.cteList onto source.cteList and call build(source). 1463 // (b) Only inner WITH populated. Pass through unchanged (the 1464 // walker handles it on its own). 1465 // (c) Both outer and inner WITH populated. Slice 107 admitted 1466 // this for disjoint names via a flat-merge; slice 108 admits 1467 // it for the SHADOWING case too (`WITH x ... INSERT ... WITH 1468 // x ... SELECT ... FROM x` — inner shadows outer per 1469 // PG/Oracle/Snowflake nested-WITH semantics). The slice-108 1470 // implementation uses a TWO-PASS walker invocation in this 1471 // method: outer pass first (allowShadowOverride=false), then 1472 // inner pass (allowShadowOverride=true, 1473 // additionalAllCteNames=outer-names). The walker's two-set 1474 // visibility model (outerKeysSnapshot ∪ localVisibleSoFar) 1475 // keeps PG semantics correct: inner-x's body sees outer-x via 1476 // the cteMap (override is post-build), and inner CTEs declared 1477 // after inner-x see inner-x. The OUTER body stays in stmts[] 1478 // at its position; its cteMap entry is just no longer 1479 // referenced by name (shadowed). Source SELECT's `FROM x` 1480 // resolves to inner-x. 1481 // 1482 // INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED stays declared but 1483 // is no longer reached by slice 108. Slice107Test §F/§Q (cross- 1484 // boundary duplicate rejects) are deleted; positive coverage moves 1485 // to Slice108Test. 1486 TCTEList outerCtes = insert.getCteList(); 1487 TCTEList savedSourceCtes = source.getCteList(); 1488 boolean handoffApplied = false; 1489 SemanticProgram inner; 1490 boolean haveOuterCtes = outerCtes != null && outerCtes.size() > 0; 1491 boolean haveInnerCtes = savedSourceCtes != null && savedSourceCtes.size() > 0; 1492 if (haveOuterCtes && haveInnerCtes) { 1493 // Slice 108 — two-pass walker. Null both AST CTE lists before 1494 // calling buildSelectBodyAfterCteWalk so the helper does not 1495 // re-process source.getCteList(). hasOuterCteListAlreadyProcessed 1496 // is passed true (round-2 codex BLOCKER 4 fix). 1497 source.setCteList(null); 1498 insert.setCteList(null); 1499 handoffApplied = true; 1500 try { 1501 List<StatementGraph> innerStmts = new ArrayList<>(); 1502 List<LineageEdge> innerLineage = new ArrayList<>(); 1503 Map<String, Integer> cteMap = new HashMap<>(); 1504 Map<String, List<String>> publishedMap = new HashMap<>(); 1505 // Outer pass: outerAllNames as its own scope. 1506 buildSelectCteList(outerCtes, provider, innerStmts, innerLineage, 1507 cteMap, publishedMap, 1508 /*allowShadowOverride=*/ false, 1509 /*additionalAllCteNames=*/ null); 1510 // Inner pass: outerAllNames also visible for forward-ref 1511 // classification (round-1 codex BLOCKER 2 fix); shadow 1512 // override admits cross-boundary duplicate names. 1513 Set<String> outerAllNames = collectCteNames(outerCtes); 1514 buildSelectCteList(savedSourceCtes, provider, innerStmts, innerLineage, 1515 cteMap, publishedMap, 1516 /*allowShadowOverride=*/ true, 1517 /*additionalAllCteNames=*/ outerAllNames); 1518 // Source SELECT body sees the post-pass cteMap (inner wins 1519 // for shadowed names). 1520 buildSelectBodyAfterCteWalk(source, provider, innerStmts, innerLineage, 1521 cteMap, publishedMap, 1522 /*hasOuterCteListAlreadyProcessed=*/ true); 1523 inner = new SemanticProgram(innerStmts, innerLineage); 1524 } finally { 1525 source.setCteList(savedSourceCtes); 1526 insert.setCteList(outerCtes); 1527 } 1528 } else { 1529 // Single-sided cases. Slice 104 AST handoff for outer-only; 1530 // pass-through for inner-only or no CTEs. 1531 if (haveOuterCtes) { 1532 source.setCteList(outerCtes); 1533 insert.setCteList(null); 1534 handoffApplied = true; 1535 } 1536 try { 1537 inner = build(source, provider); 1538 } finally { 1539 if (handoffApplied) { 1540 source.setCteList(savedSourceCtes); 1541 insert.setCteList(outerCtes); 1542 } 1543 } 1544 } 1545 1546 // Slice 93 — delegate INSERT-graph assembly to the shared helper 1547 // used by both single-target (slice 78) and Hive multi-insert 1548 // (slice 93). out is freshly empty so the helper's rebase offset 1549 // is 0 (no-op for inner lineage). RETURNING/OUTPUT clauses are 1550 // passed directly (slice 85 still owns the projection build). 1551 List<StatementGraph> out = new ArrayList<>(inner.getStatements().size() + 1); 1552 List<LineageEdge> outLineage = new ArrayList<>(); 1553 assembleInsertGraphAndLineage( 1554 insert, targetTable, targetQName, inner, 1555 "INSERT", 1556 insert.getReturningClause(), 1557 insert.getOutputClause(), 1558 out, outLineage, provider); 1559 return new SemanticProgram(out, outLineage); 1560 } 1561 1562 /** 1563 * Slice 93 — admit a Hive multi-insert block of the form 1564 * {@code FROM src INSERT INTO t1 SELECT col1 INSERT INTO t2 SELECT col2}. 1565 * 1566 * <p>The parser represents the whole block as one {@link TInsertSqlStatement} 1567 * whose first INSERT-SELECT pair is the primary statement and whose 1568 * additional pairs are in {@link TInsertSqlStatement#getMultiInsertStatements()}. 1569 * Crucially, each sub-SELECT already carries the shared FROM source in its own 1570 * {@code fromClause} / {@code fromSourceTable} — no post-processing is needed. 1571 * 1572 * <p>Produces a flat {@link SemanticProgram} containing per-pair blocks of 1573 * statements concatenated in INSERT order: each block contributes its source 1574 * SELECT's inner statements (CTE bodies / FROM-subquery bodies extracted by 1575 * {@link #build}) followed by its outer SELECT followed by an INSERT graph. 1576 * The minimum is {@code 2N} statements (one SELECT + one INSERT per target); 1577 * sub-SELECTs with extracted inner programs produce more. Each INSERT carries 1578 * cross-statement lineage edges pointing at its preceding SELECT via 1579 * {@link LineageRef#statementOutput}; per-pair inner lineage edges are 1580 * rebased by the current {@code out.size()} so absolute statement indices 1581 * remain valid across the concatenated program. 1582 * 1583 * <p>Safety note on the source-table fallback: this method enables 1584 * {@code provider.withSourceTableFallback(true)} so secondary sub-SELECTs 1585 * (which Resolver2 does not traverse) can still bind their column refs. 1586 * The fallback is constrained at the provider level to fire only when 1587 * Phase 2 did not run AND any explicit qualifier matches Phase 1's source — 1588 * see {@link Resolver2NameBindingProvider#bindColumn}. Current Hive 1589 * multi-insert parses always present a single FROM source, so Phase 1's 1590 * unqualified-column resolution is unambiguous in practice. 1591 */ 1592 private static SemanticProgram buildHiveMultiInsert(TInsertSqlStatement insert, 1593 NameBindingProvider provider) { 1594 // Slice 93 — source-table fallback strategy for Hive multi-insert. 1595 // 1596 // TSQLResolver2 does NOT process the secondary inserts in 1597 // getMultiInsertStatements(): their column refs have 1598 // resolution == null (Phase 2 did not run) even though Phase 1's 1599 // linkColumnToTable sets sourceTable. To let collectColumnRefs 1600 // accept these bindings, we enable a narrow source-table fallback 1601 // in the provider — but ONLY when every sub-SELECT has a SINGLE 1602 // FROM source (the common Hive multi-insert shape that current 1603 // parser support admits). In single-source contexts, Phase 1's 1604 // unqualified-column resolution is unambiguous; the fallback is 1605 // safe (round-2 codex Q1 BLOCKING). 1606 // 1607 // If any sub-SELECT has multiple FROM sources, Phase 1 may have 1608 // heuristically picked one source for an unqualified column — 1609 // promoting that to EXACT_MATCH could silently mis-bind. In that 1610 // case the fallback stays disabled; users must qualify column 1611 // references in the secondary branch (the qualifier-matches-source 1612 // safety in bindColumn still allows qualified refs through). 1613 boolean singleSource = isSingleSourceMultiInsert(insert); 1614 NameBindingProvider effectiveProvider = singleSource 1615 ? provider.withSourceTableFallback(true) 1616 : provider; 1617 1618 List<StatementGraph> out = new ArrayList<>(); 1619 List<LineageEdge> outLineage = new ArrayList<>(); 1620 1621 // Slice 109 — outer WITH on multi-insert: build the CTE bodies ONCE 1622 // upfront so each sub-SELECT's `FROM x` resolves against the shared 1623 // cteMap/publishedMap. The parser attaches the outer WITH to the 1624 // primary insert's getCteList(); sub-INSERTs in 1625 // getMultiInsertStatements() carry null cteLists. The AST handoff 1626 // mirrors the slice-104 single-target pattern but only nulls 1627 // insert.getCteList() — there is no source SELECT to move it onto 1628 // because each sub-INSERT has its own. 1629 TCTEList outerCtes = insert.getCteList(); 1630 Map<String, Integer> cteMap = new HashMap<>(); 1631 Map<String, List<String>> publishedMap = new HashMap<>(); 1632 boolean handoffApplied = false; 1633 if (outerCtes != null && outerCtes.size() > 0) { 1634 insert.setCteList(null); 1635 handoffApplied = true; 1636 try { 1637 buildSelectCteList(outerCtes, effectiveProvider, out, outLineage, 1638 cteMap, publishedMap, 1639 /*allowShadowOverride=*/ false, 1640 /*additionalAllCteNames=*/ null); 1641 } catch (RuntimeException ex) { 1642 // Restore eagerly on CTE-build failure so a downstream caller 1643 // observing the AST sees the original cteList. 1644 insert.setCteList(outerCtes); 1645 throw ex; 1646 } 1647 } 1648 1649 try { 1650 // Primary INSERT (first target) 1651 appendOneHiveInsert(insert, effectiveProvider, out, outLineage, 1652 cteMap, publishedMap); 1653 1654 // Additional INSERTs from getMultiInsertStatements() 1655 for (Object miObj : insert.getMultiInsertStatements()) { 1656 appendOneHiveInsert((TInsertSqlStatement) miObj, effectiveProvider, 1657 out, outLineage, cteMap, publishedMap); 1658 } 1659 } finally { 1660 if (handoffApplied) { 1661 insert.setCteList(outerCtes); 1662 } 1663 } 1664 1665 return new SemanticProgram(out, outLineage); 1666 } 1667 1668 /** 1669 * Slice 93 — true when every INSERT-SELECT pair in a Hive multi-insert 1670 * block has a single FROM source (i.e., one entry in 1671 * {@code subQuery.getTables()}). Guards the source-table fallback so 1672 * Phase 1's heuristic source assignment is only trusted in contexts 1673 * where it is provably unambiguous (round-2 codex Q1 BLOCKING). 1674 */ 1675 private static boolean isSingleSourceMultiInsert(TInsertSqlStatement insert) { 1676 if (!isSingleSourceSubQuery(insert.getSubQuery())) { 1677 return false; 1678 } 1679 for (Object miObj : insert.getMultiInsertStatements()) { 1680 TInsertSqlStatement mi = (TInsertSqlStatement) miObj; 1681 if (!isSingleSourceSubQuery(mi.getSubQuery())) { 1682 return false; 1683 } 1684 } 1685 return true; 1686 } 1687 1688 private static boolean isSingleSourceSubQuery(TSelectSqlStatement sel) { 1689 return sel != null && sel.getTables() != null && sel.getTables().size() == 1; 1690 } 1691 1692 /** 1693 * Build one INSERT-SELECT pair into {@code out} / {@code outLineage}. 1694 * Called by {@link #buildHiveMultiInsert} for the primary and each 1695 * additional INSERT in a Hive multi-insert block. Each call validates 1696 * the target/source, builds the source SELECT via {@link #build}, and 1697 * delegates the post-build INSERT-graph assembly to 1698 * {@link #assembleInsertGraphAndLineage} so the layout exactly mirrors 1699 * the single-target slice-78 INSERT path (the helper also handles 1700 * inner-lineage rebasing when {@code out} is non-empty). 1701 */ 1702 private static void appendOneHiveInsert(TInsertSqlStatement insert, 1703 NameBindingProvider provider, 1704 List<StatementGraph> out, 1705 List<LineageEdge> outLineage, 1706 Map<String, Integer> cteMap, 1707 Map<String, List<String>> publishedMap) { 1708 TTable targetTable = insert.getTargetTable(); 1709 if (targetTable == null || targetTable.getTableName() == null) { 1710 throw new SemanticIRBuildException(Diagnostic.error( 1711 DiagnosticCode.INSERT_TARGET_MISSING, 1712 "Hive multi-insert: INSERT has no resolvable target table", 1713 insert)); 1714 } 1715 String targetQName = targetTable.getTableName().toString(); 1716 if (targetQName.isEmpty()) { 1717 throw new SemanticIRBuildException(Diagnostic.error( 1718 DiagnosticCode.INSERT_TARGET_MISSING, 1719 "Hive multi-insert: INSERT target table name is empty", 1720 insert)); 1721 } 1722 TSelectSqlStatement source = insert.getSubQuery(); 1723 if (source == null) { 1724 throw new SemanticIRBuildException(Diagnostic.error( 1725 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 1726 "Hive multi-insert: INSERT has no source SELECT", 1727 insert)); 1728 } 1729 1730 // Slice 109 — when outer CTEs are present (cteMap non-empty), build 1731 // the source SELECT via buildSelectBodyAfterCteWalk directly into 1732 // out/outLineage so it sees the shared cteMap/publishedMap. The 1733 // slice-93 path (no outer CTEs) keeps the build(source, provider) + 1734 // assembleInsertGraphAndLineage flow unchanged. 1735 if (cteMap.isEmpty()) { 1736 SemanticProgram inner = build(source, provider); 1737 // Hive has no RETURNING/OUTPUT — pass null clauses directly. 1738 assembleInsertGraphAndLineage( 1739 insert, targetTable, targetQName, inner, 1740 "Hive multi-insert: INSERT", 1741 /*returningClause=*/ null, 1742 /*outputClause=*/ null, 1743 out, outLineage, provider); 1744 return; 1745 } 1746 1747 // Slice 109 — defensive: parser probe shows sub-SELECTs in Hive 1748 // multi-insert do NOT carry their own cteList. If a future parser 1749 // change ever attached one, mixed outer+inner WITH semantics would 1750 // need slice-107/108-style two-pass walker support; until then the 1751 // shape rejects with the existing mixed-WITH code. 1752 if (source.getCteList() != null && source.getCteList().size() > 0) { 1753 throw new SemanticIRBuildException(Diagnostic.error( 1754 DiagnosticCode.INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED, 1755 "Hive multi-insert: mixed outer + inner WITH on a " 1756 + "sub-SELECT is not supported by " 1757 + "SemanticIRBuilder.buildHiveMultiInsert; " 1758 + "slice 109 admits outer-only WITH on multi-insert", 1759 insert)); 1760 } 1761 1762 // Snapshot out.size() so the source SELECT and its inner extractions 1763 // are pinned to known positions. The slice-23 EXISTS-extraction and 1764 // FROM-subquery extraction paths inside buildSelectBodyAfterCteWalk 1765 // append directly to out/outLineage; the source SELECT lands LAST. 1766 int beforeSelectIdx = out.size(); 1767 buildSelectBodyAfterCteWalk(source, provider, out, outLineage, 1768 cteMap, publishedMap, 1769 /*hasOuterCteListAlreadyProcessed=*/ true); 1770 if (out.size() <= beforeSelectIdx) { 1771 // Defensive: buildSelectBodyAfterCteWalk always appends at least 1772 // the source SELECT; this branch is unreachable in practice. 1773 throw new SemanticIRBuildException(Diagnostic.error( 1774 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 1775 "Hive multi-insert: INSERT source built no statements", 1776 insert)); 1777 } 1778 int selectIdx = out.size() - 1; 1779 assembleInsertTargetGraphFromAppended( 1780 insert, targetTable, targetQName, selectIdx, 1781 "Hive multi-insert: INSERT", 1782 /*returningClause=*/ null, 1783 /*outputClause=*/ null, 1784 out, outLineage, provider); 1785 } 1786 1787 /** 1788 * Slice 93 — shared INSERT-graph assembly used by both the slice-78 1789 * single-target {@link #buildInsert} and the slice-93 Hive multi-insert 1790 * {@link #appendOneHiveInsert}. Appends {@code inner.getStatements()} 1791 * to {@code out} (rebasing {@code inner.getLineage()}'s STATEMENT_OUTPUT 1792 * indices when {@code out} is non-empty), then appends an INSERT-kind 1793 * {@link StatementGraph} and per-source-output cross-statement 1794 * {@link LineageEdge}s. 1795 * 1796 * <p>Discriminators between the two callers: 1797 * <ul> 1798 * <li>{@code diagnosticPrefix} is woven into column-count-mismatch 1799 * and empty-inner-source error messages so the originating call 1800 * site is identifiable.</li> 1801 * <li>{@code returningClause} / {@code outputClause} are passed 1802 * directly to {@link #buildReturningColumns} (slice 78 supplies 1803 * the INSERT's RETURNING/OUTPUT clauses; slice 93's Hive path 1804 * passes {@code null}/{@code null} since Hive has no 1805 * RETURNING/OUTPUT). Passing the clauses directly keeps the 1806 * discriminator visible at every call site rather than hidden 1807 * behind a boolean (round-2 codex Q3 suggestion).</li> 1808 * </ul> 1809 * 1810 * <p>Mutates both {@code out} and {@code outLineage}. 1811 */ 1812 private static void assembleInsertGraphAndLineage( 1813 TInsertSqlStatement insert, 1814 TTable targetTable, 1815 String targetQName, 1816 SemanticProgram inner, 1817 String diagnosticPrefix, 1818 TReturningClause returningClause, 1819 TOutputClause outputClause, 1820 List<StatementGraph> out, 1821 List<LineageEdge> outLineage, 1822 NameBindingProvider provider) { 1823 List<StatementGraph> innerStmts = inner.getStatements(); 1824 if (innerStmts.isEmpty()) { 1825 // Defensive: build() always returns at least one statement when 1826 // it doesn't throw. This branch is unreachable in practice but 1827 // surfaces a structured diagnostic instead of an 1828 // IndexOutOfBoundsException on the sourceOuter access below. 1829 throw new SemanticIRBuildException(Diagnostic.error( 1830 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 1831 diagnosticPrefix + " source built no statements", 1832 insert)); 1833 } 1834 1835 // Rebase inner lineage edges by the current out.size() offset 1836 // (round-2 codex Q4 BLOCKING). For the slice-78 single-target 1837 // path out is empty (offset=0) so rebase is a no-op; for the 1838 // slice-93 Hive path each subsequent INSERT-SELECT pair adds 1839 // an offset matching the absolute position of its inner block. 1840 int offset = out.size(); 1841 int selectIdx = offset + innerStmts.size() - 1; 1842 out.addAll(innerStmts); 1843 for (LineageEdge e : inner.getLineage()) { 1844 outLineage.add(rebaseLineageEdge(e, offset)); 1845 } 1846 1847 StatementGraph sourceOuter = innerStmts.get(innerStmts.size() - 1); 1848 List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns(); 1849 int sourceOutCount = sourceOutputs.size(); 1850 1851 // Optional explicit INSERT column list. Verbatim bare-name 1852 // spelling per slice-78 contract; arity mismatch rejects. 1853 TObjectNameList colList = insert.getColumnList(); 1854 List<String> targetColumnNames = new ArrayList<>(); 1855 if (colList != null && colList.size() > 0) { 1856 for (int i = 0; i < colList.size(); i++) { 1857 TObjectName n = colList.getObjectName(i); 1858 targetColumnNames.add(n == null ? "" : n.toString()); 1859 } 1860 if (targetColumnNames.size() != sourceOutCount) { 1861 throw new SemanticIRBuildException(Diagnostic.error( 1862 DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH, 1863 diagnosticPrefix + " column list has " 1864 + targetColumnNames.size() 1865 + " column(s) but source SELECT produced " 1866 + sourceOutCount + " output(s)", 1867 insert)); 1868 } 1869 } 1870 1871 // INSERT StatementGraph — slice-78 single-target shape with the 1872 // source SELECT as a SUBQUERY-kind relation entry. 1873 String sourceName = sourceOuter.getName(); 1874 String sourceRelAlias = (sourceName != null && !sourceName.isEmpty()) 1875 ? sourceName : "__insert_source__"; 1876 RelationBinding sourceBinding = new RelationBinding( 1877 RelationKind.SUBQUERY, sourceRelAlias); 1878 List<RelationSource> insertRelations = new ArrayList<>(); 1879 insertRelations.add(new RelationSource(sourceRelAlias, sourceBinding)); 1880 1881 RelationBinding targetBinding = new RelationBinding( 1882 RelationKind.TABLE, targetQName); 1883 TargetRelation target = new TargetRelation(targetBinding, targetColumnNames); 1884 1885 int insertIdx = out.size(); 1886 String insertTargetAlias = effectiveAliasOf(targetTable); 1887 if (insertTargetAlias == null || insertTargetAlias.isEmpty()) { 1888 insertTargetAlias = targetQName; 1889 } 1890 // Slice 85: RETURNING/OUTPUT projections. Clauses are passed 1891 // through directly from the call site (slice-78 single-target 1892 // forwards the INSERT's own clauses; slice-93 Hive multi-insert 1893 // forwards null/null since Hive has no RETURNING/OUTPUT). 1894 List<OutputColumn> returningCols = buildReturningColumns( 1895 returningClause, 1896 outputClause, 1897 "INSERT", 1898 targetQName, 1899 insertTargetAlias, 1900 targetTable, 1901 /*fromSideRelations=*/ Collections.<RelationSource>emptyList(), 1902 /*fromSideAliasToStmtIndex=*/ Collections.<String, Integer>emptyMap(), 1903 provider, 1904 insertIdx, 1905 outLineage, 1906 insert); 1907 1908 StatementGraph insertOuter = new StatementGraph( 1909 /*name=*/ null, 1910 "INSERT", 1911 insertRelations, 1912 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 1913 returningCols, 1914 /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(), 1915 /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(), 1916 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 1917 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 1918 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 1919 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 1920 /*distinct=*/ false, 1921 /*setOperator=*/ null, 1922 /*rowLimit=*/ null, 1923 target); 1924 out.add(insertOuter); 1925 1926 // Cross-statement lineage: target.col_i ← STATEMENT_OUTPUT(selectIdx, srcName_i) 1927 for (int i = 0; i < sourceOutCount; i++) { 1928 String srcName = sourceOutputs.get(i).getName(); 1929 String tgtName = (i < targetColumnNames.size()) 1930 ? targetColumnNames.get(i) : srcName; 1931 if (tgtName == null || tgtName.isEmpty()) { 1932 continue; 1933 } 1934 outLineage.add(new LineageEdge( 1935 LineageRef.tableColumn(targetQName, tgtName), 1936 LineageRef.statementOutput(selectIdx, srcName))); 1937 } 1938 } 1939 1940 /** 1941 * Slice 109 — assemble the INSERT-target half (TargetRelation, INSERT 1942 * StatementGraph, RETURNING/OUTPUT projections, and cross-statement 1943 * lineage edges) when the source SELECT and its inner extractions have 1944 * ALREADY been appended directly to {@code out}/{@code outLineage} by 1945 * {@link #buildSelectBodyAfterCteWalk}. The slice-93 1946 * {@link #assembleInsertGraphAndLineage} helper, by contrast, takes a 1947 * pre-built {@link SemanticProgram} and rebases STATEMENT_OUTPUT 1948 * indices on the way in — that path is unused here because the source 1949 * SELECT was already built into absolute positions in {@code out}. 1950 * 1951 * <p>{@code selectIdx} must be the position of the source SELECT in 1952 * {@code out} (last statement appended by the caller before this helper 1953 * runs). RETURNING/OUTPUT clauses are passed directly (Hive multi- 1954 * insert callers pass {@code null}/{@code null}); other DMLs that 1955 * adopt this helper later can forward their own. 1956 */ 1957 private static void assembleInsertTargetGraphFromAppended( 1958 TInsertSqlStatement insert, 1959 TTable targetTable, 1960 String targetQName, 1961 int selectIdx, 1962 String diagnosticPrefix, 1963 TReturningClause returningClause, 1964 TOutputClause outputClause, 1965 List<StatementGraph> out, 1966 List<LineageEdge> outLineage, 1967 NameBindingProvider provider) { 1968 StatementGraph sourceOuter = out.get(selectIdx); 1969 List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns(); 1970 int sourceOutCount = sourceOutputs.size(); 1971 1972 TObjectNameList colList = insert.getColumnList(); 1973 List<String> targetColumnNames = new ArrayList<>(); 1974 if (colList != null && colList.size() > 0) { 1975 for (int i = 0; i < colList.size(); i++) { 1976 TObjectName n = colList.getObjectName(i); 1977 targetColumnNames.add(n == null ? "" : n.toString()); 1978 } 1979 if (targetColumnNames.size() != sourceOutCount) { 1980 throw new SemanticIRBuildException(Diagnostic.error( 1981 DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH, 1982 diagnosticPrefix + " column list has " 1983 + targetColumnNames.size() 1984 + " column(s) but source SELECT produced " 1985 + sourceOutCount + " output(s)", 1986 insert)); 1987 } 1988 } 1989 1990 String sourceName = sourceOuter.getName(); 1991 String sourceRelAlias = (sourceName != null && !sourceName.isEmpty()) 1992 ? sourceName : "__insert_source__"; 1993 RelationBinding sourceBinding = new RelationBinding( 1994 RelationKind.SUBQUERY, sourceRelAlias); 1995 List<RelationSource> insertRelations = new ArrayList<>(); 1996 insertRelations.add(new RelationSource(sourceRelAlias, sourceBinding)); 1997 1998 RelationBinding targetBinding = new RelationBinding( 1999 RelationKind.TABLE, targetQName); 2000 TargetRelation target = new TargetRelation(targetBinding, targetColumnNames); 2001 2002 int insertIdx = out.size(); 2003 String insertTargetAlias = effectiveAliasOf(targetTable); 2004 if (insertTargetAlias == null || insertTargetAlias.isEmpty()) { 2005 insertTargetAlias = targetQName; 2006 } 2007 List<OutputColumn> returningCols = buildReturningColumns( 2008 returningClause, 2009 outputClause, 2010 "INSERT", 2011 targetQName, 2012 insertTargetAlias, 2013 targetTable, 2014 /*fromSideRelations=*/ Collections.<RelationSource>emptyList(), 2015 /*fromSideAliasToStmtIndex=*/ Collections.<String, Integer>emptyMap(), 2016 provider, 2017 insertIdx, 2018 outLineage, 2019 insert); 2020 2021 StatementGraph insertOuter = new StatementGraph( 2022 /*name=*/ null, 2023 "INSERT", 2024 insertRelations, 2025 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 2026 returningCols, 2027 /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2028 /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2029 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2030 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2031 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2032 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2033 /*distinct=*/ false, 2034 /*setOperator=*/ null, 2035 /*rowLimit=*/ null, 2036 target); 2037 out.add(insertOuter); 2038 2039 for (int i = 0; i < sourceOutCount; i++) { 2040 String srcName = sourceOutputs.get(i).getName(); 2041 String tgtName = (i < targetColumnNames.size()) 2042 ? targetColumnNames.get(i) : srcName; 2043 if (tgtName == null || tgtName.isEmpty()) { 2044 continue; 2045 } 2046 outLineage.add(new LineageEdge( 2047 LineageRef.tableColumn(targetQName, tgtName), 2048 LineageRef.statementOutput(selectIdx, srcName))); 2049 } 2050 } 2051 2052 /** 2053 * Slice 93 — rebase a {@link LineageEdge}'s {@code STATEMENT_OUTPUT} 2054 * statement indices by {@code offset}. {@code TABLE_COLUMN} refs are 2055 * returned unchanged. Used to concatenate inner {@link SemanticProgram}s 2056 * into a larger one (Hive multi-insert: each INSERT-SELECT pair's inner 2057 * program contributes its own block of statements). 2058 */ 2059 private static LineageEdge rebaseLineageEdge(LineageEdge e, int offset) { 2060 if (offset == 0) { 2061 return e; 2062 } 2063 LineageRef from = rebaseLineageRef(e.getFrom(), offset); 2064 LineageRef to = rebaseLineageRef(e.getTo(), offset); 2065 if (from == e.getFrom() && to == e.getTo()) { 2066 return e; 2067 } 2068 return new LineageEdge(from, to); 2069 } 2070 2071 private static LineageRef rebaseLineageRef(LineageRef ref, int offset) { 2072 if (ref == null) { 2073 return null; 2074 } 2075 if (ref.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) { 2076 return ref; 2077 } 2078 return LineageRef.statementOutput( 2079 ref.getStatementIndex() + offset, ref.getOutputName()); 2080 } 2081 2082 /** 2083 * Slice 79 — admit a single {@code CREATE TABLE target [(c1, ...)] AS 2084 * SELECT ...} (CTAS) statement. Builds the source SELECT via 2085 * {@link #build} unchanged, then appends a {@code "CREATE_TABLE"}- 2086 * kind {@link StatementGraph} carrying the target relation and 2087 * cross-statement lineage edges (mirrors slice-78 INSERT). 2088 * 2089 * <p>Admitted shape: {@code CREATE [OR REPLACE] TABLE target 2090 * [(c1, c2, ...)] AS <subquery-SELECT>}. Plain 2091 * {@code CREATE TABLE target (a INT, b VARCHAR)} (column DDL with 2092 * no AS SELECT) is rejected via 2093 * {@link DiagnosticCode#CREATE_AS_NO_SOURCE_SELECT}. Explicit 2094 * column-list arity mismatch surfaces as 2095 * {@link DiagnosticCode#CREATE_AS_COLUMN_COUNT_MISMATCH}; a 2096 * missing / empty target name surfaces (defensively) as 2097 * {@link DiagnosticCode#CREATE_AS_TARGET_MISSING}. 2098 * 2099 * <p>For CTAS the explicit column-list spellings come from 2100 * {@link TCreateTableSqlStatement#getColumnList()} — only the bare 2101 * column name from each {@link TColumnDefinition} is consumed; 2102 * data-type tokens are ignored by slice 79. 2103 */ 2104 public static SemanticProgram buildCreateTable(TCreateTableSqlStatement create, 2105 NameBindingProvider provider) { 2106 if (create == null) { 2107 throw new IllegalArgumentException("create must not be null"); 2108 } 2109 if (provider == null) { 2110 throw new IllegalArgumentException("provider must not be null"); 2111 } 2112 2113 // Target name extraction. CTAS exposes the target via the 2114 // TCustomSqlStatement-inherited getTargetTable(); the explicit 2115 // getTableName() is a thin wrapper around tables[0].getTableName() 2116 // and also works. Use getTableName() for symmetry with the 2117 // slice-78 INSERT path. 2118 TObjectName targetName = create.getTableName(); 2119 if (targetName == null) { 2120 throw new SemanticIRBuildException(Diagnostic.error( 2121 DiagnosticCode.CREATE_AS_TARGET_MISSING, 2122 "CREATE TABLE has no resolvable target table name", 2123 create)); 2124 } 2125 String targetQName = targetName.toString(); 2126 if (targetQName == null || targetQName.isEmpty()) { 2127 throw new SemanticIRBuildException(Diagnostic.error( 2128 DiagnosticCode.CREATE_AS_TARGET_MISSING, 2129 "CREATE TABLE target table name is empty", 2130 create)); 2131 } 2132 2133 TSelectSqlStatement source = create.getSubQuery(); 2134 if (source == null) { 2135 throw new SemanticIRBuildException(Diagnostic.error( 2136 DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT, 2137 "CREATE TABLE has no AS SELECT subquery; slice 79 admits " 2138 + "CTAS (CREATE TABLE <target> [(c1, ...)] AS SELECT ...) only", 2139 create)); 2140 } 2141 2142 // Pull explicit column-list spellings BEFORE building the inner 2143 // — keeps the error path cheap for the structural-invalid case 2144 // (CTAS with column count mismatch is detected after the inner 2145 // build because we don't know the source output count yet). 2146 List<String> targetColumnNames = new ArrayList<>(); 2147 TColumnDefinitionList colList = create.getColumnList(); 2148 if (colList != null && colList.size() > 0) { 2149 for (int i = 0; i < colList.size(); i++) { 2150 TColumnDefinition cd = colList.getColumn(i); 2151 TObjectName n = (cd == null) ? null : cd.getColumnName(); 2152 String spelling = (n == null) ? "" : n.toString(); 2153 targetColumnNames.add(spelling); 2154 } 2155 } 2156 2157 return assembleCreateLikeProgram(create, source, provider, 2158 "CREATE_TABLE", targetQName, targetColumnNames); 2159 } 2160 2161 /** 2162 * Slice 79 — admit a single 2163 * {@code CREATE [OR REPLACE] VIEW v [(c1, ...)] AS SELECT ...} 2164 * statement. Mirrors {@link #buildCreateTable} except the source 2165 * SELECT is fetched via {@link TCreateViewSqlStatement#getSubquery()} 2166 * (lowercase 'q'), the target name from 2167 * {@link TCreateViewSqlStatement#getViewName()}, and the explicit 2168 * column-list spellings from {@link TViewAliasClause} on the AST. 2169 */ 2170 public static SemanticProgram buildCreateView(TCreateViewSqlStatement create, 2171 NameBindingProvider provider) { 2172 if (create == null) { 2173 throw new IllegalArgumentException("create must not be null"); 2174 } 2175 if (provider == null) { 2176 throw new IllegalArgumentException("provider must not be null"); 2177 } 2178 2179 TObjectName viewName = create.getViewName(); 2180 if (viewName == null) { 2181 throw new SemanticIRBuildException(Diagnostic.error( 2182 DiagnosticCode.CREATE_AS_TARGET_MISSING, 2183 "CREATE VIEW has no resolvable view name", 2184 create)); 2185 } 2186 String targetQName = viewName.toString(); 2187 if (targetQName == null || targetQName.isEmpty()) { 2188 throw new SemanticIRBuildException(Diagnostic.error( 2189 DiagnosticCode.CREATE_AS_TARGET_MISSING, 2190 "CREATE VIEW target view name is empty", 2191 create)); 2192 } 2193 2194 TSelectSqlStatement source = create.getSubquery(); 2195 if (source == null) { 2196 throw new SemanticIRBuildException(Diagnostic.error( 2197 DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT, 2198 "CREATE VIEW has no AS SELECT subquery; slice 79 admits " 2199 + "CREATE VIEW <target> [(c1, ...)] AS SELECT ... only", 2200 create)); 2201 } 2202 2203 // View-side explicit column aliases via viewAliasClause. Items 2204 // whose alias is null are preserved as empty-string entries so 2205 // a parser-quirk gap doesn't silently collapse the list and 2206 // shift later aliases onto wrong source-output positions — 2207 // count-mismatch detection downstream stays accurate 2208 // (codex diff-review round 1 P2 catch). 2209 List<String> targetColumnNames = new ArrayList<>(); 2210 TViewAliasClause aliasClause = create.getViewAliasClause(); 2211 if (aliasClause != null) { 2212 TViewAliasItemList items = aliasClause.getViewAliasItemList(); 2213 if (items != null) { 2214 for (int i = 0; i < items.size(); i++) { 2215 TViewAliasItem item = items.getViewAliasItem(i); 2216 TObjectName alias = (item == null) ? null : item.getAlias(); 2217 String spelling = (alias == null) ? "" : alias.toString(); 2218 targetColumnNames.add(spelling); 2219 } 2220 } 2221 } 2222 2223 return assembleCreateLikeProgram(create, source, provider, 2224 "CREATE_VIEW", targetQName, targetColumnNames); 2225 } 2226 2227 /** 2228 * Shared assembly path for slice-79 CTAS / CREATE VIEW. Given a 2229 * pre-validated target name and the (possibly empty) list of 2230 * explicit column-list spellings, builds the source SELECT, 2231 * validates column-list arity, and emits the outer 2232 * StatementGraph + cross-stmt lineage edges. Mirrors the 2233 * post-source half of slice-78 {@link #buildInsert}. 2234 */ 2235 private static SemanticProgram assembleCreateLikeProgram( 2236 TParseTreeNode anchor, TSelectSqlStatement source, 2237 NameBindingProvider provider, String outerKind, 2238 String targetQName, List<String> targetColumnNames) { 2239 SemanticProgram inner = build(source, provider); 2240 List<StatementGraph> innerStmts = inner.getStatements(); 2241 if (innerStmts.isEmpty()) { 2242 throw new SemanticIRBuildException(Diagnostic.error( 2243 DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT, 2244 "CREATE source built no statements", 2245 anchor)); 2246 } 2247 int selectIdx = innerStmts.size() - 1; 2248 StatementGraph sourceOuter = innerStmts.get(selectIdx); 2249 List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns(); 2250 int sourceOutCount = sourceOutputs.size(); 2251 2252 if (!targetColumnNames.isEmpty() 2253 && targetColumnNames.size() != sourceOutCount) { 2254 throw new SemanticIRBuildException(Diagnostic.error( 2255 DiagnosticCode.CREATE_AS_COLUMN_COUNT_MISMATCH, 2256 outerKind.equals("CREATE_TABLE") 2257 ? ("CREATE TABLE column list has " + targetColumnNames.size() 2258 + " column(s) but source SELECT produced " 2259 + sourceOutCount + " output(s)") 2260 : ("CREATE VIEW alias list has " + targetColumnNames.size() 2261 + " column(s) but source SELECT produced " 2262 + sourceOutCount + " output(s)"), 2263 anchor)); 2264 } 2265 2266 String sourceName = sourceOuter.getName(); 2267 String sourceRelAlias = (sourceName != null && !sourceName.isEmpty()) 2268 ? sourceName : "__create_source__"; 2269 RelationBinding sourceBinding = new RelationBinding( 2270 RelationKind.SUBQUERY, sourceRelAlias); 2271 List<RelationSource> createRelations = new ArrayList<>(); 2272 createRelations.add(new RelationSource(sourceRelAlias, sourceBinding)); 2273 2274 RelationBinding targetBinding = new RelationBinding( 2275 RelationKind.TABLE, targetQName); 2276 TargetRelation target = new TargetRelation(targetBinding, targetColumnNames); 2277 2278 List<StatementGraph> out = new ArrayList<>(innerStmts.size() + 1); 2279 out.addAll(innerStmts); 2280 List<LineageEdge> outLineage = new ArrayList<>(inner.getLineage()); 2281 2282 StatementGraph createOuter = new StatementGraph( 2283 /*name=*/ null, 2284 outerKind, 2285 createRelations, 2286 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 2287 /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2288 /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2289 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2290 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2291 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2292 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2293 /*distinct=*/ false, 2294 /*setOperator=*/ null, 2295 /*rowLimit=*/ null, 2296 target); 2297 out.add(createOuter); 2298 2299 for (int i = 0; i < sourceOutCount; i++) { 2300 String srcName = sourceOutputs.get(i).getName(); 2301 String tgtName = (i < targetColumnNames.size()) 2302 ? targetColumnNames.get(i) : srcName; 2303 if (tgtName == null || tgtName.isEmpty()) { 2304 continue; 2305 } 2306 outLineage.add(new LineageEdge( 2307 LineageRef.tableColumn(targetQName, tgtName), 2308 LineageRef.statementOutput(selectIdx, srcName))); 2309 } 2310 2311 return new SemanticProgram(out, outLineage); 2312 } 2313 2314 /** 2315 * Slice 80 / 82 — admit {@code UPDATE target SET c1 = expr1, 2316 * c2 = expr2, ... [FROM source_list] [WHERE pred]} statements. 2317 * Emits one {@code "UPDATE"}-kind {@link StatementGraph} carrying 2318 * the target relation plus synthetic {@link OutputColumn} entries 2319 * per SET assignment (output name = SET LHS verbatim spelling; 2320 * sources = column refs collected from the RHS expression). 2321 * Optional WHERE refs surface on 2322 * {@link StatementGraph#getFilterColumnRefs()}. 2323 * 2324 * <p>Slice 82 lifts the slice-80 {@code UPDATE_JOINED_NOT_SUPPORTED} 2325 * reject for the common PG / MSSQL / BigQuery / Snowflake / Redshift 2326 * FROM-side joined UPDATE shapes. The IR shape gains two slots: 2327 * {@code relations[]} now carries TABLE-kind RelationSources for 2328 * FROM-side sources (slice 80 left empty), and 2329 * {@code joinColumnRefs[]} now carries ON-clause column refs from 2330 * FROM-side JOINs. The target stays on 2331 * {@link StatementGraph#getTarget()}; a reference-identity filter 2332 * excludes the target's own TTable instance from {@code relations[]}. 2333 * 2334 * <p>Admitted shape: 2335 * <ul> 2336 * <li>Single-target UPDATE without FROM (slice 80) — 2337 * {@code relations[]} stays empty.</li> 2338 * <li>PG / BQ / SF / RS {@code UPDATE t SET ... FROM source} 2339 * (single FROM source).</li> 2340 * <li>PG / BQ {@code UPDATE t SET ... FROM s1, s2, ...} 2341 * (comma-FROM list).</li> 2342 * <li>PG / MSSQL {@code UPDATE t SET ... FROM s1 [INNER|LEFT|RIGHT|FULL OUTER] JOIN s2 ON ...} 2343 * — ON refs populate {@code joinColumnRefs[]}.</li> 2344 * <li>MSSQL {@code UPDATE t SET ... FROM t INNER JOIN s ON ...} 2345 * — target may appear in FROM; reference-identity filter 2346 * excludes the target's own TTable instance from 2347 * {@code relations[]}.</li> 2348 * <li>Explicit {@code CROSS JOIN} (no ON; semantically equivalent 2349 * to comma-FROM).</li> 2350 * <li>SET LHS is a {@link EExpressionType#simple_object_name_t} 2351 * column reference (qualified {@code t.x} or bare {@code x}). 2352 * Oracle tuple {@code SET (a, b) = (...)} (LHS = list_t) 2353 * rejects via 2354 * {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}.</li> 2355 * <li>SET RHS may be any expression NOT containing a scalar 2356 * subquery and NOT containing a window function. Subqueries 2357 * reject via 2358 * {@link DiagnosticCode#UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED}; 2359 * window functions reuse the existing 2360 * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} 2361 * routed through {@link #rejectWindowFunctionInScope}.</li> 2362 * <li>Optional WHERE clause — existing WHERE-side rejects 2363 * (subqueries, window functions) continue to apply via the 2364 * shared {@link #containsAnySubquery} + 2365 * {@code rejectWindowFunctionInScope} helpers used by SELECT 2366 * WHERE.</li> 2367 * </ul> 2368 * 2369 * <p>Slice 82 reject scope, with slice 83 admitting subquery FROM 2370 * sources (the slice-82 {@code UPDATE_FROM_SUBQUERY_NOT_SUPPORTED} 2371 * code stays declared but unreached — slice-71/72 2372 * retain-for-documentation precedent): 2373 * <ul> 2374 * <li>Subquery as a FROM source — slice 83 admits via the 2375 * SELECT-side {@code processDirectSubqueryTable} extractor, 2376 * publishing a SUBQUERY-kind {@link RelationSource} and a 2377 * cross-statement {@link LineageEdge} per subquery-bound 2378 * output source.</li> 2379 * <li>USING in any FROM-side join item — 2380 * {@link DiagnosticCode#UPDATE_FROM_JOIN_USING_NOT_SUPPORTED}.</li> 2381 * <li>NATURAL JOIN in any FROM-side join item — 2382 * {@link DiagnosticCode#UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED}.</li> 2383 * <li>Subquery in any ON condition — 2384 * {@link DiagnosticCode#UPDATE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED}.</li> 2385 * <li>Window function in any ON condition — reuses 2386 * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} via 2387 * {@link #rejectWindowFunctionInScope}.</li> 2388 * </ul> 2389 * 2390 * <p>Deferred (rejected at the outer level before any SET 2391 * processing): 2392 * <ul> 2393 * <li>Top-level WITH on UPDATE → 2394 * {@link DiagnosticCode#UPDATE_CTE_NOT_SUPPORTED}.</li> 2395 * <li>RETURNING projection (PG / Oracle) → 2396 * {@link DiagnosticCode#UPDATE_RETURNING_CLAUSE_NOT_SUPPORTED}.</li> 2397 * <li>OUTPUT projection (SQL Server) → 2398 * {@link DiagnosticCode#UPDATE_OUTPUT_CLAUSE_NOT_SUPPORTED}.</li> 2399 * <li>ORDER BY / LIMIT on UPDATE (MySQL / Couchbase) → 2400 * {@link DiagnosticCode#UPDATE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED}.</li> 2401 * <li>Empty / missing SET clause, Couchbase UNSET-only updates → 2402 * {@link DiagnosticCode#UPDATE_NO_SET_CLAUSE}.</li> 2403 * <li>Missing target table (defensive) → 2404 * {@link DiagnosticCode#UPDATE_TARGET_MISSING}.</li> 2405 * </ul> 2406 * 2407 * <p>Cross-statement {@link LineageEdge}s, one per SET assignment: 2408 * <pre> 2409 * from = LineageRef.tableColumn(targetQName, target_col_i) 2410 * to = LineageRef.statementOutput(0, output_name_i) 2411 * </pre> 2412 * Statement index 0 is the UPDATE statement itself — the synthetic 2413 * output IS the per-assignment "projection" that flows into the 2414 * target column. This is the slice-78 INSERT contract 2415 * (TABLE_COLUMN → STATEMENT_OUTPUT) with the source SELECT replaced 2416 * by the UPDATE's own per-assignment outputs; consumers read 2417 * {@code outputs[i].sources} to enumerate the RHS column refs that 2418 * feed the target column. 2419 */ 2420 public static SemanticProgram buildUpdate(TUpdateSqlStatement update, 2421 NameBindingProvider provider) { 2422 if (update == null) { 2423 throw new IllegalArgumentException("update must not be null"); 2424 } 2425 if (provider == null) { 2426 throw new IllegalArgumentException("provider must not be null"); 2427 } 2428 2429 // Slice 86 — defensive UsingScope reset at entry so a parent 2430 // scope cannot leak into UPDATE's binding decisions. Mirrors 2431 // SELECT-side buildSelectStatementImpl (slice 65). The UPDATE's 2432 // own UsingScope is installed after the FROM-join walker (step 2433 // 5.8 below). 2434 provider = provider.withUsingScope(UsingScope.EMPTY); 2435 2436 // 1) Slice 105 — admit top-level WITH on UPDATE. Walks the CTE 2437 // list left-to-right, building each body as a preceding 2438 // StatementGraph and producing cteNameToStatementIndex + 2439 // ctePublishedColumns for the FROM-as-CTE branch in 2440 // buildUpdateRelation below. Mirrors the slice-101 MERGE walker. 2441 // `stmts` / `lineage` allocated here (hoisted from the prior 2442 // slice-83 location) so the CTE walker can append. 2443 // UPDATE_CTE_NOT_SUPPORTED stays declared-but-unreached 2444 // (slice 71/72/82/86/95/96/97/98/99/100/101/102/103/104 precedent). 2445 List<StatementGraph> stmts = new ArrayList<>(); 2446 List<LineageEdge> lineage = new ArrayList<>(); 2447 Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>(); 2448 Map<String, Integer> cteNameToStatementIndex = buildUpdateCteList( 2449 update, provider, stmts, lineage, ctePublishedColumns); 2450 2451 // 2) Target table — defensive (parser usually rejects first). 2452 TTable targetTable = update.getTargetTable(); 2453 if (targetTable == null || targetTable.getTableName() == null) { 2454 throw new SemanticIRBuildException(Diagnostic.error( 2455 DiagnosticCode.UPDATE_TARGET_MISSING, 2456 "UPDATE statement has no resolvable target table", 2457 update)); 2458 } 2459 String targetQName = targetTable.getTableName().toString(); 2460 if (targetQName == null || targetQName.isEmpty()) { 2461 throw new SemanticIRBuildException(Diagnostic.error( 2462 DiagnosticCode.UPDATE_TARGET_MISSING, 2463 "UPDATE target table name is empty", 2464 update)); 2465 } 2466 2467 // 3) Slice 82 — FROM-side joined UPDATE is now admitted. The 2468 // slice-80 UPDATE_JOINED_NOT_SUPPORTED rejects (which fired on 2469 // update.tables.size() > 1 and update.getFromSourceJoin() != null) 2470 // are removed. The shape-specific rejects below (subquery in 2471 // FROM, USING, NATURAL, subquery in ON, window in ON) replace 2472 // them. UPDATE_JOINED_NOT_SUPPORTED remains declared but 2473 // unreached for API stability (the residual join-form-target 2474 // shape `UPDATE (a JOIN b) SET ...` does not parse in any 2475 // supported dialect — verified by AST probe). 2476 // 2477 // Reject ordering within buildUpdate: WITH / target-missing / 2478 // RETURNING / OUTPUT / ORDER BY / LIMIT / SET-empty all run 2479 // before the per-source FROM walk so a single rejection wins 2480 // on multi-violation shapes (e.g. `UPDATE t ... FROM s 2481 // RETURNING ...` rejects RETURNING before the FROM walk). 2482 2483 // 4) Slice 85 lifts the RETURNING / OUTPUT rejects — projections 2484 // are now admitted via {@link #buildReturningColumns} called after 2485 // SET / WHERE / FROM walks complete (the projection expressions 2486 // need the providerWithStar binding constructed in step 5.5). 2487 // The cheap statement-level OUTPUT_INTO reject fires here so a 2488 // multi-violation shape (OUTPUT … INTO target with RETURNING 2489 // content errors) routes to the cheaper structural code first. 2490 // {@code UPDATE_RETURNING_CLAUSE_NOT_SUPPORTED} and 2491 // {@code UPDATE_OUTPUT_CLAUSE_NOT_SUPPORTED} stay declared but 2492 // unreached (slice 71/72 retain-for-documentation precedent). 2493 if (update.getOutputClause() != null 2494 && update.getOutputClause().getIntoTable() != null) { 2495 throw new SemanticIRBuildException(Diagnostic.error( 2496 DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED, 2497 "UPDATE OUTPUT ... INTO <target> writes a second target; " 2498 + "slice 85 admits projection-only OUTPUT", 2499 update)); 2500 } 2501 if (update.getOrderByClause() != null 2502 || update.getLimitClause() != null) { 2503 throw new SemanticIRBuildException(Diagnostic.error( 2504 DiagnosticCode.UPDATE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED, 2505 "UPDATE with ORDER BY / LIMIT (MySQL / Couchbase) is " 2506 + "not supported by SemanticIRBuilder.buildUpdate; " 2507 + "slice 80 admits no row-pruning on UPDATE", 2508 update)); 2509 } 2510 2511 // 5) SET / UNSET — slice 80 requires a non-empty SET clause; a 2512 // Couchbase UNSET-only update (UnSetTerms populated, SET empty) 2513 // routes through the same code with discriminating message text. 2514 TResultColumnList sets = update.getResultColumnList(); 2515 boolean hasUnSet = update.getUnSetTerms() != null 2516 && update.getUnSetTerms().size() > 0; 2517 if (sets == null || sets.size() == 0) { 2518 String reason = hasUnSet 2519 ? "UPDATE has only an UNSET clause (Couchbase); slice 80 " 2520 + "requires a non-empty SET clause" 2521 : "UPDATE has no SET clause"; 2522 throw new SemanticIRBuildException(Diagnostic.error( 2523 DiagnosticCode.UPDATE_NO_SET_CLAUSE, 2524 reason, 2525 update)); 2526 } 2527 2528 // 5.5) Slice 83 — extract FROM subqueries as their own 2529 // StatementGraphs (after slice 105's CTE walker so the CTE 2530 // bodies precede any extracted FROM-subquery in the program). 2531 // 2532 // The extractor reuses the SELECT-side 2533 // {@link #processDirectSubqueryTable} verbatim — passing the 2534 // slice-105 cteNameToStatementIndex + ctePublishedColumns so a 2535 // nested SELECT inside a FROM-subquery can still resolve outer 2536 // CTE references through CTEScope (Resolver2 already binds CTE 2537 // refs in UPDATE correctly; the maps are passed for parity with 2538 // the SELECT/MERGE call sites). Inner predicate subqueries in 2539 // WHERE / JOIN ON / GROUP BY are caught by the slice-17 leak 2540 // guard ({@link #rejectSubqueriesInFromSubqueryBodyClauses}). 2541 // 2542 // No snapshot/rollback wrapper here (codex round-1 Q5 NICE): 2543 // buildUpdate owns fresh local stmts/lineage lists and 2544 // propagates exceptions to the caller — no observer can see 2545 // partial mutation. 2546 // 2547 // Slice 110 — decorate `provider` with `withCteContext` BEFORE 2548 // passing it to `extractUpdateFromSubqueries` so a nested SELECT 2549 // inside an extracted FROM-subquery body (e.g. 2550 // `UPDATE t SET col = sub.x FROM (SELECT id, x FROM cte) sub`) 2551 // routes CTE refs through `RelationKind.CTE`. Mirrors the 2552 // slice-106 DELETE-side `providerWithCte` pattern at line ~3205 2553 // (codex round-2 Q2 BLOCKING fix in slice 106). The slice-105 2554 // UPDATE site missed this decoration; slice 110 closes the gap 2555 // here since it also adds the same decoration on the WHERE-side 2556 // predicate-subquery extraction (line ~2370 below). 2557 NameBindingProvider providerWithCte = cteNameToStatementIndex.isEmpty() 2558 ? provider 2559 : provider.withCteContext(cteNameToStatementIndex.keySet()); 2560 Map<String, Integer> subqueryAliasToIndex = 2561 extractUpdateFromSubqueries(update, providerWithCte, stmts, lineage, 2562 cteNameToStatementIndex, ctePublishedColumns); 2563 // Build the in-scope map (subquery-alias → published column 2564 // names, plus CTE-bound alias → CTE published columns) so 2565 // `provider.withInScopeRelationColumns(map)` recognises 2566 // `sub.x` AND `cte.x` for the consuming UPDATE. Base-table 2567 // FROM-side relations don't need an entry; their column 2568 // resolution stays on the Resolver2 catalog path. 2569 Map<String, List<String>> updateInScope = buildUpdateInScopeMap( 2570 update, subqueryAliasToIndex, stmts, 2571 cteNameToStatementIndex, ctePublishedColumns); 2572 // Slice 110 — base `providerWithStar` on `providerWithCte` 2573 // (instead of raw `provider`) so SET RHS / WHERE / RETURNING 2574 // collectors and the slice-86 USING/NATURAL walker all see the 2575 // outer CTE context. Without this, a CTE-bound reference inside 2576 // a JOIN ON expression or a SET RHS scalar would bind as TABLE- 2577 // kind even when the CTE is declared at the UPDATE level. 2578 NameBindingProvider providerWithStar = updateInScope.isEmpty() 2579 ? providerWithCte 2580 : providerWithCte.withInScopeRelationColumns(updateInScope); 2581 2582 // 5.7) Slice 86 — relocated from slice-82 step 8. The FROM-side 2583 // join walker now runs BEFORE SET RHS / WHERE collection so the 2584 // slice-86 UsingScope (step 5.8 below) can be applied to those 2585 // collectors. Slice 65 SELECT-side ordering: buildRelations → 2586 // buildUsingScope → buildOutputColumns / buildFilter / etc. 2587 // The join walker uses `providerWithStar` (inScope only — no 2588 // UsingScope yet) because USING/NATURAL emit joinColumnRefs[] 2589 // directly via emitMergedJoinRefs without consulting UsingScope. 2590 // 2591 // The walker treats `update.getJoins()` as the authoritative 2592 // FROM-list representation: 2593 // - PG plain `FROM s` → joins=[{table=s, items=[]}] 2594 // - PG comma-FROM → joins=[{s1, items=[]}, {s2, items=[]}, ...] 2595 // - PG / MSSQL explicit JOIN → joins=[{driver, items=[item1,...]}] 2596 // - MSSQL target-in-FROM → joins=[{target_alias, items=[other,...]}] 2597 // 2598 // For each TJoin: the driver table goes through buildUpdateRelation 2599 // (which applies the slice-82 FROM-source rejects + identity 2600 // filter); each JoinItem is walked through buildUpdateJoinItem 2601 // which (slice 86) admits USING / NATURAL via slice-64/65/66 2602 // shared helpers in addition to ON / CROSS. 2603 List<RelationSource> relations = new ArrayList<>(); 2604 // Slice 82 codex round-1 Q2 BLOCKING — LinkedHashSet dedup spans 2605 // the whole FROM so a column appearing in two ON clauses 2606 // produces one entry. Slice 86 USING/NATURAL emit refs also flow 2607 // through this dedup. 2608 java.util.LinkedHashSet<ColumnRef> joinRefsSet = 2609 new java.util.LinkedHashSet<>(); 2610 for (TJoin join : update.getJoins()) { 2611 TTable leftTable = join.getTable(); 2612 buildUpdateRelation(leftTable, targetTable, relations, update, 2613 cteNameToStatementIndex); 2614 TJoinItemList items = join.getJoinItems(); 2615 if (items == null) continue; 2616 // Slice 86 — per top-level TJoin LeftOutputState seeded 2617 // with providerWithStar (codex round-1 B2 BLOCKING: inScope 2618 // installed so extracted FROM-subquery drivers' published 2619 // columns are visible to lookupRelationColumnNames for 2620 // NATURAL inference). Reset between top-level TJoins so 2621 // comma-FROM groups stay independent (matches SELECT-side 2622 // buildRelations slice-66 behavior). 2623 LeftOutputState leftState = new LeftOutputState(); 2624 seedLeftOutput(leftState, leftTable, providerWithStar); 2625 for (int i = 0; i < items.size(); i++) { 2626 TJoinItem item = items.getJoinItem(i); 2627 // Slice 86 — extended buildUpdateJoinItem signature 2628 // threads the join context (topJoin / items / itemIndex) 2629 // and LeftOutputState to the USING/NATURAL admit paths 2630 // so they can call the SELECT-side slice-64/65/66 2631 // shared helpers verbatim. 2632 // Slice 105 — threads cteNameToStatementIndex so the 2633 // join walker's per-item buildUpdateRelation call can 2634 // route objectname-typed CTE references to a SUBQUERY- 2635 // kind RelationSource pointing at the CTE statement. 2636 buildUpdateJoinItem(join, items, i, targetTable, 2637 providerWithStar, relations, joinRefsSet, leftState, 2638 update, cteNameToStatementIndex); 2639 } 2640 } 2641 List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet); 2642 2643 // 5.8) Slice 86 — install the UPDATE's own UsingScope on 2644 // providerWithStar AFTER the join walker so SET RHS / WHERE / 2645 // RETURNING refs see merged-key resolution (mirrors SELECT-side 2646 // buildSelectStatementImpl slice 65 ordering). The join walker 2647 // itself emits joinColumnRefs via direct emit-refs helpers, so 2648 // UsingScope is irrelevant to ON refs (matches SELECT-side 2649 // contract). 2650 UsingScope updateUsingScope = buildUpdateUsingScope(update, providerWithStar); 2651 if (!updateUsingScope.isEmpty()) { 2652 providerWithStar = providerWithStar.withUsingScope(updateUsingScope); 2653 } 2654 2655 // 5.9) Slice 115 — extract uncorrelated scalar subqueries on SET 2656 // RHS as their own <scalar_subquery_<idx>> StatementGraphs 2657 // appended to `stmts` BEFORE the UPDATE statement. Mirrors slice 2658 // 11 SELECT-side scalar projection extraction. A SET assignment 2659 // whose RHS is exactly a top-level subquery_t admits as a scalar 2660 // SET RHS: the body is built via buildSelectStatement (with the 2661 // slice-11 scalar-body invariants: allowFromSubqueries=false, 2662 // allowScalarProjectionSubqueries=false, allowWindowProjection= 2663 // false), inner predicate-leak guards run, and the resulting 2664 // ScalarInfo (extracted body index + inner output name) is stored 2665 // for the per-assignment loop and lineage emission below. 2666 // 2667 // Correlated scalar subqueries (whose inner refs would resolve to 2668 // an outer alias such as the UPDATE target or a FROM-side 2669 // relation) STILL reject via the slice-11 promoter called with 2670 // EnclosingScope.empty() — the inner ref's alias does not match 2671 // any local relation and no enclosing scope is provided, so 2672 // promoteCorrelatedRefsToOuterReference throws 2673 // SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS. Lifting UPDATE-side 2674 // correlation is a follow-up slice (slice 14 SELECT analogue 2675 // extended to UPDATE). 2676 Map<Integer, List<ScalarInfo>> setRhsScalarInfo = 2677 extractScalarSubqueriesFromUpdateSetRhs(update, providerWithStar, 2678 stmts, lineage, cteNameToStatementIndex, 2679 subqueryAliasToIndex); 2680 2681 // 6) Per-assignment processing. Each TResultColumn carries an 2682 // assignment_t TExpression whose leftOperand is the SET LHS 2683 // (target column reference) and whose rightOperand is the value 2684 // expression. We collect: 2685 // - target column spelling → TargetRelation.columns[i] 2686 // - synthetic output name → outputs[i].name (verbatim LHS 2687 // spelling, mirrors slice-78 2688 // INSERT column-list contract) 2689 // - RHS source column refs → outputs[i].sources 2690 List<OutputColumn> outputs = new ArrayList<>(); 2691 List<String> targetColumnNames = new ArrayList<>(); 2692 for (int i = 0; i < sets.size(); i++) { 2693 TResultColumn rc = sets.getResultColumn(i); 2694 TExpression assignment = (rc == null) ? null : rc.getExpr(); 2695 // Defensive: per TUpdateSqlStatement's javadoc each SET term 2696 // is an assignment_t. If the parser produced something else 2697 // (no AST shape observed in the tested corpora) we still 2698 // route through TUPLE_ASSIGNMENT_NOT_SUPPORTED so an 2699 // unexpected shape surfaces a stable diagnostic. 2700 if (assignment == null 2701 || assignment.getExpressionType() != EExpressionType.assignment_t) { 2702 throw new SemanticIRBuildException(Diagnostic.error( 2703 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 2704 "UPDATE SET assignment #" + (i + 1) + " is not a " 2705 + "simple column-value assignment_t; slice 80 " 2706 + "admits target_col = expr assignments only", 2707 update)); 2708 } 2709 TExpression lhs = assignment.getLeftOperand(); 2710 TExpression rhs = assignment.getRightOperand(); 2711 if (lhs == null || rhs == null) { 2712 throw new SemanticIRBuildException(Diagnostic.error( 2713 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 2714 "UPDATE SET assignment #" + (i + 1) 2715 + " is missing an operand", 2716 update)); 2717 } 2718 // Tuple LHS (Oracle) - SET (a, b) = (SELECT c1, c2 FROM ...) 2719 // surfaces as list_t. Reject before any subquery-on-RHS 2720 // walk so the diagnostic clearly identifies the tuple shape. 2721 if (lhs.getExpressionType() == EExpressionType.list_t) { 2722 throw new SemanticIRBuildException(Diagnostic.error( 2723 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 2724 "UPDATE SET tuple assignment '(a, b) = ...' is not " 2725 + "supported by SemanticIRBuilder.buildUpdate; " 2726 + "slice 80 admits target_col = expr only", 2727 update)); 2728 } 2729 if (lhs.getExpressionType() != EExpressionType.simple_object_name_t) { 2730 throw new SemanticIRBuildException(Diagnostic.error( 2731 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 2732 "UPDATE SET assignment #" + (i + 1) + " LHS is " 2733 + "expressionType=" + lhs.getExpressionType() 2734 + "; slice 80 admits simple column references only", 2735 update)); 2736 } 2737 TObjectName targetCol = lhs.getObjectOperand(); 2738 if (targetCol == null) { 2739 throw new SemanticIRBuildException(Diagnostic.error( 2740 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 2741 "UPDATE SET assignment #" + (i + 1) + " LHS has no " 2742 + "TObjectName operand", 2743 update)); 2744 } 2745 String colSpelling = targetCol.toString(); 2746 2747 // Slice 115 — top-level subquery_t SET RHS already extracted 2748 // in step 5.9 as a <scalar_subquery_<idx>> StatementGraph. 2749 // OutputColumn carries empty sources; slice-115/119 cross-stmt 2750 // edge below wires the consumer to the extracted body. 2751 if (rhs.getExpressionType() == EExpressionType.subquery_t) { 2752 targetColumnNames.add(colSpelling); 2753 outputs.add(new OutputColumn(colSpelling, 2754 /*derived=*/ true, /*aggregate=*/ false, 2755 Collections.<ColumnRef>emptyList())); 2756 continue; 2757 } 2758 // Slice 119 — mixed-expression scalar subquery path: subquery 2759 // nested inside a compound RHS (e.g. `SET col = (SELECT...) + 1`). 2760 // The scalar(s) were already extracted in step 5.9; collect only 2761 // the non-subquery column refs by skipping extracted subq nodes. 2762 if (containsAnySubqueryExpression(rhs)) { 2763 List<TExpression> subqRootsList = 2764 collectNestedSubqueryExpressions(rhs); 2765 if (subqRootsList.isEmpty()) { 2766 // P2-1 codex-review: containsAnySubqueryExpression 2767 // returned true (via getSubQuery() != null) but no 2768 // subquery_t nodes were found by acceptChildren 2769 // traversal (e.g. EXISTS or non-scalar predicate 2770 // subquery). Preserve the original reject so lineage 2771 // is not silently dropped. 2772 throw new SemanticIRBuildException(Diagnostic.error( 2773 DiagnosticCode.UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED, 2774 "UPDATE SET assignment #" + (i + 1) + " right-hand " 2775 + "side contains a non-scalar subquery " 2776 + "(slice 119 admits only scalar subquery_t " 2777 + "inside compound expressions)", 2778 update)); 2779 } 2780 Set<TExpression> subqRoots = Collections.newSetFromMap( 2781 new IdentityHashMap<TExpression, Boolean>()); 2782 subqRoots.addAll(subqRootsList); 2783 // P2-2 codex-review: window functions in the non-subquery 2784 // part of a compound RHS are still illegal. Use the 2785 // skipping variant so scalar body contents are not scanned 2786 // (window functions inside a scalar SELECT are legitimate). 2787 rejectWindowFunctionInScopeSkipping(rhs, "UPDATE SET RHS", 2788 subqRoots); 2789 List<ColumnRef> sources = collectColumnRefsSkipping( 2790 rhs, providerWithStar, subqRoots); 2791 targetColumnNames.add(colSpelling); 2792 outputs.add(new OutputColumn(colSpelling, 2793 /*derived=*/ true, /*aggregate=*/ false, sources)); 2794 continue; 2795 } 2796 // Window function on RHS — reuse the existing scope reject. 2797 rejectWindowFunctionInScope(rhs, "UPDATE SET RHS"); 2798 2799 // Collect physical column refs from the RHS expression. 2800 List<ColumnRef> sources = collectColumnRefs(rhs, providerWithStar); 2801 boolean derived = 2802 rhs.getExpressionType() != EExpressionType.simple_object_name_t; 2803 targetColumnNames.add(colSpelling); 2804 outputs.add(new OutputColumn(colSpelling, 2805 derived, /*aggregate=*/ false, sources)); 2806 } 2807 2808 // 7) WHERE refs — slice 110 lifts the slice-80 blanket subquery 2809 // reject by routing uncorrelated predicate-subquery wrappers 2810 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 2811 // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction pipeline 2812 // refactored to take a PredicateClauseContext. Each extracted 2813 // wrapper lands as its own <predicate_subquery_<i>> StatementGraph 2814 // BEFORE the UPDATE statement (so updateIdx already accounts for 2815 // them via stmts.size() below). Remaining non-subquery refs flow 2816 // into filterColumnRefs via collectColumnRefsSkipping. SET-RHS 2817 // subqueries still reject (slice-110 scope excludes SET RHS). 2818 // Window functions in non-subquery subtrees still reject via 2819 // the existing rejectWindowFunctionInScopeSkipping helper. 2820 List<ColumnRef> filterRefs; 2821 TWhereClause where = update.getWhereClause(); 2822 if (where == null || where.getCondition() == null) { 2823 filterRefs = Collections.<ColumnRef>emptyList(); 2824 } else { 2825 Set<TExpression> extractedWhereRoots = 2826 Collections.<TExpression>emptySet(); 2827 if (containsAnySubquery(where)) { 2828 // Slice 110 — `providerWithStar` already carries 2829 // `withCteContext(cteNameToStatementIndex.keySet())` 2830 // (applied at the providerWithCte → providerWithStar 2831 // chain above) so the predicate body's inner SELECT's 2832 // `FROM cte` refs route through `RelationKind.CTE`. 2833 // Without that, emitLineageForStatement would lose the 2834 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge to the CTE 2835 // body. 2836 extractedWhereRoots = 2837 extractUncorrelatedPredicateSubqueriesFromClause( 2838 where.getCondition(), providerWithStar, 2839 stmts, lineage, cteNameToStatementIndex, 2840 PredicateClauseContext.UPDATE_WHERE); 2841 rejectAnyRemainingSubqueriesFromClause( 2842 where.getCondition(), extractedWhereRoots, 2843 PredicateClauseContext.UPDATE_WHERE); 2844 } 2845 rejectWindowFunctionInScopeSkipping(where, "WHERE clause", 2846 extractedWhereRoots); 2847 // Slice 83 — providerWithStar so WHERE refs against 2848 // extracted subquery aliases bind correctly. Slice 110 — 2849 // skip extracted predicate-subquery subtrees so inner refs 2850 // do not leak into outer filterColumnRefs (mirrors the 2851 // slice-23 JOIN-ON ref collector). 2852 filterRefs = collectColumnRefsSkipping(where, providerWithStar, 2853 extractedWhereRoots); 2854 } 2855 2856 // (Step 8 of slice 82 was relocated to step 5.7 by slice 86 so 2857 // the slice-86 UsingScope built at step 5.8 can apply to the 2858 // SET RHS / WHERE collectors above. The walker logic itself is 2859 // unchanged from slice 82's contract — only its position moved.) 2860 2861 RelationBinding targetBinding = new RelationBinding( 2862 RelationKind.TABLE, targetQName); 2863 TargetRelation target = new TargetRelation(targetBinding, targetColumnNames); 2864 2865 // Slice 85 — build RETURNING / OUTPUT projection columns BEFORE 2866 // the StatementGraph so the new returningColumns slot can be 2867 // populated. updateIdx is computed first (deterministic — the 2868 // DML's position is stmts.size() at the moment of the 2869 // upcoming stmts.add(updateStmt)). LineageEdges are emitted 2870 // here via the shared helper (consumer ← producer). 2871 int updateIdx = stmts.size(); 2872 // UPDATE target alias = effective alias from the target's 2873 // TTable (slice-82 / slice-83 use the same convention for 2874 // FROM-side reference identity). FROM-side relations is the 2875 // walked `relations[]` list already built above. 2876 String updateTargetAlias = effectiveAliasOf(targetTable); 2877 if (updateTargetAlias == null || updateTargetAlias.isEmpty()) { 2878 updateTargetAlias = targetQName; 2879 } 2880 // Slice 123 — hoist the slice-105 combined map (slice-83 2881 // subqueryAliasToIndex + slice-105 CTE-as-relation alias→cteIdx) 2882 // ABOVE buildReturningColumns so the slice-85 walker can promote a 2883 // RETURNING/OUTPUT ref to a SUBQUERY-kind FROM-side relation column 2884 // to a cross-stmt STATEMENT_OUTPUT → STATEMENT_OUTPUT edge. The same 2885 // map instance is reused below for emitUpdateSubquerySourceEdges 2886 // (slice 105), so the hoist is behaviour-preserving for that call. 2887 Map<String, Integer> combinedAliasToSubIdx = 2888 buildUpdateCombinedAliasToSubIdx(update, 2889 subqueryAliasToIndex, cteNameToStatementIndex); 2890 List<OutputColumn> returningColumns = buildReturningColumns( 2891 update.getReturningClause(), 2892 update.getOutputClause(), 2893 "UPDATE", 2894 targetQName, 2895 updateTargetAlias, 2896 /*targetTable=*/ targetTable, 2897 relations, 2898 combinedAliasToSubIdx, 2899 providerWithStar, 2900 updateIdx, 2901 lineage, 2902 update); 2903 2904 StatementGraph updateStmt = new StatementGraph( 2905 /*name=*/ null, 2906 "UPDATE", 2907 relations, 2908 outputs, 2909 returningColumns, 2910 filterRefs, 2911 /*joinColumnRefs=*/ joinRefs, 2912 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2913 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2914 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2915 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2916 /*distinct=*/ false, 2917 /*setOperator=*/ null, 2918 /*rowLimit=*/ null, 2919 target); 2920 2921 // Slice 83 — updateIdx is dynamic: stmts already contains any 2922 // extracted FROM-subquery statements from step 5.5. The 2923 // slice-78/80 contract `target.col_i ← STATEMENT_OUTPUT(idx, 2924 // out_i)` is preserved by indexing the UPDATE's own statement 2925 // position rather than the slice-80 hardcoded 0. 2926 stmts.add(updateStmt); 2927 2928 // Slice 78/80 cross-stmt edges — one per SET assignment: 2929 // target.col_i ← STATEMENT_OUTPUT(updateIdx, out_i) 2930 for (int i = 0; i < outputs.size(); i++) { 2931 String tgtName = targetColumnNames.get(i); 2932 String outName = outputs.get(i).getName(); 2933 if (tgtName == null || tgtName.isEmpty() 2934 || outName == null || outName.isEmpty()) { 2935 // Defensive — both should be the same verbatim spelling. 2936 continue; 2937 } 2938 lineage.add(new LineageEdge( 2939 LineageRef.tableColumn(targetQName, tgtName), 2940 LineageRef.statementOutput(updateIdx, outName))); 2941 } 2942 2943 // Slice 115 — for each SET assignment whose RHS was extracted as 2944 // a top-level scalar subquery in step 5.9, emit the cross-stmt 2945 // wire edge: 2946 // STATEMENT_OUTPUT(updateIdx, outName) → 2947 // STATEMENT_OUTPUT(scalarIdx, innerOutputName) 2948 // mirrors the SELECT-side slice-11 emission in 2949 // emitLineageForStatement (line ~7440). Runs AFTER the slice-78/ 2950 // 80 target edge loop so the target edge is always emitted 2951 // first; the scalar-bound assignment's OutputColumn.sources is 2952 // empty by construction so the slice-83 2953 // emitUpdateSubquerySourceEdges call below is a no-op for these 2954 // outputs. 2955 // Slice 115/119 — one edge per extracted scalar per SET assignment: 2956 // STATEMENT_OUTPUT(updateIdx, outName) → STATEMENT_OUTPUT(scalarIdx, innerOutputName) 2957 if (!setRhsScalarInfo.isEmpty()) { 2958 for (Map.Entry<Integer, List<ScalarInfo>> e : setRhsScalarInfo.entrySet()) { 2959 int ord = e.getKey(); 2960 if (ord < 0 || ord >= outputs.size()) continue; 2961 String outName = outputs.get(ord).getName(); 2962 if (outName == null || outName.isEmpty()) continue; 2963 for (ScalarInfo info : e.getValue()) { 2964 lineage.add(new LineageEdge( 2965 LineageRef.statementOutput(updateIdx, outName), 2966 LineageRef.statementOutput(info.statementIndex, 2967 info.innerOutputName))); 2968 } 2969 } 2970 } 2971 2972 // Slice 83 — emit STATEMENT_OUTPUT(updateIdx, out_i) → 2973 // STATEMENT_OUTPUT(subIdx, col) edges for output sources that 2974 // bind to a SUBQUERY-kind relation in this UPDATE's 2975 // relations[]. Base-table FROM-side sources stay as 2976 // outputs[i].sources only — preserves the slice-82 contract 2977 // that joined UPDATE without subqueries emits exactly ONE 2978 // cross-stmt edge per SET assignment (the target edge above). 2979 // Slice 105 — combine the slice-83 subqueryAliasToIndex with 2980 // the slice-105 CTE-as-relation alias→cteIdx entries so a SET 2981 // RHS reference to a CTE column (which lives on a SUBQUERY- 2982 // kind relation per slice 105) still produces a cross-stmt 2983 // STATEMENT_OUTPUT edge to the CTE body. Without the merge the 2984 // visible OutputColumn.sources stays correct but lineage[] 2985 // silently drops the canonical edge (codex round-2 Q5). 2986 // Slice 123 — combinedAliasToSubIdx is now built above (hoisted 2987 // for buildReturningColumns); reuse it here. 2988 if (!combinedAliasToSubIdx.isEmpty()) { 2989 emitUpdateSubquerySourceEdges(updateStmt, updateIdx, 2990 combinedAliasToSubIdx, lineage); 2991 } 2992 2993 return new SemanticProgram(stmts, lineage); 2994 } 2995 2996 /** 2997 * Slice 83 — emit STATEMENT_OUTPUT → STATEMENT_OUTPUT edges from 2998 * each UPDATE output to its subquery-bound source column. Walks 2999 * {@code outputs[i].sources} and, for any source whose 3000 * {@code relationAlias} matches a SUBQUERY-kind entry in the 3001 * statement's {@link RelationSource} list, emits an edge to the 3002 * corresponding extracted subquery's STATEMENT_OUTPUT position. 3003 * 3004 * <p>Why not call {@link #emitLineageForStatement}? The SELECT-path 3005 * helper emits edges for ALL output sources (TABLE-kind → 3006 * TABLE_COLUMN; CTE/SUBQUERY-kind → STATEMENT_OUTPUT). For UPDATE 3007 * the slice-78/80 contract is intentionally narrower: the only 3008 * cross-stmt edge per SET assignment is the target edge. Adding 3009 * STATEMENT_OUTPUT → TABLE_COLUMN edges for base-table FROM-side 3010 * sources would change the cross-stmt edge count contract that 3011 * slice-82 tests assert ({@code edges.size() == numAssignments}). 3012 * The slice-83 emitter therefore is SUBQUERY-only — base-table 3013 * FROM-side refs continue to surface via {@code outputs[i].sources} 3014 * but emit no extra LineageEdge. 3015 */ 3016 private static void emitUpdateSubquerySourceEdges( 3017 StatementGraph updateStmt, 3018 int updateIdx, 3019 Map<String, Integer> subqueryAliasToIndex, 3020 List<LineageEdge> lineage) { 3021 // Codex slice-83 diff-review Q1 BLOCKING — both the map and 3022 // the lookup must use the same casing policy so SQL like 3023 // `... FROM (SELECT ...) sub WHERE ... SUB.x = …` (resolver-2 3024 // may surface either case in `src.getRelationAlias()` depending 3025 // on dialect and quoting) still finds the SUBQUERY-kind entry. 3026 // The slice-83 inScope map and `subqueryAliasToIndex` are both 3027 // keyed lowercase; do the same here. (SELECT-side 3028 // `emitLineageForStatement` uses case-sensitive equality — 3029 // pre-existing limitation; a separate refactor.) 3030 Map<String, RelationSource> aliasToRelation = new HashMap<>(); 3031 for (RelationSource rs : updateStmt.getRelations()) { 3032 String key = rs.getAlias(); 3033 // Skip null / empty aliases — empty-string would produce a 3034 // vacuous "" key and could spuriously match other empty-alias 3035 // relations (codex round-2 Q2 advisory). 3036 if (key == null || key.isEmpty()) continue; 3037 aliasToRelation.put(key.toLowerCase(Locale.ROOT), rs); 3038 } 3039 for (OutputColumn out : updateStmt.getOutputColumns()) { 3040 String outName = out.getName(); 3041 if (outName == null || outName.isEmpty()) continue; 3042 for (ColumnRef src : out.getSources()) { 3043 String srcAlias = src.getRelationAlias(); 3044 if (srcAlias == null || srcAlias.isEmpty()) continue; 3045 RelationSource rel = aliasToRelation.get( 3046 srcAlias.toLowerCase(Locale.ROOT)); 3047 if (rel == null) continue; 3048 if (rel.getBinding() == null 3049 || rel.getBinding().getKind() != RelationKind.SUBQUERY) { 3050 continue; 3051 } 3052 Integer subIdx = subqueryAliasToIndex.get( 3053 rel.getAlias().toLowerCase(Locale.ROOT)); 3054 if (subIdx == null) continue; 3055 lineage.add(new LineageEdge( 3056 LineageRef.statementOutput(updateIdx, outName), 3057 LineageRef.statementOutput(subIdx, src.getColumnName()))); 3058 } 3059 } 3060 } 3061 3062 /** 3063 * Slice 115 — walk the UPDATE's SET clause and extract each 3064 * assignment whose RHS is exactly a top-level 3065 * {@link EExpressionType#subquery_t} as its own 3066 * {@code <scalar_subquery_<idx>>} {@link StatementGraph} appended to 3067 * {@code stmts} BEFORE the UPDATE. Mirrors the SELECT-side 3068 * {@link #extractScalarSubqueriesAsStatementsInternal} slice-11 3069 * pipeline but iterates SET assignments instead of result columns. 3070 * Returns {@code assignmentOrdinal → ScalarInfo} so {@link #buildUpdate} 3071 * can wire the cross-stmt edge for each extracted body. 3072 * 3073 * <p>Scope rejects (mirroring slice 11): 3074 * <ul> 3075 * <li>Multi-column inner SELECT — 3076 * {@link DiagnosticCode#SCALAR_SUBQUERY_COLUMN_COUNT}.</li> 3077 * <li>Inner projection has no alias and no column name — 3078 * {@link DiagnosticCode#SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED}.</li> 3079 * <li>Subqueries in scalar body's WHERE / JOIN ON / GROUP BY — 3080 * slice-11 {@link #rejectSubqueriesInScalarBodyClauses}.</li> 3081 * <li>FROM-subqueries inside scalar body — 3082 * {@code allowFromSubqueries=false} (slice-15 invariant).</li> 3083 * <li>Nested scalar projections inside scalar body — 3084 * {@code allowScalarProjectionSubqueries=false} (set-op-branch 3085 * precedent; slice 115 initial scope).</li> 3086 * <li>Window functions in scalar body — 3087 * {@code allowWindowProjection=false} (slice-11 precedent).</li> 3088 * <li>Correlated scalar subqueries (inner refs to outer aliases) — 3089 * {@link #promoteCorrelatedRefsToOuterReference} called with 3090 * {@link EnclosingScope#empty()} throws 3091 * {@link DiagnosticCode#SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS}. 3092 * Lifting UPDATE-side correlation is a follow-up slice 3093 * (slice 14 SELECT analogue extended to UPDATE).</li> 3094 * </ul> 3095 * 3096 * <p>Snapshot/rollback wrapper around the loop body mirrors 3097 * {@link #extractScalarSubqueriesAsStatements} so a partial 3098 * extraction (e.g. second of two scalar SET RHS fails on shape 3099 * validation) truncates {@code stmts}/{@code lineage} back to the 3100 * pre-call boundary. 3101 * 3102 * <p>Assignments whose RHS is not a top-level {@code subquery_t} are 3103 * silently skipped here; they fall through to the per-assignment 3104 * loop's existing slice-80 / slice-115 mixed-expression reject path. 3105 */ 3106 private static Map<Integer, List<ScalarInfo>> extractScalarSubqueriesFromUpdateSetRhs( 3107 TUpdateSqlStatement update, 3108 NameBindingProvider provider, 3109 List<StatementGraph> stmts, 3110 List<LineageEdge> lineage, 3111 Map<String, Integer> cteNameToStatementIndex, 3112 Map<String, Integer> subqueryAliasToIndex) { 3113 TResultColumnList sets = update.getResultColumnList(); 3114 if (sets == null || sets.size() == 0) { 3115 return Collections.<Integer, List<ScalarInfo>>emptyMap(); 3116 } 3117 // Fast pre-scan: any SET RHS that contains a subquery (top-level 3118 // subquery_t or nested inside a compound expression)? Avoids the 3119 // snapshot/rollback wrapper overhead when none are present. 3120 // Slice 115 handled top-level subquery_t only; slice 119 extends 3121 // to mixed-expression RHS (e.g. `SET col = (SELECT...) + 1`). 3122 // Tuple-LHS assignments (Oracle SET (a, b) = ...) are 3123 // intentionally skipped so the per-assignment loop's 3124 // UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED reject (slice 80 3125 // contract) wins. 3126 boolean anySubquery = false; 3127 for (int i = 0; i < sets.size(); i++) { 3128 TResultColumn rc = sets.getResultColumn(i); 3129 if (rc == null || rc.getExpr() == null) continue; 3130 TExpression assignment = rc.getExpr(); 3131 if (assignment.getExpressionType() != EExpressionType.assignment_t) { 3132 continue; 3133 } 3134 TExpression lhs = assignment.getLeftOperand(); 3135 if (lhs == null 3136 || lhs.getExpressionType() == EExpressionType.list_t) { 3137 continue; 3138 } 3139 TExpression rhs = assignment.getRightOperand(); 3140 if (rhs != null && containsAnySubqueryExpression(rhs)) { 3141 anySubquery = true; 3142 break; 3143 } 3144 } 3145 if (!anySubquery) { 3146 return Collections.<Integer, List<ScalarInfo>>emptyMap(); 3147 } 3148 int stmtsSnapshot = stmts.size(); 3149 int lineageSnapshot = lineage.size(); 3150 try { 3151 return extractScalarSubqueriesFromUpdateSetRhsInternal( 3152 update, provider, stmts, lineage, 3153 cteNameToStatementIndex, subqueryAliasToIndex, sets); 3154 } catch (RuntimeException ex) { 3155 while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1); 3156 while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1); 3157 throw ex; 3158 } 3159 } 3160 3161 /** 3162 * Internal body of {@link #extractScalarSubqueriesFromUpdateSetRhs}; 3163 * wrapped with snapshot/rollback by the public entry point. Do not 3164 * call directly from non-wrapper sites. 3165 */ 3166 private static Map<Integer, List<ScalarInfo>> extractScalarSubqueriesFromUpdateSetRhsInternal( 3167 TUpdateSqlStatement update, 3168 NameBindingProvider provider, 3169 List<StatementGraph> stmts, 3170 List<LineageEdge> lineage, 3171 Map<String, Integer> cteNameToStatementIndex, 3172 Map<String, Integer> subqueryAliasToIndex, 3173 TResultColumnList sets) { 3174 Map<Integer, List<ScalarInfo>> ordinalToInfo = new HashMap<>(); 3175 for (int i = 0; i < sets.size(); i++) { 3176 TResultColumn rc = sets.getResultColumn(i); 3177 if (rc == null || rc.getExpr() == null) continue; 3178 TExpression assignment = rc.getExpr(); 3179 if (assignment.getExpressionType() != EExpressionType.assignment_t) { 3180 continue; 3181 } 3182 TExpression lhs = assignment.getLeftOperand(); 3183 // Skip tuple-LHS assignments — the per-assignment loop's 3184 // slice-80 UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED reject 3185 // should win for these (e.g. Oracle `SET (a, b) = (SELECT 3186 // c1, c2 FROM ...)`). Without this skip, the inner SELECT's 3187 // multi-column projection would surface as 3188 // SCALAR_SUBQUERY_COLUMN_COUNT here instead. 3189 if (lhs == null 3190 || lhs.getExpressionType() == EExpressionType.list_t) { 3191 continue; 3192 } 3193 TExpression rhs = assignment.getRightOperand(); 3194 if (rhs == null || !containsAnySubqueryExpression(rhs)) { 3195 continue; // no subquery in this RHS — handled by per-assignment loop 3196 } 3197 // "outer alias" used in diagnostic messages — the SET LHS 3198 // column spelling. Mirrors the slice-11 `outerAlias` role. 3199 String outerAlias = (lhs.getExpressionType() == EExpressionType.simple_object_name_t 3200 && lhs.getObjectOperand() != null) 3201 ? lhs.getObjectOperand().toString() 3202 : ("SET assignment #" + (i + 1)); 3203 3204 // Determine which subquery TExpression nodes to extract. 3205 // Slice 115 path: RHS is exactly a top-level subquery_t → 3206 // single-element list. 3207 // Slice 119 path: RHS is a compound expression (arithmetic, 3208 // CASE, function) containing one or more subquery_t nodes 3209 // at any depth → list in traversal order. 3210 List<TExpression> subqExprs; 3211 if (rhs.getExpressionType() == EExpressionType.subquery_t) { 3212 subqExprs = Collections.singletonList(rhs); 3213 } else { 3214 subqExprs = collectNestedSubqueryExpressions(rhs); 3215 } 3216 if (subqExprs.isEmpty()) continue; // defensive (containsAnySubqueryExpression true but none found) 3217 3218 // Build the UPDATE-side enclosing scope once per assignment 3219 // (used by each per-scalar correlation promotion below). 3220 EnclosingScope innerEnclosing = buildUpdateEnclosingScope(update, 3221 cteNameToStatementIndex, subqueryAliasToIndex, 3222 /*parent=*/ null); 3223 3224 List<ScalarInfo> infos = new ArrayList<>(); 3225 for (TExpression subqExpr : subqExprs) { 3226 TSelectSqlStatement inner = subqExpr.getSubQuery(); 3227 if (inner == null) { 3228 throw new SemanticIRBuildException( 3229 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_NO_INNER_SELECT, 3230 "scalar subquery on UPDATE SET RHS for '" + outerAlias 3231 + "' has no inner SELECT", rc)); 3232 } 3233 // Pre-recursion validation (matches slice 11 ordering): 3234 // inspect inner column count and naming before recursive 3235 // build so the diagnostic is scalar-specific. 3236 TResultColumnList innerRcl = inner.getResultColumnList(); 3237 if (innerRcl == null || innerRcl.size() == 0) { 3238 throw new SemanticIRBuildException( 3239 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT, 3240 "scalar subquery on UPDATE SET RHS for '" + outerAlias 3241 + "' must project exactly one column, got 0", rc)); 3242 } 3243 if (innerRcl.size() != 1) { 3244 throw new SemanticIRBuildException( 3245 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT, 3246 "scalar subquery on UPDATE SET RHS for '" + outerAlias 3247 + "' must project exactly one column, got " 3248 + innerRcl.size(), rc)); 3249 } 3250 TResultColumn innerCol = innerRcl.getResultColumn(0); 3251 String innerAlias = innerCol.getColumnAlias(); 3252 String innerColName = innerCol.getColumnNameOnly(); 3253 boolean innerHasName = 3254 (innerAlias != null && !innerAlias.isEmpty()) 3255 || (innerColName != null && !innerColName.isEmpty()); 3256 if (!innerHasName && !isConstantExpression(innerCol.getExpr())) { 3257 throw new SemanticIRBuildException( 3258 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED, 3259 "scalar subquery on UPDATE SET RHS for '" + outerAlias 3260 + "' inner projection has no alias and no column " 3261 + "name; add an explicit alias inside the subquery", 3262 rc)); 3263 } 3264 // Predicate-leak guard: scalar body's WHERE / JOIN ON / 3265 // GROUP BY must not contain subqueries. Slice 121: the 3266 // UPDATE SET-RHS scalar path keeps the WHERE reject 3267 // (allowWherePredicateSubqueries=false) — only the 3268 // projection scalar path lifts WHERE predicate subqueries. 3269 rejectSubqueriesInScalarBodyClauses(inner, outerAlias, 3270 /*allowWherePredicateSubqueries=*/ false); 3271 3272 // Slice 117 / 119 — decorate provider with the inner 3273 // SELECT's local FROM aliases for tolerant outer-binding. 3274 Set<String> innerLocalAliases = precomputeInnerLocalAliases(inner); 3275 NameBindingProvider tolerantProvider = innerLocalAliases.isEmpty() 3276 ? provider 3277 : provider.withTolerantOuterBinding(innerLocalAliases); 3278 3279 String scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">"; 3280 StatementGraph innerStmt = buildSelectStatement(inner, tolerantProvider, 3281 scalarName, 3282 /*hasOuterCteListAlreadyProcessed=*/ false, 3283 /*allowFromSubqueries=*/ false, 3284 /*allowScalarProjectionSubqueries=*/ false, 3285 /*allowWindowProjection=*/ false); 3286 innerStmt = promoteCorrelatedRefsToOuterReference( 3287 innerStmt, outerAlias, innerEnclosing); 3288 int idx = stmts.size(); 3289 stmts.add(innerStmt); 3290 String innerOutName = effectiveOutputName(innerCol); 3291 infos.add(new ScalarInfo(idx, innerOutName)); 3292 emitLineageForStatement(innerStmt, idx, lineage, 3293 cteNameToStatementIndex, 3294 innerEnclosing.flattenSubqueryAliasToIndex(), 3295 Collections.<Integer, ScalarInfo>emptyMap()); 3296 } 3297 ordinalToInfo.put(i, infos); 3298 } 3299 return ordinalToInfo; 3300 } 3301 3302 /** 3303 * Slice 82 — process one FROM-side source table for joined 3304 * {@link #buildUpdate}. Applies the slice-82 reject contract for 3305 * non-table FROM sources, then appends a TABLE-kind 3306 * {@link RelationSource} unless the table is the target 3307 * (reference-identity filter — clean IR semantics: relations[] 3308 * models read-side sources only). 3309 */ 3310 private static void buildUpdateRelation(TTable t, TTable targetTable, 3311 List<RelationSource> relations, 3312 TUpdateSqlStatement update, 3313 Map<String, Integer> cteNameToStatementIndex) { 3314 if (t == null) { 3315 return; // defensive — parser should never produce a null table 3316 } 3317 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 3318 // Slice 83 — admit FROM-side subqueries. The inner SELECT 3319 // has already been extracted as its own StatementGraph by 3320 // {@link #extractUpdateFromSubqueries} (step 5.5 of 3321 // buildUpdate). Here we publish the SUBQUERY-kind 3322 // {@link RelationSource} so {@code outputs[i].sources} 3323 // resolved via the inScope-enhanced provider can route to 3324 // it. Alias and qualifiedName both use 3325 // {@code effectiveAliasOf(t)} — matching slice-14 / slice-58 3326 // SUBQUERY-kind convention used by SELECT. 3327 // 3328 // {@code UPDATE_FROM_SUBQUERY_NOT_SUPPORTED} stays declared 3329 // but unreached (slice-71/72 retain-for-documentation 3330 // precedent — keeps the public DiagnosticCode enum stable 3331 // for consumers that route by code). 3332 String subAlias = effectiveAliasOf(t); 3333 if (subAlias != null && !subAlias.isEmpty()) { 3334 relations.add(new RelationSource(subAlias, 3335 new RelationBinding(RelationKind.SUBQUERY, subAlias))); 3336 } 3337 return; 3338 } 3339 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 3340 // Defensive: TTable wrapping a TJoin. Not reached by any 3341 // observed parser path on the supported dialects (slice-82 3342 // probe set), but gets its own DiagnosticCode so consumers 3343 // can route this distinct shape without parsing message 3344 // text — per slice-80's message-text-discrimination 3345 // contract (codex round-1 Q4 BLOCKING). 3346 throw new SemanticIRBuildException(Diagnostic.error( 3347 DiagnosticCode.UPDATE_FROM_NESTED_JOIN_NOT_SUPPORTED, 3348 "UPDATE FROM source is a nested join wrapper; " 3349 + "slice 82 admits simple table FROM sources only", 3350 update)); 3351 } 3352 // Reference-identity filter: target's own TTable instance is 3353 // excluded from relations[]. In MSSQL `UPDATE T2 ... FROM Table2 T2 …`, 3354 // tables[0] / joins[0].getTable() IS the same instance as 3355 // update.getTargetTable(); excluding it keeps the IR clean 3356 // (relations[] models reads, target models writes). The 3357 // catalog-miss WARN walker's target-first ordering handles the 3358 // cross-instance-same-name MSSQL self-join edge case where two 3359 // distinct TTable instances share the same qualified name. 3360 if (t == targetTable) { 3361 return; 3362 } 3363 TObjectName tName = t.getTableName(); 3364 if (tName == null) { 3365 return; // defensive 3366 } 3367 // Slice 105 — FROM-side CTE detection. When the FROM-side table 3368 // is an objectname-typed reference whose bare name matches a 3369 // declared CTE in this UPDATE's outer WITH clause, emit a 3370 // SUBQUERY-kind RelationSource pointing at the CTE statement 3371 // (mirrors MERGE USING-as-CTE in slice 101). The slice-77 3372 // catalog-miss WARN walker filters to RelationKind.TABLE so 3373 // CTE-bound relations are naturally skipped, even when the 3374 // catalog also declares the same name (codex round-2 Q4 3375 // confirmed YES). The cross-stmt lineage edge from 3376 // STATEMENT_OUTPUT(updateIdx,col) → STATEMENT_OUTPUT(cteIdx,col) 3377 // is emitted by emitUpdateSubquerySourceEdges using the 3378 // combined alias→subIdx map. 3379 if (cteNameToStatementIndex != null 3380 && !cteNameToStatementIndex.isEmpty()) { 3381 String bareName = tName.toString(); 3382 if (bareName != null && !bareName.isEmpty()) { 3383 String bareNameLower = bareName.toLowerCase(Locale.ROOT); 3384 if (cteNameToStatementIndex.containsKey(bareNameLower)) { 3385 String cteAlias = effectiveAliasOf(t); 3386 if (cteAlias == null || cteAlias.isEmpty()) { 3387 cteAlias = bareName; 3388 } 3389 relations.add(new RelationSource(cteAlias, 3390 new RelationBinding(RelationKind.SUBQUERY, cteAlias))); 3391 return; 3392 } 3393 } 3394 } 3395 // effectiveAliasOf returns the SQL-written alias if present, 3396 // else the table name. RelationSource requires a non-empty 3397 // alias; this matches the slice-58/59 buildRelation contract. 3398 relations.add(new RelationSource(effectiveAliasOf(t), 3399 new RelationBinding(RelationKind.TABLE, tName.toString()))); 3400 } 3401 3402 /** 3403 * Slice 82 (extended by slice 86) — process one {@link TJoinItem} 3404 * for joined {@link #buildUpdate}. Routes USING / NATURAL JoinItems 3405 * through the SELECT-side slice-64/65/66 shared helpers 3406 * ({@link #populateUsingJoinRefs} / {@link #emitMergedJoinRefs} / 3407 * {@link #naturalSharedKeys}) so the UPDATE join walker emits the 3408 * same {@code joinColumnRefs[]} shape as a SELECT body. ON / CROSS 3409 * JoinItems retain the slice-82 reject contract (subquery in ON, 3410 * window in ON) and ref-collection path. 3411 * 3412 * <p>Slice 86 signature extension: the join context 3413 * ({@code topJoin}, {@code items}, {@code itemIndex}) and the 3414 * per-top-level-TJoin {@link LeftOutputState} are required by the 3415 * shared helpers — the prior-relations chain for emit-refs and the 3416 * accumulated left row type for NATURAL inference. 3417 * 3418 * <p>USING / NATURAL shape conflicts (USING+ON, NATURAL+USING, 3419 * NATURAL+ON) reuse the existing slice-64/66 codes 3420 * ({@link DiagnosticCode#JOIN_WITH_BOTH_ON_AND_USING}, 3421 * {@link DiagnosticCode#NATURAL_WITH_USING}, 3422 * {@link DiagnosticCode#NATURAL_WITH_ON}) rather than introducing 3423 * UPDATE-specific codes — matching slice 86's "reuse SELECT-side 3424 * machinery verbatim" architecture. 3425 * 3426 * <p>Slice 82's lifted reject codes 3427 * ({@link DiagnosticCode#UPDATE_FROM_JOIN_USING_NOT_SUPPORTED} and 3428 * {@link DiagnosticCode#UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED}) 3429 * stay declared-but-unreached for API stability — slice 71/72/82 3430 * retain-for-documentation precedent. 3431 */ 3432 private static void buildUpdateJoinItem(TJoin topJoin, 3433 TJoinItemList items, 3434 int itemIndex, 3435 TTable targetTable, 3436 NameBindingProvider provider, 3437 List<RelationSource> relations, 3438 java.util.LinkedHashSet<ColumnRef> joinRefs, 3439 LeftOutputState leftState, 3440 TUpdateSqlStatement update, 3441 Map<String, Integer> cteNameToStatementIndex) { 3442 if (items == null) return; 3443 TJoinItem item = items.getJoinItem(itemIndex); 3444 if (item == null) return; 3445 3446 TObjectNameList usingCols = item.getUsingColumns(); 3447 boolean hasUsing = usingCols != null && usingCols.size() > 0; 3448 boolean isNatural = isNaturalJoinType(item.getJoinType()); 3449 boolean hasOn = item.getOnCondition() != null; 3450 3451 // Slice 86 — USING/NATURAL admit paths. Shape conflicts use the 3452 // slice-64/66 SELECT-side codes verbatim; the UPDATE-specific 3453 // lifted codes (UPDATE_FROM_JOIN_USING_NOT_SUPPORTED / 3454 // UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED) are no longer thrown 3455 // (declared-but-unreached for API stability). 3456 if (isNatural && hasUsing) { 3457 throw new SemanticIRBuildException(Diagnostic.error( 3458 DiagnosticCode.NATURAL_WITH_USING, 3459 "NATURAL JOIN must not carry a USING clause; choose " 3460 + "either NATURAL or USING, not both", item)); 3461 } 3462 if (isNatural && hasOn) { 3463 throw new SemanticIRBuildException(Diagnostic.error( 3464 DiagnosticCode.NATURAL_WITH_ON, 3465 "NATURAL JOIN must not carry an ON condition; rewrite " 3466 + "as JOIN ... ON, or drop the NATURAL keyword", item)); 3467 } 3468 if (hasUsing && hasOn) { 3469 throw new SemanticIRBuildException(Diagnostic.error( 3470 DiagnosticCode.JOIN_WITH_BOTH_ON_AND_USING, 3471 "JOIN cannot carry both ON and USING; choose one", item)); 3472 } 3473 3474 if (hasUsing) { 3475 // Right-side table first: applies slice-82 source-shape 3476 // rejects + identity filter exactly as the ON path. 3477 buildUpdateRelation(item.getTable(), targetTable, relations, update, 3478 cteNameToStatementIndex); 3479 // Slice 64 emit-refs: left-then-right per key, walking 3480 // priorRelations = topJoin.getTable() + items[0..itemIndex-1]. 3481 List<ColumnRef> usingRefs = new ArrayList<>(); 3482 populateUsingJoinRefs(topJoin, items, itemIndex, item.getTable(), 3483 usingCols, provider, usingRefs); 3484 joinRefs.addAll(usingRefs); 3485 // Slice 66 LeftOutputState update: merge right's columns 3486 // into accumulated state so a subsequent NATURAL JoinItem 3487 // sees the row type (matches SELECT-side 3488 // {@code buildRelations}). 3489 List<String> usingKeyNames = new ArrayList<>(usingCols.size()); 3490 for (int k = 0; k < usingCols.size(); k++) { 3491 TObjectName key = usingCols.getObjectName(k); 3492 if (key == null) continue; 3493 String keyName = key.getColumnNameOnly(); 3494 if (keyName != null && !keyName.isEmpty()) { 3495 usingKeyNames.add(keyName); 3496 } 3497 } 3498 mergeRightIntoLeftOutput(leftState, item.getTable(), provider, 3499 usingKeyNames); 3500 return; 3501 } 3502 3503 if (isNatural) { 3504 // Right-side table first; identity filter excludes target. 3505 buildUpdateRelation(item.getTable(), targetTable, relations, update, 3506 cteNameToStatementIndex); 3507 // Slice 66 catalog-required NATURAL inference. When either 3508 // side lacks resolvable column metadata the shared-column list 3509 // cannot be computed; DEGRADE (mirrors the SELECT-side 3510 // buildRelations path) — skip the merged join refs, record a 3511 // non-fatal NATURAL_CATALOG_REQUIRED warning, and append the 3512 // right's columns to the running state for consistency. 3513 NaturalKeyResult r = naturalSharedKeys(leftState, item.getTable(), provider); 3514 if (r.kind != NaturalKeyResult.Kind.SUCCESS) { 3515 recordNaturalDegradeWarning(r, item); 3516 appendRightToLeftOutput(leftState, item.getTable(), provider); 3517 return; 3518 } 3519 List<ColumnRef> naturalRefs = new ArrayList<>(); 3520 emitMergedJoinRefs(JoinKind.NATURAL, r.keys, topJoin, items, 3521 itemIndex, item.getTable(), provider, naturalRefs); 3522 joinRefs.addAll(naturalRefs); 3523 // Update LeftOutputState with the right's columns (merging 3524 // shared keys into existing slots, appending non-shared 3525 // columns as new entries). 3526 mergeRightIntoLeftOutput(leftState, item.getTable(), provider, r.keys); 3527 return; 3528 } 3529 3530 // ON / CROSS branch — slice-82 contract preserved. 3531 buildUpdateRelation(item.getTable(), targetTable, relations, update, 3532 cteNameToStatementIndex); 3533 // Slice 86 — append right to LeftOutputState so subsequent 3534 // NATURAL JoinItems in the same top-level TJoin observe the 3535 // accumulated row type. CROSS / ON contribute non-merged 3536 // columns to state (matches SELECT-side appendRightToLeftOutput). 3537 appendRightToLeftOutput(leftState, item.getTable(), provider); 3538 TExpression onCond = item.getOnCondition(); 3539 if (onCond == null) return; // CROSS JOIN: no ON. 3540 if (containsAnySubqueryExpression(onCond)) { 3541 throw new SemanticIRBuildException(Diagnostic.error( 3542 DiagnosticCode.UPDATE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED, 3543 "UPDATE FROM JOIN ON condition contains a subquery; " 3544 + "slice 82 admits scalar predicates only", 3545 item)); 3546 } 3547 rejectWindowFunctionInScope(onCond, "UPDATE FROM JOIN ON"); 3548 joinRefs.addAll(collectColumnRefs(onCond, provider)); 3549 } 3550 3551 /** 3552 * Slice 83 — extract every FROM-side subquery in 3553 * {@code update.getJoins()} as its own {@link StatementGraph} 3554 * appended to {@code stmts} before the UPDATE itself. Walks both 3555 * the driver TTable of each TJoin AND each JoinItem's right table. 3556 * Returns an alias → stmts-index map so the consuming UPDATE can 3557 * (a) build its in-scope column map via 3558 * {@link #buildUpdateInScopeMap}, and (b) emit 3559 * STATEMENT_OUTPUT → STATEMENT_OUTPUT edges via 3560 * {@link #emitUpdateSubquerySourceEdges}. 3561 * 3562 * <p>Reuses the SELECT-side {@link #processDirectSubqueryTable} 3563 * verbatim, passing empty CTE maps because slice 80 already 3564 * rejects top-level WITH on UPDATE 3565 * ({@link DiagnosticCode#UPDATE_CTE_NOT_SUPPORTED}). The inner 3566 * SELECT's own FROM-subqueries are handled recursively by the 3567 * helper. Inner predicate subqueries in WHERE / JOIN ON / 3568 * GROUP BY are caught by the slice-17 leak guard 3569 * ({@link #rejectSubqueriesInFromSubqueryBodyClauses}). Inner 3570 * top-level WITH is rejected by 3571 * {@code buildSelectStatement(hasOuterCteListAlreadyProcessed=false)}. 3572 * Inner scalar projection subqueries are rejected by 3573 * {@code buildSelectStatement(allowScalarProjectionSubqueries=false)}. 3574 * 3575 * <p>No mutation-guard wrapper here: buildUpdate owns fresh local 3576 * lists and exceptions propagate to the caller (codex round-1 Q5 3577 * NICE). 3578 */ 3579 private static Map<String, Integer> extractUpdateFromSubqueries( 3580 TUpdateSqlStatement update, 3581 NameBindingProvider provider, 3582 List<StatementGraph> stmts, 3583 List<LineageEdge> lineage, 3584 Map<String, Integer> cteNameToStatementIndex, 3585 Map<String, List<String>> ctePublishedColumns) { 3586 Map<String, Integer> aliasToIndex = new HashMap<>(); 3587 TJoinList joins = update.getJoins(); 3588 if (joins == null) return aliasToIndex; 3589 // Slice 105 — forward the outer-WITH CTE maps so a nested SELECT 3590 // inside an extracted FROM-subquery body can resolve outer-WITH 3591 // CTE references. Resolver2 wires CTEScope already; the maps are 3592 // forwarded for parity with the SELECT / MERGE call sites. 3593 Map<String, Integer> cteMap = cteNameToStatementIndex == null 3594 ? Collections.<String, Integer>emptyMap() 3595 : cteNameToStatementIndex; 3596 Map<String, List<String>> ctePublished = ctePublishedColumns == null 3597 ? Collections.<String, List<String>>emptyMap() 3598 : ctePublishedColumns; 3599 for (TJoin join : joins) { 3600 // Driver table — may be a subquery (PG / Snowflake / BQ / 3601 // Redshift `UPDATE t SET … FROM (SELECT …) sub` shape). 3602 processDirectSubqueryTable(join.getTable(), provider, 3603 stmts, lineage, cteMap, ctePublished, aliasToIndex); 3604 TJoinItemList items = join.getJoinItems(); 3605 if (items == null) continue; 3606 for (int i = 0; i < items.size(); i++) { 3607 TJoinItem item = items.getJoinItem(i); 3608 if (item == null) continue; 3609 // Right-side table of a JoinItem — may be a subquery 3610 // (MSSQL / PG `UPDATE t SET … FROM x JOIN (SELECT …) 3611 // sub ON …` shape). 3612 processDirectSubqueryTable(item.getTable(), provider, 3613 stmts, lineage, cteMap, ctePublished, aliasToIndex); 3614 } 3615 } 3616 return aliasToIndex; 3617 } 3618 3619 /** 3620 * Slice 83 — build an effective-alias-keyed in-scope map publishing 3621 * each extracted FROM-subquery's output column names. The consuming 3622 * UPDATE wraps its provider via 3623 * {@code provider.withInScopeRelationColumns(map)} so {@code sub.x} 3624 * resolves to the subquery's published column rather than failing 3625 * resolution against the catalog. 3626 * 3627 * <p>Base-table FROM-side relations do not need an entry: their 3628 * column resolution stays on the Resolver2 catalog path. Slice 60's 3629 * SELECT-side {@link #buildEffectiveAliasInScopeMap} also skips 3630 * base-table relations. 3631 * 3632 * <p>Slice 105 — when an outer WITH clause declares a CTE and a 3633 * FROM-side relation references that CTE by its bare name, publish 3634 * the CTE's column names against the FROM-side effective alias so 3635 * SET RHS / WHERE / ON refs against the CTE alias bind correctly. 3636 */ 3637 private static Map<String, List<String>> buildUpdateInScopeMap( 3638 TUpdateSqlStatement update, 3639 Map<String, Integer> subqueryAliasToIndex, 3640 List<StatementGraph> stmts, 3641 Map<String, Integer> cteNameToStatementIndex, 3642 Map<String, List<String>> ctePublishedColumns) { 3643 Map<String, List<String>> result = new HashMap<>(); 3644 boolean haveSubq = subqueryAliasToIndex != null 3645 && !subqueryAliasToIndex.isEmpty(); 3646 boolean haveCte = cteNameToStatementIndex != null 3647 && !cteNameToStatementIndex.isEmpty(); 3648 if (!haveSubq && !haveCte) { 3649 return result; 3650 } 3651 TJoinList joins = update.getJoins(); 3652 if (joins == null) return result; 3653 for (TJoin join : joins) { 3654 addUpdateRelationToInScopeMap(join.getTable(), 3655 subqueryAliasToIndex, stmts, result, 3656 cteNameToStatementIndex, ctePublishedColumns); 3657 TJoinItemList items = join.getJoinItems(); 3658 if (items == null) continue; 3659 for (int i = 0; i < items.size(); i++) { 3660 TJoinItem item = items.getJoinItem(i); 3661 if (item == null) continue; 3662 addUpdateRelationToInScopeMap(item.getTable(), 3663 subqueryAliasToIndex, stmts, result, 3664 cteNameToStatementIndex, ctePublishedColumns); 3665 } 3666 } 3667 return result; 3668 } 3669 3670 private static void addUpdateRelationToInScopeMap(TTable t, 3671 Map<String, Integer> subqueryAliasToIndex, 3672 List<StatementGraph> stmts, 3673 Map<String, List<String>> result, 3674 Map<String, Integer> cteNameToStatementIndex, 3675 Map<String, List<String>> ctePublishedColumns) { 3676 if (t == null) return; 3677 // Slice 105 — CTE-as-FROM-relation in-scope publication. When 3678 // the FROM-side table is an objectname-typed reference whose 3679 // bare name matches a declared outer CTE, publish the CTE's 3680 // own column names against the FROM-side effective alias so 3681 // SET RHS / WHERE refs against the CTE alias bind correctly. 3682 if (cteNameToStatementIndex != null 3683 && !cteNameToStatementIndex.isEmpty() 3684 && ctePublishedColumns != null 3685 && t.getTableType() 3686 == gudusoft.gsqlparser.ETableSource.objectname) { 3687 TObjectName tName = t.getTableName(); 3688 if (tName != null) { 3689 String bare = tName.toString(); 3690 if (bare != null && !bare.isEmpty()) { 3691 String bareLower = bare.toLowerCase(Locale.ROOT); 3692 if (cteNameToStatementIndex.containsKey(bareLower)) { 3693 String aliasKey = effectiveAliasLowerCaseOrNull(t); 3694 if (aliasKey == null) aliasKey = bareLower; 3695 List<String> cols = ctePublishedColumns.get(bareLower); 3696 if (cols != null) { 3697 result.put(aliasKey, cols); 3698 } 3699 return; 3700 } 3701 } 3702 } 3703 } 3704 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) { 3705 return; 3706 } 3707 if (subqueryAliasToIndex == null) { 3708 return; 3709 } 3710 String key = effectiveAliasLowerCaseOrNull(t); 3711 if (key == null) return; 3712 Integer idx = subqueryAliasToIndex.get(key); 3713 if (idx == null) return; 3714 result.put(key, outputColumnNames(stmts.get(idx))); 3715 } 3716 3717 /** 3718 * Slice 81 / slice 84 — admit single-target and joined 3719 * {@code DELETE} statements and produce a {@code "DELETE"}-kind 3720 * {@link StatementGraph} (§8.1.4 row D11 follow-up via slice 84's 3721 * joined-DELETE candidate (a)). 3722 * 3723 * <p>Structurally mirrors slice-80 + slice-82 + slice-83 3724 * {@link #buildUpdate} but with no SET clause and an empty 3725 * {@code outputColumns} list — DELETE has no projection of its 3726 * own (RETURNING / OUTPUT projections are deferred to a later 3727 * slice). The target relation is exposed via the slice-78 3728 * {@link TargetRelation} slot; its {@code columns} list is 3729 * intentionally empty because DELETE removes whole rows rather 3730 * than writing specific columns. 3731 * 3732 * <p>WHERE-side reads still surface on 3733 * {@link StatementGraph#getFilterColumnRefs()} so downstream 3734 * governance can audit "what predicates does this DELETE depend 3735 * on". Cross-statement {@link LineageEdge}s are NOT emitted (the 3736 * slice-78 / slice-80 {@code target.col_i ← STATEMENT_OUTPUT(…)} 3737 * contract has no DELETE analogue: there is no source 3738 * projection). 3739 * 3740 * <p>Slice 84 admit scope (lifts slice-81's blanket joined-DELETE 3741 * reject for the common PG / MSSQL FROM-side shapes; mirrors 3742 * slice 82 + slice 83 onto DELETE): 3743 * <ul> 3744 * <li>PG / Snowflake / BQ / Redshift {@code DELETE FROM t USING 3745 * source_list [WHERE]} — {@code source_list} = simple table, 3746 * comma-separated tables, or chain of explicit JOIN ... ON 3747 * (driver is taken from {@code referenceJoins}).</li> 3748 * <li>MSSQL {@code DELETE FROM t FROM driver_table [JOIN other 3749 * ON ...] [WHERE]} — the target may itself appear in the 3750 * FROM-FROM clause as a different TTable instance.</li> 3751 * <li>MSSQL {@code DELETE alias FROM t alias INNER JOIN ... ON …} 3752 * — the alias-form DELETE where target is matched by alias.</li> 3753 * <li>CROSS JOIN inside USING — no ON; semantically equivalent 3754 * to comma-FROM.</li> 3755 * <li>{@code DELETE FROM t USING (SELECT …) s [WHERE]} — 3756 * FROM-subquery as a USING source; mirrors slice-83 UPDATE 3757 * FROM-subquery extraction.</li> 3758 * </ul> 3759 * 3760 * <p>Slice 84 reject scope (preserves slice-81 reject coverage 3761 * for shapes that still need a refinement slice): 3762 * <ul> 3763 * <li>{@link DiagnosticCode#DELETE_JOINED_NOT_SUPPORTED} — any 3764 * shape with {@code delete.getJoins().size() > 0}: MySQL 3765 * multi-target {@code DELETE T1, T2 FROM …}, MySQL 3766 * self-reference {@code DELETE T1 FROM T1}, MySQL 3767 * multi-USING {@code DELETE FROM T1 USING T1, T2}. 3768 * Candidates (c) and (d) in §8.1.4 lift these later.</li> 3769 * <li>{@link DiagnosticCode#DELETE_FROM_JOIN_USING_NOT_SUPPORTED} 3770 * — {@code USING(col1, col2)} on a FROM-side join item; 3771 * mirror of slice-82 {@code UPDATE_FROM_JOIN_USING_*}.</li> 3772 * <li>{@link DiagnosticCode#DELETE_FROM_JOIN_NATURAL_NOT_SUPPORTED} 3773 * — {@code NATURAL JOIN} on a FROM-side join item.</li> 3774 * <li>{@link DiagnosticCode#DELETE_FROM_NESTED_JOIN_NOT_SUPPORTED} 3775 * — defensive: TTable wrapping a TJoin in the FROM source 3776 * (not reached by any observed parser path on supported 3777 * dialects, but kept distinct from the subquery code per 3778 * slice-80 message-text-discrimination contract).</li> 3779 * <li>{@link DiagnosticCode#DELETE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED} 3780 * — subquery in a JOIN ON predicate.</li> 3781 * </ul> 3782 * 3783 * <p>Other rejected shapes (slice-81 baseline preserved): 3784 * {@link DiagnosticCode#DELETE_CTE_NOT_SUPPORTED}, 3785 * {@link DiagnosticCode#DELETE_TARGET_MISSING}, 3786 * {@link DiagnosticCode#DELETE_RETURNING_CLAUSE_NOT_SUPPORTED}, 3787 * {@link DiagnosticCode#DELETE_OUTPUT_CLAUSE_NOT_SUPPORTED}, 3788 * {@link DiagnosticCode#DELETE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED}. 3789 * 3790 * <p>WHERE-side subqueries reuse the existing 3791 * {@link DiagnosticCode#WHERE_HAS_SUBQUERY_NOT_SUPPORTED} (no 3792 * new DELETE-side code) — consistent with slice-80 UPDATE WHERE 3793 * handling. Window functions in WHERE / ON reuse 3794 * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK}. 3795 * 3796 * <p>IR shape (slice 84 changes from slice 81): 3797 * <ul> 3798 * <li>{@code relations[]} — now carries TABLE-kind 3799 * {@link RelationSource}s for joined-DELETE FROM-side 3800 * sources, plus SUBQUERY-kind sources for {@code USING 3801 * (SELECT …)} extractions. Slice 81 left it empty. 3802 * Reference-identity filter excludes the target's own 3803 * TTable instance; the slice-82 walker-order swap (target 3804 * before relations[] in 3805 * {@link gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer#collectCatalogMissWarnings}) 3806 * handles same-qualified-name target+driver collisions 3807 * (e.g. MSSQL {@code DELETE FROM t FROM t spqh JOIN sp}).</li> 3808 * <li>{@code joinColumnRefs[]} — now carries ON-clause refs 3809 * collected from each JoinItem under a per-DELETE 3810 * {@link java.util.LinkedHashSet} for cross-JoinItem dedup 3811 * (slice-82 codex round-1 Q2 BLOCKING precedent).</li> 3812 * <li>The DELETE itself emits NO new cross-stmt 3813 * {@link LineageEdge}s — empty {@code outputColumns[]} 3814 * means there is no STATEMENT_OUTPUT(deleteIdx, …) anchor 3815 * for slice-83's SUBQUERY-kind emitter. Extracted 3816 * FROM-subqueries DO emit their own internal lineage edges 3817 * via {@code emitLineageForStatement} inside 3818 * {@link #processDirectSubqueryTable}.</li> 3819 * </ul> 3820 */ 3821 public static SemanticProgram buildDelete(TDeleteSqlStatement delete, 3822 NameBindingProvider provider) { 3823 if (delete == null) { 3824 throw new IllegalArgumentException("delete must not be null"); 3825 } 3826 if (provider == null) { 3827 throw new IllegalArgumentException("provider must not be null"); 3828 } 3829 3830 // 1) Slice 106 — admit top-level WITH on DELETE. Walks the CTE 3831 // list left-to-right, building each body as a preceding 3832 // StatementGraph and producing cteNameToStatementIndex + 3833 // ctePublishedColumns for the FROM-as-CTE branch in 3834 // buildDeleteRelation below. Mirrors the slice-105 UPDATE 3835 // walker. `stmts` / `lineage` allocated here (hoisted from the 3836 // prior slice-84 location) so the CTE walker can append. 3837 // DELETE_CTE_NOT_SUPPORTED stays declared-but-unreached 3838 // (slice 71/72/82/86/95/96/97/98/99/100/101/102/103/104/105 3839 // precedent). 3840 List<StatementGraph> stmts = new ArrayList<>(); 3841 List<LineageEdge> lineage = new ArrayList<>(); 3842 Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>(); 3843 Map<String, Integer> cteNameToStatementIndex = buildDeleteCteList( 3844 delete, provider, stmts, lineage, ctePublishedColumns); 3845 3846 // 2) Target table — defensive (parser usually rejects first). 3847 TTable targetTable = delete.getTargetTable(); 3848 if (targetTable == null || targetTable.getTableName() == null) { 3849 throw new SemanticIRBuildException(Diagnostic.error( 3850 DiagnosticCode.DELETE_TARGET_MISSING, 3851 "DELETE statement has no resolvable target table", 3852 delete)); 3853 } 3854 String targetQName = targetTable.getTableName().toString(); 3855 if (targetQName == null || targetQName.isEmpty()) { 3856 throw new SemanticIRBuildException(Diagnostic.error( 3857 DiagnosticCode.DELETE_TARGET_MISSING, 3858 "DELETE target table name is empty", 3859 delete)); 3860 } 3861 3862 // 3) Slice 84 / Slice 92 — joined-DELETE discriminator. 3863 // Parser-probe-verified shapes: 3864 // - Admit (slice 84): PG `DELETE FROM t USING j` / MSSQL 3865 // `DELETE FROM t FROM t spqh JOIN sp` / MSSQL `DELETE spqh 3866 // FROM t spqh JOIN sp` / Snowflake DELETE-USING — all have 3867 // joins.size=0 and referenceJoins.size > 0. 3868 // - Admit (slice 92): MySQL `DELETE T1 FROM T1 [WHERE pred]` 3869 // self-reference — joins.size=1, refJoins.size=1, and all 3870 // three names (joins[0].table, refJoins[0].table, target) 3871 // agree case-insensitively. Semantically identical to 3872 // `DELETE FROM T1 [WHERE pred]`; produces the same IR shape. 3873 // - Reject (slice-81 code preserved for non-self-ref): 3874 // MySQL `DELETE T1, T2 FROM …` (joins.size=2) and 3875 // MySQL `DELETE FROM T1 USING T1, T2` (refJoins.size=2). 3876 // 3877 // Slice 84 drops the slice-81 `tables.size > 1` and 3878 // `fromSourceJoin != null` blanket rejects (both fire for 3879 // admit shapes; probe confirms no parser-reachable shape 3880 // needs them when joins.size == 0). Candidate (d) in §8.1.4 3881 // (Hive multi-insert) remains open for a future slice. 3882 boolean mysqlSelfRef = false; 3883 if (delete.joins != null && delete.joins.size() > 0) { 3884 // Slice 92 — admit MySQL self-reference form: 3885 // DELETE T1 FROM T1 [WHERE …] 3886 // The check requires all three names to match (codex 3887 // plan-review rounds Q1+Q5 BLOCKING fix: checking only 3888 // joins[0] is insufficient — DELETE T1 FROM T2 would 3889 // incorrectly admit because joins[0]=T1=target but 3890 // refJoins[0]=T2≠target). 3891 mysqlSelfRef = isMysqlSelfReferenceDelete(delete, targetQName); 3892 if (!mysqlSelfRef) { 3893 throw new SemanticIRBuildException(Diagnostic.error( 3894 DiagnosticCode.DELETE_JOINED_NOT_SUPPORTED, 3895 "DELETE with multi-target / multi-USING clause is " 3896 + "not supported by SemanticIRBuilder.buildDelete; " 3897 + "slice 84 admits PG `DELETE FROM t USING j` and " 3898 + "MSSQL `DELETE FROM t FROM t JOIN s` shapes; " 3899 + "slice 92 admits MySQL " 3900 + "`DELETE T1 FROM T1 [WHERE …]` self-reference", 3901 delete)); 3902 } 3903 } 3904 3905 // 4) Slice 85 lifts the RETURNING / OUTPUT rejects on DELETE. 3906 // The cheap statement-level OUTPUT_INTO reject fires here so a 3907 // multi-violation shape routes to the cheaper structural code 3908 // first. {@code DELETE_RETURNING_CLAUSE_NOT_SUPPORTED} and 3909 // {@code DELETE_OUTPUT_CLAUSE_NOT_SUPPORTED} stay declared but 3910 // unreached (slice 71/72 retain-for-documentation precedent). 3911 if (delete.getOutputClause() != null 3912 && delete.getOutputClause().getIntoTable() != null) { 3913 throw new SemanticIRBuildException(Diagnostic.error( 3914 DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED, 3915 "DELETE OUTPUT ... INTO <target> writes a second target; " 3916 + "slice 85 admits projection-only OUTPUT", 3917 delete)); 3918 } 3919 if (delete.getOrderByClause() != null 3920 || delete.getLimitClause() != null) { 3921 throw new SemanticIRBuildException(Diagnostic.error( 3922 DiagnosticCode.DELETE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED, 3923 "DELETE with ORDER BY / LIMIT (MySQL) is not " 3924 + "supported by SemanticIRBuilder.buildDelete; " 3925 + "slice 81 admits no row-pruning on DELETE", 3926 delete)); 3927 } 3928 3929 // 4.7) Slice 84 — extract FROM-subqueries from referenceJoins 3930 // (after slice 106's CTE walker so the CTE bodies precede any 3931 // extracted FROM-subquery in the program). Mirrors slice-83 3932 // UPDATE FROM-subquery extraction (which uses 3933 // update.getJoins()); here we use delete.getReferenceJoins(). 3934 // buildDelete owns fresh local stmts/lineage lists (allocated 3935 // in step 1 above) so exceptions propagate cleanly to the 3936 // caller — no snapshot/rollback wrapper. 3937 // 3938 // Slice 106 — forward cteNameToStatementIndex + 3939 // ctePublishedColumns so a nested SELECT inside an extracted 3940 // FROM-subquery body can resolve outer-WITH CTE references 3941 // (Resolver2 wires CTEScope; the maps are forwarded for parity 3942 // with the SELECT / MERGE / UPDATE call sites and so the 3943 // §N test for `USING (SELECT … FROM cte) sub` produces the 3944 // expected cross-stmt edge to the CTE body). 3945 // 3946 // Decorate the provider with the outer-WITH CTE name set so 3947 // the SELECT-side {@link #buildRelation} routes references to 3948 // those names through {@link RelationKind#CTE} (rather than 3949 // TABLE), which in turn makes 3950 // {@link #emitLineageForStatement} emit the cross-stmt 3951 // {@code STATEMENT_OUTPUT(subIdx,col) → 3952 // STATEMENT_OUTPUT(cteIdx,col)} edge required by §N. This 3953 // mirrors the SELECT-side outer-WITH walker 3954 // (see {@link #build}'s {@code outerProvider}). 3955 NameBindingProvider providerWithCte = cteNameToStatementIndex.isEmpty() 3956 ? provider 3957 : provider.withCteContext(cteNameToStatementIndex.keySet()); 3958 Map<String, Integer> subqueryAliasToIndex = 3959 extractDeleteFromSubqueries(delete, providerWithCte, stmts, lineage, 3960 cteNameToStatementIndex, ctePublishedColumns); 3961 Map<String, List<String>> deleteInScope = buildDeleteInScopeMap( 3962 delete, subqueryAliasToIndex, stmts, 3963 cteNameToStatementIndex, ctePublishedColumns); 3964 NameBindingProvider providerWithStar = deleteInScope.isEmpty() 3965 ? providerWithCte 3966 : providerWithCte.withInScopeRelationColumns(deleteInScope); 3967 3968 // 5) WHERE refs — slice 111 lifts the slice-81 blanket subquery 3969 // reject by routing uncorrelated predicate-subquery wrappers 3970 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 3971 // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction pipeline 3972 // refactored by slice 110 to take a PredicateClauseContext. The 3973 // new DELETE_WHERE constant carries clause-specific 3974 // DiagnosticCode IDs (8 new DELETE_WHERE_* codes) and a 3975 // "DELETE WHERE clause" label. Each extracted wrapper lands as 3976 // its own <predicate_subquery_<i>> StatementGraph BEFORE the 3977 // DELETE (deleteIdx below = stmts.size() naturally accounts for 3978 // them — slice-83 dynamic-index pattern, slice 110 UPDATE 3979 // precedent). Remaining non-subquery refs flow into 3980 // filterColumnRefs via collectColumnRefsSkipping (or 3981 // collectColumnRefsTolerant on the slice-92 MySQL self-ref 3982 // path). Window functions in non-subquery subtrees still reject 3983 // via rejectWindowFunctionInScopeSkipping. Slice 84 — 3984 // providerWithStar so WHERE refs against extracted subquery 3985 // aliases bind correctly (slice-83 precedent). 3986 // 3987 // Slice 106 — providerWithCte (then providerWithStar on top of 3988 // it) already decorates the provider with the outer-WITH CTE 3989 // name set so the predicate body's inner SELECT routes 3990 // `FROM cte` refs through RelationKind.CTE and 3991 // emitLineageForStatement emits the 3992 // STATEMENT_OUTPUT(subIdx,col) → STATEMENT_OUTPUT(cteIdx,col) 3993 // cross-stmt edge (slice 110 UPDATE precedent). 3994 List<ColumnRef> filterRefs; 3995 TWhereClause where = delete.getWhereClause(); 3996 if (where == null || where.getCondition() == null) { 3997 filterRefs = Collections.<ColumnRef>emptyList(); 3998 } else { 3999 Set<TExpression> extractedWhereRoots = 4000 Collections.<TExpression>emptySet(); 4001 if (containsAnySubquery(where)) { 4002 extractedWhereRoots = 4003 extractUncorrelatedPredicateSubqueriesFromClause( 4004 where.getCondition(), providerWithStar, 4005 stmts, lineage, cteNameToStatementIndex, 4006 PredicateClauseContext.DELETE_WHERE); 4007 rejectAnyRemainingSubqueriesFromClause( 4008 where.getCondition(), extractedWhereRoots, 4009 PredicateClauseContext.DELETE_WHERE); 4010 } 4011 rejectWindowFunctionInScopeSkipping(where, "WHERE clause", 4012 extractedWhereRoots); 4013 // Codex diff-review P1 fix: for MySQL self-reference DELETE the 4014 // MySQL parser puts 3 T1 instances in stmt.tables (target + 4015 // joins[0] + refJoins[0]), making Resolver2's inferredCandidates 4016 // see 3 candidates for any unqualified column → NOT_FOUND → 4017 // COLUMN_BINDING_NON_EXACT. Use a tolerant collector for the 4018 // self-ref path: EXACT_MATCH bindings (qualified refs) are 4019 // preserved verbatim; non-exact bindings emit the column ref with 4020 // the SQL-written qualifier (null for unqualified refs) instead of 4021 // throwing. Qualified refs like WHERE T1.id = 1 still get full 4022 // EXACT_MATCH treatment; only WHERE id = 1 (no qualifier) falls 4023 // back to the tolerant path. Slice 111 — both helpers now skip 4024 // extracted predicate-subquery subtrees so inner refs do not 4025 // leak into outer filterColumnRefs. 4026 filterRefs = mysqlSelfRef 4027 ? collectColumnRefsTolerant(where, providerWithStar, 4028 targetQName, extractedWhereRoots) 4029 : collectColumnRefsSkipping(where, providerWithStar, 4030 extractedWhereRoots); 4031 } 4032 4033 // 5.5) Slice 84 — walk delete.getReferenceJoins() to populate 4034 // relations[] (TABLE-kind FROM-side sources, target excluded 4035 // by reference identity; SUBQUERY-kind for USING (SELECT …)) 4036 // and joinColumnRefs[] (ON-clause refs across all JoinItems). 4037 // Mirrors slice-82 buildUpdate's FROM walk, with the 4038 // `update.getJoins()` source replaced by 4039 // `delete.getReferenceJoins()`. 4040 // 4041 // Slice 92 — for MySQL self-reference DELETE T1 FROM T1, 4042 // refJoins[0] is the same table as the target; skip the loop 4043 // so relations[] stays empty (mirrors the slice-81 single-target 4044 // contract). Resolver2's ScopeBuilder has already registered 4045 // the FROM-clause table (including any alias) via the 4046 // `referenceJoins` walk in preVisit(TDeleteSqlStatement), so 4047 // WHERE refs resolve correctly even without a relations[] entry. 4048 List<RelationSource> relations = new ArrayList<>(); 4049 // Slice-82 codex round-1 Q2 BLOCKING precedent — joinRefs 4050 // accumulates across multiple JoinItems in chained-JOIN 4051 // shapes. LinkedHashSet ensures cross-JoinItem dedup. 4052 java.util.LinkedHashSet<ColumnRef> joinRefsSet = 4053 new java.util.LinkedHashSet<>(); 4054 TJoinList refJoins = delete.getReferenceJoins(); 4055 if (!mysqlSelfRef && refJoins != null) { 4056 for (int ji = 0; ji < refJoins.size(); ji++) { 4057 TJoin join = refJoins.getJoin(ji); 4058 TTable leftTable = join.getTable(); 4059 // Slice 106 — threads cteNameToStatementIndex so the 4060 // FROM-driver buildDeleteRelation call can route 4061 // objectname-typed CTE references to a SUBQUERY-kind 4062 // RelationSource pointing at the CTE statement. 4063 buildDeleteRelation(leftTable, targetTable, relations, delete, 4064 cteNameToStatementIndex); 4065 TJoinItemList items = join.getJoinItems(); 4066 if (items == null) continue; 4067 for (int i = 0; i < items.size(); i++) { 4068 TJoinItem item = items.getJoinItem(i); 4069 // Slice 106 — threads cteNameToStatementIndex through 4070 // the JoinItem walker so JOIN-side CTE refs (MSSQL 4071 // `FROM target t JOIN cte ON …`) get SUBQUERY-kind 4072 // RelationSource emission. 4073 buildDeleteJoinItem(item, targetTable, providerWithStar, 4074 relations, joinRefsSet, delete, 4075 cteNameToStatementIndex); 4076 } 4077 } 4078 } 4079 List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet); 4080 4081 // 6) Build the DELETE outer. 4082 // - relations[] may be non-empty for joined DELETE (slice 84); 4083 // empty for single-target DELETE (slice 81 contract). 4084 // - target.columns empty by design — DELETE removes whole rows. 4085 RelationBinding targetBinding = new RelationBinding( 4086 RelationKind.TABLE, targetQName); 4087 TargetRelation target = new TargetRelation( 4088 targetBinding, Collections.<String>emptyList()); 4089 4090 // Slice 85 — build RETURNING / OUTPUT projection columns BEFORE 4091 // the StatementGraph so the new returningColumns slot can be 4092 // populated. deleteIdx mirrors the slice-84 stmts.size() pattern. 4093 int deleteIdx = stmts.size(); 4094 // DELETE target alias = effective alias from the target's 4095 // TTable (slice-84 convention). 4096 String deleteTargetAlias = effectiveAliasOf(targetTable); 4097 if (deleteTargetAlias == null || deleteTargetAlias.isEmpty()) { 4098 deleteTargetAlias = targetQName; 4099 } 4100 // Slice 123 — combine slice-84 FROM-subquery aliases with slice-106 4101 // CTE-as-relation aliases so a RETURNING/OUTPUT ref to a SUBQUERY- 4102 // kind FROM-side relation column emits the cross-stmt 4103 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge into the producer. 4104 Map<String, Integer> returningAliasToSubIdx = 4105 buildDeleteCombinedAliasToSubIdx(delete, 4106 subqueryAliasToIndex, cteNameToStatementIndex); 4107 List<OutputColumn> returningColumns = buildReturningColumns( 4108 delete.getReturningClause(), 4109 delete.getOutputClause(), 4110 "DELETE", 4111 targetQName, 4112 deleteTargetAlias, 4113 /*targetTable=*/ targetTable, 4114 relations, 4115 returningAliasToSubIdx, 4116 providerWithStar, 4117 deleteIdx, 4118 lineage, 4119 delete); 4120 4121 StatementGraph deleteStmt = new StatementGraph( 4122 /*name=*/ null, 4123 "DELETE", 4124 relations, 4125 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 4126 returningColumns, 4127 filterRefs, 4128 joinRefs, 4129 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4130 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4131 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4132 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4133 /*distinct=*/ false, 4134 /*setOperator=*/ null, 4135 /*rowLimit=*/ null, 4136 target); 4137 4138 stmts.add(deleteStmt); 4139 // Slice 85 — extracted FROM-subqueries have already emitted 4140 // their own internal lineage edges into `lineage` via 4141 // processDirectSubqueryTable; buildReturningColumns also 4142 // already appended STATEMENT_OUTPUT(deleteIdx, retName) → 4143 // TABLE_COLUMN(targetQName, baseCol) edges above. No further 4144 // edges are needed. 4145 return new SemanticProgram(stmts, lineage); 4146 } 4147 4148 /** 4149 * Slice 94 — admit the single-target MERGE skeleton: 4150 * <pre> 4151 * MERGE INTO target [AS] tgt 4152 * USING (source_table | (SELECT ...) ) [AS] src 4153 * ON <join condition> 4154 * WHEN MATCHED [AND <cond>] THEN UPDATE SET c1 = expr1 [, ...] 4155 * WHEN NOT MATCHED [AND <cond>] THEN INSERT [(c1, ...)] VALUES (expr1, ...) 4156 * WHEN MATCHED [AND <cond>] THEN DELETE 4157 * </pre> 4158 * 4159 * <p>Emits one {@code "MERGE"}-kind {@link StatementGraph} carrying: 4160 * <ul> 4161 * <li>{@link TargetRelation} on {@code getTarget()} only — slice 4162 * 78/80 contract: target lives on the dedicated target slot, 4163 * NOT in {@code relations[]}. The slice-77/79 catalog walker 4164 * fires the kind-discriminated "MERGE target relation 'X'" 4165 * message via {@code targetWarnMessage("MERGE")}.</li> 4166 * <li>{@code relations[]} = one entry for the USING source 4167 * (TABLE-kind base table or SUBQUERY-kind aliased subquery). 4168 * The slice-77 FROM walker fires "FROM relation 'X'" for 4169 * missing source.</li> 4170 * <li>{@code outputColumns[]} = empty (MERGE has no projection).</li> 4171 * <li>{@code joinColumnRefs[]} = ON condition refs + per-WHEN AND 4172 * condition refs, LinkedHashSet-deduplicated (slice 82 4173 * pattern).</li> 4174 * <li>{@code filterColumnRefs[]} = per-WHEN action WHERE refs 4175 * (UPDATE WHERE, UPDATE...DELETE WHERE, INSERT WHERE; slice 4176 * 95). Empty when no action WHERE is present.</li> 4177 * </ul> 4178 * 4179 * <p>Per-WHEN action lineage: 4180 * <ul> 4181 * <li>{@code WHEN MATCHED THEN UPDATE SET col_i = expr_i}: emit 4182 * one {@link LineageEdge} per (target col, RHS source ref) 4183 * pair as {@code TABLE_COLUMN(target,col) ← <ref>} — direct, 4184 * no STATEMENT_OUTPUT intermediate (MERGE has no SELECT 4185 * projection). Codex round-2 Q4 confirmed YES.</li> 4186 * <li>{@code WHEN NOT MATCHED THEN INSERT (c1, ...) VALUES (e1, ...)}: 4187 * same pattern — one edge per (insert col, source ref).</li> 4188 * <li>{@code WHEN MATCHED THEN DELETE}: no per-column lineage 4189 * (slice 81 DELETE contract).</li> 4190 * <li>{@code WHEN MATCHED [AND <cond>] THEN DO NOTHING} (PG 15+, 4191 * slice 96): admitted as a no-op action. No per-column 4192 * lineage (slice 81 DELETE precedent). Per-WHEN AND 4193 * condition refs still feed {@code joinColumnRefs[]} via 4194 * the pre-dispatch block.</li> 4195 * <li>{@code WHEN NOT MATCHED BY SOURCE [AND <cond>] THEN 4196 * UPDATE SET ... | DELETE} (SQL Server, slice 97): 4197 * admitted with the SQL Server semantic invariant that 4198 * SET RHS and per-WHEN AND cond may not reference USING 4199 * source columns (no source row exists when the action 4200 * fires). Source-side refs reject with 4201 * {@link DiagnosticCode#MERGE_NOT_MATCHED_BY_SOURCE_REFERENCES_SOURCE}. 4202 * INSERT on BY SOURCE is parser-admitted but semantically 4203 * invalid; rejects with 4204 * {@link DiagnosticCode#MERGE_NOT_MATCHED_BY_SOURCE_INSERT_NOT_VALID}. 4205 * UPDATE target self-refs ({@code t.a = t.b}) emit no 4206 * lineage edges (slice-94 alias-filter convention; codex 4207 * round-1 Q2 confirmed). PG 17+ BY SOURCE syntax still 4208 * parses as type 2 plain NOT MATCHED in parser 4.1.5.0 4209 * — that parser gap is not addressed in slice 97.</li> 4210 * </ul> 4211 * 4212 * <p>For USING-subquery, the inner SELECT is built via {@link #build} 4213 * and appended as a preceding {@link StatementGraph}; its inner 4214 * lineage edges are rebased by the current statement-list offset so 4215 * STATEMENT_OUTPUT indices stay valid (slice 78 INSERT pattern). 4216 * 4217 * <p>Resolver2 already handles MERGE via {@link gudusoft.gsqlparser.resolver2.scope.MergeScope} 4218 * — both USING base tables and USING subqueries surface as 4219 * {@code sourceTable + EXACT_MATCH} bindings on RHS / VALUES / 4220 * ON / WHEN-AND refs. Codex round-2 Q5 BLOCKING fix: we install 4221 * an explicit slice-83-style published-column map only for 4222 * USING subqueries (deterministic; cheap; matches the SELECT- 4223 * side FROM-subquery pattern even when redundant). 4224 */ 4225 public static SemanticProgram buildMerge(TMergeSqlStatement merge, 4226 NameBindingProvider provider) { 4227 if (merge == null) { 4228 throw new IllegalArgumentException("merge must not be null"); 4229 } 4230 if (provider == null) { 4231 throw new IllegalArgumentException("provider must not be null"); 4232 } 4233 // Slice 94 — defensive UsingScope reset at entry. MERGE does 4234 // not produce its own UsingScope but a parent context might 4235 // (e.g. nested-statement contexts); mirrors slice 80 / 86 4236 // buildUpdate hygiene. 4237 provider = provider.withUsingScope(UsingScope.EMPTY); 4238 4239 // Slice 101 — hoist allocations earlier so buildMergeCteList can 4240 // append CTE bodies as preceding statements. The slice-94 reject 4241 // at this location is replaced by the CTE walker below. 4242 List<StatementGraph> stmts = new ArrayList<>(); 4243 List<LineageEdge> lineage = new ArrayList<>(); 4244 4245 // 1) Slice 101 — admit top-level WITH on MERGE. Walks CTE list 4246 // left-to-right, building each body as a preceding statement. 4247 // Produces cteNameToStatementIndex + ctePublishedColumns for the 4248 // USING-as-CTE branch below. Mirrors SELECT-side build() at 4249 // lines 516-653. `MERGE_CTE_NOT_SUPPORTED` stays declared-but- 4250 // unreached for API stability (slice 71/72/82/86/95/96/97/98/99/100 4251 // precedent). 4252 Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>(); 4253 Map<String, Integer> cteNameToStatementIndex = buildMergeCteList( 4254 merge, provider, stmts, lineage, ctePublishedColumns); 4255 4256 // 2) Target table — defensive. 4257 TTable targetTable = merge.getTargetTable(); 4258 if (targetTable == null || targetTable.getTableName() == null) { 4259 throw new SemanticIRBuildException(Diagnostic.error( 4260 DiagnosticCode.MERGE_TARGET_MISSING, 4261 "MERGE statement has no resolvable target table", 4262 merge)); 4263 } 4264 String targetQName = targetTable.getTableName().toString(); 4265 if (targetQName == null || targetQName.isEmpty()) { 4266 throw new SemanticIRBuildException(Diagnostic.error( 4267 DiagnosticCode.MERGE_TARGET_MISSING, 4268 "MERGE target table name is empty", 4269 merge)); 4270 } 4271 4272 // 3) USING source — defensive. 4273 TTable usingTable = merge.getUsingTable(); 4274 if (usingTable == null) { 4275 throw new SemanticIRBuildException(Diagnostic.error( 4276 DiagnosticCode.MERGE_USING_SOURCE_MISSING, 4277 "MERGE statement has no USING source", 4278 merge)); 4279 } 4280 4281 // 4) ON condition — defensive (parser usually rejects first). 4282 TExpression onCondition = merge.getCondition(); 4283 if (onCondition == null) { 4284 throw new SemanticIRBuildException(Diagnostic.error( 4285 DiagnosticCode.MERGE_ON_CONDITION_MISSING, 4286 "MERGE statement has no ON condition", 4287 merge)); 4288 } 4289 4290 // 5) OUTPUT INTO / RETURNING / LIMIT / error logging rejects. 4291 // Slice 98 lifts MSSQL MERGE OUTPUT projection (non-INTO) via 4292 // the slice-85 buildReturningColumns walker; the actual call 4293 // is deferred until after step 8 because the walker needs the 4294 // populated relations[] (USING source). OUTPUT INTO continues 4295 // to reject (writes a second target). The RETURNING-clause 4296 // branch stays declared-but-unreached: PG parser drops 4297 // MERGE RETURNING silently, Oracle PARSE_FAILED, Couchbase 4298 // has no test reach (slice 71/72/82/86/95/96/97 precedent). 4299 if (merge.getOutputClause() != null 4300 && merge.getOutputClause().getIntoTable() != null) { 4301 throw new SemanticIRBuildException(Diagnostic.error( 4302 DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED, 4303 "MERGE OUTPUT ... INTO <target> writes a second " 4304 + "target; slice 98 admits OUTPUT projection only", 4305 merge)); 4306 } 4307 if (merge.getReturningClause() != null) { 4308 throw new SemanticIRBuildException(Diagnostic.error( 4309 DiagnosticCode.MERGE_RETURNING_CLAUSE_NOT_SUPPORTED, 4310 "MERGE RETURNING projection (Oracle / Couchbase) is " 4311 + "not supported by SemanticIRBuilder.buildMerge", 4312 merge)); 4313 } 4314 if (merge.getLimitClause() != null) { 4315 throw new SemanticIRBuildException(Diagnostic.error( 4316 DiagnosticCode.MERGE_LIMIT_NOT_SUPPORTED, 4317 "MERGE with LIMIT (Couchbase) is not supported by " 4318 + "SemanticIRBuilder.buildMerge", 4319 merge)); 4320 } 4321 if (merge.getErrorLoggingClause() != null) { 4322 throw new SemanticIRBuildException(Diagnostic.error( 4323 DiagnosticCode.MERGE_ERROR_LOGGING_NOT_SUPPORTED, 4324 "MERGE LOG ERRORS INTO (Oracle) is not supported by " 4325 + "SemanticIRBuilder.buildMerge", 4326 merge)); 4327 } 4328 4329 // 6) Build USING source RelationSource. If USING is a subquery, 4330 // extract it as a preceding StatementGraph and emit a SUBQUERY- 4331 // kind RelationSource that points at it; slice-83 pattern. 4332 // Otherwise emit a TABLE-kind RelationSource. 4333 // Slice 101 — `stmts` / `lineage` were hoisted to the top of 4334 // buildMerge so the CTE walker can append its preceding CTE 4335 // body statements first. Do NOT re-declare them here. 4336 String usingAlias = effectiveAliasOf(usingTable); 4337 if (usingAlias == null || usingAlias.isEmpty()) { 4338 usingAlias = (usingTable.getName() == null 4339 || usingTable.getName().toString().isEmpty()) 4340 ? "__merge_using__" 4341 : usingTable.getName().toString(); 4342 } 4343 boolean usingIsSubquery = usingTable.getTableType() 4344 == gudusoft.gsqlparser.ETableSource.subquery; 4345 List<RelationSource> relations = new ArrayList<>(); 4346 Map<String, List<String>> mergeInScope = new LinkedHashMap<>(); 4347 NameBindingProvider providerWithStar = provider; 4348 // Slice 94 — alias resolution maps for the per-WHEN action 4349 // lineage emitter. TABLE-kind sources map alias → qualifiedName; 4350 // SUBQUERY-kind sources map alias → statement index of the 4351 // extracted inner SELECT. A SEPARATE `targetAliases` set 4352 // identifies refs whose relationAlias is the target alias 4353 // (codex round-1 diff Q1 BLOCKING — without this separation, 4354 // a self-merge where USING happens to share the target's name 4355 // would mis-classify the source alias as the target alias). 4356 Map<String, String> aliasToTableQName = new HashMap<>(); 4357 Map<String, Integer> aliasToSubIdx = new HashMap<>(); 4358 Set<String> targetAliases = new HashSet<>(); 4359 String targetAlias = effectiveAliasOf(targetTable); 4360 if (targetAlias != null && !targetAlias.isEmpty()) { 4361 targetAliases.add(targetAlias.toLowerCase(Locale.ROOT)); 4362 } 4363 targetAliases.add(targetQName.toLowerCase(Locale.ROOT)); 4364 if (usingIsSubquery) { 4365 TSelectSqlStatement usingSelect = usingTable.getSubquery(); 4366 if (usingSelect == null) { 4367 throw new SemanticIRBuildException(Diagnostic.error( 4368 DiagnosticCode.MERGE_SOURCE_NOT_SUPPORTED, 4369 "MERGE USING declared as subquery but no inner " 4370 + "SELECT statement was attached", 4371 merge)); 4372 } 4373 // Slice 122 — close the slice-110 documented parity gap so a 4374 // USING body that references an outer MERGE CTE 4375 // (`WITH cte AS (...) MERGE INTO t USING (SELECT ... FROM cte) s`) 4376 // binds CTE-kind and emits the cross-stmt edge into the CTE body. 4377 // Build the USING body directly into the SHARED stmts/lineage via 4378 // buildSelectProgramInto (no rebase — see its javadoc for the full 4379 // rationale), seeded with a COPY of the outer MERGE's CTE maps. 4380 // The COPY (not the live map) keeps a USING-body-own CTE from 4381 // leaking into the outer cteNameToStatementIndex consulted later 4382 // by the slice-116 action-WHERE extractor and the USING-as-CTE 4383 // `else` branch — matching pre-slice-122, where own-WITH CTEs were 4384 // never registered in the outer map. 4385 Map<String, Integer> usingCteMap = 4386 new HashMap<>(cteNameToStatementIndex); 4387 Map<String, List<String>> usingCtePublished = 4388 new LinkedHashMap<>(ctePublishedColumns); 4389 int subIdx = buildSelectProgramInto(usingSelect, provider, 4390 stmts, lineage, usingCteMap, usingCtePublished); 4391 // Codex round-2 Q5 BLOCKING fix: install slice-83-style 4392 // in-scope map for USING subquery columns, scoped only to 4393 // the USING alias (codex round-3 Q2: ensure scoped to 4394 // USING alias only, no override of target / base-table). 4395 StatementGraph usingOuter = stmts.get(subIdx); 4396 List<String> publishedCols = new ArrayList<>(); 4397 for (OutputColumn oc : usingOuter.getOutputColumns()) { 4398 if (oc.getName() != null && !oc.getName().isEmpty()) { 4399 publishedCols.add(oc.getName()); 4400 } 4401 } 4402 mergeInScope.put( 4403 usingAlias.toLowerCase(Locale.ROOT), publishedCols); 4404 providerWithStar = provider.withInScopeRelationColumns( 4405 mergeInScope); 4406 relations.add(new RelationSource(usingAlias, 4407 new RelationBinding(RelationKind.SUBQUERY, usingAlias))); 4408 aliasToSubIdx.put( 4409 usingAlias.toLowerCase(Locale.ROOT), subIdx); 4410 } else { 4411 // Slice 101 — USING-as-CTE detection. When MERGE has a WITH 4412 // clause and the USING bare name matches a CTE declared in 4413 // that WITH clause, route to a SUBQUERY-kind RelationSource 4414 // pointing at the CTE's already-built statement index. This 4415 // ensures: 4416 // (a) lineage edges flow to STATEMENT_OUTPUT(cteIdx, col), 4417 // not the fictitious TABLE_COLUMN(cteName, col); 4418 // (b) the slice-77 catalog-miss WARN walker (which walks 4419 // only TABLE-kind RelationSources) skips the CTE name; 4420 // (c) Resolver2-bound CTE refs (probe 2026-05-17: status 4421 // EXACT_MATCH with sourceTable=<cteName>) flow through 4422 // the same emitMergeLineageEdge dispatch. 4423 // Case-insensitive lookup matches SQL identifier semantics. 4424 String usingBareName = (usingTable.getName() == null) 4425 ? "" 4426 : usingTable.getName().toString(); 4427 String usingBareNameLower = 4428 usingBareName.toLowerCase(Locale.ROOT); 4429 Integer cteIdx = usingBareNameLower.isEmpty() 4430 ? null 4431 : cteNameToStatementIndex.get(usingBareNameLower); 4432 if (cteIdx != null) { 4433 // USING references a declared CTE. 4434 List<String> publishedCols = ctePublishedColumns.get( 4435 usingBareNameLower); 4436 if (publishedCols == null) { 4437 publishedCols = new ArrayList<>(); 4438 } 4439 mergeInScope.put( 4440 usingAlias.toLowerCase(Locale.ROOT), 4441 publishedCols); 4442 providerWithStar = provider.withInScopeRelationColumns( 4443 mergeInScope); 4444 relations.add(new RelationSource(usingAlias, 4445 new RelationBinding( 4446 RelationKind.SUBQUERY, usingAlias))); 4447 aliasToSubIdx.put( 4448 usingAlias.toLowerCase(Locale.ROOT), cteIdx); 4449 // Also register the bare CTE name in case the SQL 4450 // omits the alias (e.g. `USING src ON ...` with no 4451 // trailing alias). Mirrors the TABLE-kind branch 4452 // (line below) which also registers the bare name. 4453 aliasToSubIdx.put(usingBareNameLower, cteIdx); 4454 } else { 4455 // TABLE-kind USING — use the source table's qualified name 4456 // as the binding's qualifiedName so the slice-77 catalog 4457 // walker can find it. 4458 String usingQName = (usingTable.getTableName() == null) 4459 ? usingAlias 4460 : usingTable.getTableName().toString(); 4461 relations.add(new RelationSource(usingAlias, 4462 new RelationBinding(RelationKind.TABLE, usingQName))); 4463 aliasToTableQName.put( 4464 usingAlias.toLowerCase(Locale.ROOT), usingQName); 4465 // Also register the bare name in case the SQL omits the 4466 // alias (e.g. `USING managers ON ...` without `s`). 4467 aliasToTableQName.put( 4468 usingQName.toLowerCase(Locale.ROOT), usingQName); 4469 } 4470 } 4471 4472 // 7) Walk ON condition + per-WHEN AND conditions to build 4473 // joinColumnRefs[] with LinkedHashSet dedup (slice 82 pattern). 4474 // Reject ON-side subqueries: not supported in this slice; users 4475 // can still use a USING subquery for complex source logic. 4476 if (containsAnySubqueryExpression(onCondition)) { 4477 throw new SemanticIRBuildException(Diagnostic.error( 4478 DiagnosticCode.MERGE_WHEN_CONDITION_HAS_SUBQUERY_NOT_SUPPORTED, 4479 "MERGE ON condition contains a subquery; slice 94 " 4480 + "admits scalar-only ON conditions", 4481 merge)); 4482 } 4483 rejectWindowFunctionInScope(onCondition, "MERGE ON condition"); 4484 LinkedHashSet<ColumnRef> joinRefsSet = new LinkedHashSet<>(); 4485 joinRefsSet.addAll(collectColumnRefs(onCondition, providerWithStar)); 4486 // Slice 95 — per-WHEN action WHERE refs (UPDATE WHERE, 4487 // UPDATE...DELETE WHERE, INSERT WHERE) accumulate here. 4488 // Slice 94 left these refs silently dropped; slice 95 routes 4489 // them through filterColumnRefs[] (slice-80 UPDATE WHERE 4490 // precedent) — distinct from joinColumnRefs[] which holds 4491 // ON + WHEN-AND match conditions. 4492 LinkedHashSet<ColumnRef> filterRefsSet = new LinkedHashSet<>(); 4493 4494 // 8) Per-WHEN clause loop. Validate type, dispatch to action 4495 // builder, accumulate joinColumnRefs and lineage edges. 4496 TargetRelation targetRel = null; 4497 List<String> targetColumnNames = new ArrayList<>(); 4498 // Stable-order map: target col spelling (lower-cased) → 4499 // verbatim spelling encountered first. Iterating WHEN clauses 4500 // in order naturally produces SET-LHS first, then INSERT 4501 // column-list, matching the plan v3 column ordering rule. 4502 Map<String, String> seenTargetCols = new LinkedHashMap<>(); 4503 // LineageEdge dedup spans the whole MERGE on 4504 // (target column lower-case, source ref lower-case key). 4505 Set<String> emittedEdgeKeys = new HashSet<>(); 4506 4507 if (merge.getWhenClauses() == null 4508 || merge.getWhenClauses().size() == 0) { 4509 throw new SemanticIRBuildException(Diagnostic.error( 4510 DiagnosticCode.MERGE_WHEN_NO_ACTION, 4511 "MERGE statement has no WHEN clauses", 4512 merge)); 4513 } 4514 // Slice 116 — providerWithCteForActionWhere decorates 4515 // providerWithStar with withCteContext so the predicate body's 4516 // inner SELECT's `FROM cte` refs route through 4517 // RelationKind.CTE (slice 110 documented this is required for 4518 // emitLineageForStatement to emit STATEMENT_OUTPUT → 4519 // STATEMENT_OUTPUT edges into the CTE body). Hoisted here once 4520 // (cteNameToStatementIndex is finalized by line 3856 well 4521 // before the per-WHEN loop; recomputing per-WHEN would be 4522 // wasteful — codex diff-review Q1 advisory). The decoration 4523 // is scoped to collectMergeActionWhere only; providerWithStar 4524 // elsewhere stays unchanged so the WHEN AND condition (line 4525 // ~4153) and per-action SET/INSERT walkers see the original 4526 // provider — they already have their own slice-94 subquery 4527 // rejects so no asymmetric resolution surfaces. 4528 final NameBindingProvider providerWithCteForActionWhere = 4529 cteNameToStatementIndex.isEmpty() 4530 ? providerWithStar 4531 : providerWithStar.withCteContext( 4532 cteNameToStatementIndex.keySet()); 4533 // Slice 118 — build the MERGE correlation scope once (target + 4534 // USING source + outer CTEs) and thread through every per-WHEN 4535 // action WHERE call so correlated predicate subqueries promote 4536 // outer-aliased refs into OUTER_REFERENCE relations instead of 4537 // rejecting them. Mirrors the slice-117 pattern (UPDATE 4538 // SET-RHS correlated scalars). The scope is null-safe — every 4539 // value type inside flows from already-computed buildMerge 4540 // state (targetTable / usingTable / aliasToSubIdx / 4541 // cteNameToStatementIndex). 4542 final EnclosingScope mergeCorrelationScope = 4543 buildMergeEnclosingScope(merge, cteNameToStatementIndex, 4544 aliasToSubIdx); 4545 for (int wi = 0; wi < merge.getWhenClauses().size(); wi++) { 4546 TMergeWhenClause when = merge.getWhenClauses().getElement(wi); 4547 // Slice 97 — BY SOURCE variants (SQL Server admits parser 4548 // types 7 / 8) are now admitted. PG 17+ syntax still parses 4549 // as type 2 (parser gap; slice 97 does not address). The 4550 // legacy MERGE_WHEN_NOT_MATCHED_BY_SOURCE_NOT_SUPPORTED 4551 // code stays declared-but-unreached (slice 71/72/82/86/95/96 4552 // precedent). 4553 boolean isNotMatchedBySource = 4554 when.getType() == TMergeWhenClause.not_matched_by_source 4555 || when.getType() 4556 == TMergeWhenClause.not_matched_by_source_with_condition; 4557 // Per-WHEN AND condition (matched_with_condition, 4558 // not_matched_with_condition, not_matched_by_target_with_condition, 4559 // not_matched_by_source_with_condition). 4560 TExpression whenCond = when.getCondition(); 4561 if (whenCond != null) { 4562 if (containsAnySubqueryExpression(whenCond)) { 4563 throw new SemanticIRBuildException(Diagnostic.error( 4564 DiagnosticCode.MERGE_WHEN_CONDITION_HAS_SUBQUERY_NOT_SUPPORTED, 4565 "MERGE WHEN AND condition contains a subquery; " 4566 + "slice 94 admits scalar-only WHEN " 4567 + "conditions", 4568 merge)); 4569 } 4570 rejectWindowFunctionInScope(whenCond, "MERGE WHEN AND condition"); 4571 List<ColumnRef> condRefs = 4572 collectColumnRefs(whenCond, providerWithStar); 4573 // Slice 97 — BY SOURCE branches forbid source-side 4574 // refs in the AND condition (no source row exists). 4575 if (isNotMatchedBySource) { 4576 rejectSourceRefsForBySource(condRefs, aliasToTableQName, 4577 aliasToSubIdx, "MERGE WHEN NOT MATCHED BY SOURCE " 4578 + "AND condition", merge); 4579 } 4580 joinRefsSet.addAll(condRefs); 4581 } 4582 // Dispatch to action. Slice 96 — DO NOTHING is a no-op 4583 // action (PG 15+): no SET/INSERT VALUES, no per-column 4584 // lineage (slice-81 DELETE precedent). Per-WHEN AND 4585 // condition refs were already collected into joinRefsSet 4586 // above. MERGE_DO_NOTHING_NOT_SUPPORTED stays declared- 4587 // but-unreached for API stability (slice 71/72/82/86/95 4588 // precedent). 4589 boolean isDoNothingAction = when.getDoNothingClause() != null; 4590 TMergeUpdateClause upd = when.getUpdateClause(); 4591 TMergeInsertClause ins = when.getInsertClause(); 4592 boolean isDeleteAction = when.getDeleteClause() != null; 4593 if (upd == null && ins == null && !isDeleteAction 4594 && !isDoNothingAction) { 4595 throw new SemanticIRBuildException(Diagnostic.error( 4596 DiagnosticCode.MERGE_WHEN_NO_ACTION, 4597 "MERGE WHEN clause #" + (wi + 1) 4598 + " has no UPDATE / INSERT / DELETE / " 4599 + "DO NOTHING action", 4600 merge)); 4601 } 4602 // Slice 95 — collect per-WHEN action WHERE refs into 4603 // filterRefsSet. Slice 116 — uncorrelated predicate-subquery 4604 // wrappers in those WHEREs now extract through the slice-23+ 4605 // pipeline via PredicateClauseContext.MERGE_WHEN_WHERE 4606 // (mirrors slice 110-114 lifts on UPDATE / DELETE / SELECT / 4607 // set-op branch / CTE-body WHEREs). Window functions still 4608 // reject via rejectWindowFunctionInScopeSkipping (slice 95 4609 // contract preserved). MERGE_UPDATE_DELETE_WHERE_NOT_SUPPORTED 4610 // remains declared-but-unreached (slice 71/72/82/86 4611 // precedent). The providerWithCteForActionWhere decoration 4612 // is hoisted ABOVE the per-WHEN loop (codex diff-review Q1 4613 // advisory) since cteNameToStatementIndex is finalized 4614 // before the loop. 4615 if (upd != null) { 4616 collectMergeActionWhere(upd.getUpdateWhereClause(), 4617 "MERGE WHEN action UPDATE WHERE", 4618 providerWithCteForActionWhere, filterRefsSet, 4619 stmts, lineage, cteNameToStatementIndex, merge, 4620 mergeCorrelationScope); 4621 collectMergeActionWhere(upd.getDeleteWhereClause(), 4622 "MERGE WHEN action DELETE WHERE", 4623 providerWithCteForActionWhere, filterRefsSet, 4624 stmts, lineage, cteNameToStatementIndex, merge, 4625 mergeCorrelationScope); 4626 } 4627 if (ins != null) { 4628 collectMergeActionWhere(ins.getInsertWhereClause(), 4629 "MERGE WHEN action INSERT WHERE", 4630 providerWithCteForActionWhere, filterRefsSet, 4631 stmts, lineage, cteNameToStatementIndex, merge, 4632 mergeCorrelationScope); 4633 } 4634 // Slice 97 — BY SOURCE forbids INSERT semantically. MSSQL 4635 // parser admits the shape, so Semantic IR rejects. 4636 if (isNotMatchedBySource && ins != null) { 4637 throw new SemanticIRBuildException(Diagnostic.error( 4638 DiagnosticCode.MERGE_NOT_MATCHED_BY_SOURCE_INSERT_NOT_VALID, 4639 "MERGE WHEN NOT MATCHED BY SOURCE THEN INSERT is " 4640 + "not a valid SQL Server action; INSERT only " 4641 + "applies when the source row has no target " 4642 + "match. Slice 97 admits UPDATE / DELETE on " 4643 + "BY SOURCE branches.", 4644 merge)); 4645 } 4646 if (upd != null) { 4647 // Slice 97 — pre-walk SET RHS refs for BY SOURCE 4648 // branches to reject source-side refs before lineage 4649 // emission (Q1 in plan-review: keep helper branch- 4650 // agnostic; double-collect cost is bounded since 4651 // BY SOURCE UPDATEs are small). 4652 if (isNotMatchedBySource) { 4653 rejectBySourceSetRhsRefs(upd, providerWithStar, 4654 aliasToTableQName, aliasToSubIdx, merge); 4655 } 4656 buildMergeUpdateAction(upd, targetQName, targetTable, 4657 providerWithStar, seenTargetCols, lineage, 4658 emittedEdgeKeys, aliasToTableQName, 4659 aliasToSubIdx, targetAliases, merge); 4660 } 4661 if (ins != null) { 4662 buildMergeInsertAction(ins, targetQName, targetTable, 4663 providerWithStar, seenTargetCols, lineage, 4664 emittedEdgeKeys, aliasToTableQName, 4665 aliasToSubIdx, targetAliases, merge); 4666 } 4667 // DELETE action: no per-column lineage (slice 81 contract). 4668 } 4669 4670 // 9) Build TargetRelation from accumulated target column spellings. 4671 for (String spelling : seenTargetCols.values()) { 4672 targetColumnNames.add(spelling); 4673 } 4674 targetRel = new TargetRelation( 4675 new RelationBinding(RelationKind.TABLE, targetQName), 4676 targetColumnNames); 4677 4678 // 9.5) Slice 98 — MSSQL MERGE OUTPUT projection. Reuses the 4679 // slice-85 buildReturningColumns walker with dmlKind="MERGE": 4680 // Pass 1.5's INSERT/DELETE pseudo-table mismatch check naturally 4681 // skips (MERGE is action-polymorphic — INSERTED and DELETED 4682 // may both legitimately appear). The walker handles $action via 4683 // a slice-98-specific short-circuit (derived OutputColumn, no 4684 // sources, no edges). mergeIdx = stmts.size() because the MERGE 4685 // StatementGraph is appended below; for USING-subquery shapes, 4686 // step 6 has already appended the extracted SELECT so the index 4687 // points at the upcoming MERGE position (slice-83 dynamic-index 4688 // pattern). Pass relations[] as fromSideRelations so unique 4689 // USING-alias matches resolve to the source qname (codex Q4). 4690 int mergeIdx = stmts.size(); 4691 String mergeTargetAlias = effectiveAliasOf(targetTable); 4692 if (mergeTargetAlias == null || mergeTargetAlias.isEmpty()) { 4693 mergeTargetAlias = targetQName; 4694 } 4695 List<OutputColumn> returningCols = buildReturningColumns( 4696 /*ret=*/ null, 4697 /*out=*/ merge.getOutputClause(), 4698 "MERGE", 4699 targetQName, 4700 mergeTargetAlias, 4701 targetTable, 4702 /*fromSideRelations=*/ relations, 4703 // Slice 124 — MERGE OUTPUT USING-source lineage unification 4704 // (symmetric follow-up to slice 123, which lifted DELETE + 4705 // UPDATE). aliasToSubIdx is already the MERGE combined 4706 // alias→stmt-index map: it holds the USING alias for both the 4707 // USING-(SELECT) subquery branch (alias→subIdx) and the 4708 // USING-as-CTE branch (alias→cteIdx); both add a SUBQUERY-kind 4709 // relation to relations[], so returningSubquerySourceIndex 4710 // promotes a MERGE OUTPUT ref bound to the USING source to the 4711 // canonical STATEMENT_OUTPUT(mergeIdx, ret) → 4712 // STATEMENT_OUTPUT(subOrCteIdx, col) edge instead of the 4713 // fictitious TABLE_COLUMN(<usingAlias>, col). TABLE-kind USING 4714 // adds nothing to aliasToSubIdx, and INSERTED/DELETED/target 4715 // refs are excluded by the gate, so those paths keep their 4716 // slice-85/98 TABLE_COLUMN edge. 4717 /*fromSideAliasToStmtIndex=*/ aliasToSubIdx, 4718 providerWithStar, 4719 mergeIdx, 4720 lineage, 4721 merge); 4722 4723 // 10) Emit StatementGraph. joinColumnRefs[] = ON + WHEN-AND refs. 4724 // Slice 95: filterColumnRefs[] = per-WHEN action WHERE refs 4725 // (UPDATE WHERE, UPDATE...DELETE WHERE, INSERT WHERE). 4726 // Slice 98: returningColumns[] = MERGE OUTPUT projection. 4727 List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet); 4728 List<ColumnRef> filterRefs = new ArrayList<>(filterRefsSet); 4729 StatementGraph mergeStmt = new StatementGraph( 4730 /*name=*/ null, 4731 "MERGE", 4732 relations, 4733 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 4734 returningCols, 4735 filterRefs, 4736 joinRefs, 4737 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4738 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4739 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4740 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4741 /*distinct=*/ false, 4742 /*setOperator=*/ null, 4743 /*rowLimit=*/ null, 4744 targetRel); 4745 stmts.add(mergeStmt); 4746 4747 return new SemanticProgram(stmts, lineage); 4748 } 4749 4750 /** 4751 * Slice 95 — collect column refs from a per-WHEN action WHERE 4752 * predicate ({@code TMergeUpdateClause.updateWhereClause}, 4753 * {@code TMergeUpdateClause.deleteWhereClause}, or 4754 * {@code TMergeInsertClause.insertWhereClause}) into the supplied 4755 * {@code filterRefsSet}. 4756 * 4757 * <p>Slice 116 — lifts the slice-95 blanket subquery reject by 4758 * routing uncorrelated predicate-subquery wrappers (IN-SELECT / 4759 * EXISTS / NOT EXISTS / scalar comparison / ANY-ALL-SOME) through 4760 * the slice-23+ JOIN-ON extraction pipeline refactored by slice 4761 * 110 to take a {@link PredicateClauseContext}. The 4762 * {@link PredicateClauseContext#MERGE_WHEN_WHERE} constant reuses 4763 * the {@code SELECT_WHERE_*} DiagnosticCode family (slice 113/114 4764 * precedent — a MERGE-action WHERE IS a SELECT WHERE in shape) so 4765 * the enum count stays at 279; only the {@code clauseLabel} 4766 * differs so diagnostic messages can identify the MERGE-action 4767 * host context. 4768 * 4769 * <p>Each extracted wrapper lands as its own 4770 * {@code <predicate_subquery_<i>>} StatementGraph BEFORE the 4771 * MERGE statement (so {@code mergeIdx = stmts.size()} below in 4772 * {@code buildMerge} already accounts for them — slice-83 4773 * dynamic-index pattern, slice 110/111 precedent). Remaining 4774 * non-subquery refs flow into {@code filterRefsSet} via 4775 * {@link #collectColumnRefsSkipping}. Window functions in 4776 * non-subquery subtrees still reject via 4777 * {@link #rejectWindowFunctionInScopeSkipping} (slice 95 4778 * window-function contract preserved). 4779 * 4780 * <p>The supplied {@code provider} must carry 4781 * {@code withCteContext(cteMap.keySet())} so the predicate body's 4782 * inner SELECT's {@code FROM cte} refs route through 4783 * {@link RelationKind#CTE} — without that decoration, 4784 * {@code emitLineageForStatement} would lose the 4785 * STATEMENT_OUTPUT → STATEMENT_OUTPUT edge to the CTE body 4786 * (slice 110 documented gap for UPDATE WHERE — same applies here). 4787 * {@code buildMerge} composes the decoration once before the 4788 * per-WHEN loop. 4789 * 4790 * <p>Null-safe: returns immediately when the WHERE expression is 4791 * absent (slice-94 default; most WHEN clauses have no action 4792 * WHERE). 4793 */ 4794 private static void collectMergeActionWhere(TExpression expr, 4795 String label, 4796 NameBindingProvider provider, 4797 LinkedHashSet<ColumnRef> filterRefsSet, 4798 List<StatementGraph> stmts, 4799 List<LineageEdge> lineage, 4800 Map<String, Integer> cteMap, 4801 TMergeSqlStatement merge, 4802 EnclosingScope correlationScope) { 4803 if (expr == null) { 4804 return; 4805 } 4806 Set<TExpression> extractedRoots = Collections.<TExpression>emptySet(); 4807 if (containsAnySubqueryExpression(expr)) { 4808 extractedRoots = 4809 extractUncorrelatedPredicateSubqueriesFromClause( 4810 expr, provider, stmts, lineage, cteMap, 4811 PredicateClauseContext.MERGE_WHEN_WHERE, 4812 correlationScope); 4813 rejectAnyRemainingSubqueriesFromClause(expr, extractedRoots, 4814 PredicateClauseContext.MERGE_WHEN_WHERE); 4815 } 4816 rejectWindowFunctionInScopeSkipping(expr, label, extractedRoots); 4817 filterRefsSet.addAll(collectColumnRefsSkipping(expr, provider, 4818 extractedRoots)); 4819 } 4820 4821 /** 4822 * Slice 97 — reject any source-aliased column ref in a 4823 * WHEN NOT MATCHED BY SOURCE branch. SQL Server forbids 4824 * source-side references in this branch because there is no 4825 * matching source row when the action fires. 4826 * 4827 * <p>A ref is "source-aliased" iff its 4828 * {@code relationAlias.toLowerCase(Locale.ROOT)} appears in 4829 * either alias map (TABLE-kind or SUBQUERY-kind USING source). 4830 * Refs whose alias is unknown to both maps are assumed to be 4831 * target-bound (slice-94 alias-filter convention) and are 4832 * left alone. 4833 * 4834 * <p>Walks all refs so a source ref nested inside an arbitrary 4835 * function call (e.g. {@code COALESCE(s.code, t.code)}) is 4836 * caught — {@code collectColumnRefs} descends through arbitrary 4837 * scalar expressions (codex round-1 Q3 confirmed YES). 4838 */ 4839 private static void rejectSourceRefsForBySource(List<ColumnRef> refs, 4840 Map<String, String> aliasToTableQName, 4841 Map<String, Integer> aliasToSubIdx, 4842 String label, 4843 TMergeSqlStatement merge) { 4844 if (refs == null || refs.isEmpty()) { 4845 return; 4846 } 4847 for (ColumnRef r : refs) { 4848 String alias = r.getRelationAlias(); 4849 if (alias == null || alias.isEmpty()) { 4850 continue; 4851 } 4852 String key = alias.toLowerCase(Locale.ROOT); 4853 if (aliasToTableQName.containsKey(key) 4854 || aliasToSubIdx.containsKey(key)) { 4855 throw new SemanticIRBuildException(Diagnostic.error( 4856 DiagnosticCode.MERGE_NOT_MATCHED_BY_SOURCE_REFERENCES_SOURCE, 4857 label + " references USING source column '" 4858 + r + "'; WHEN NOT MATCHED BY SOURCE " 4859 + "branches must only reference target " 4860 + "columns or constants.", 4861 merge)); 4862 } 4863 } 4864 } 4865 4866 /** 4867 * Slice 97 — pre-walk the SET RHS of every assignment in a BY SOURCE 4868 * UPDATE action and reject any source-side ref. Called before 4869 * {@link #buildMergeUpdateAction} so the existing slice-94 helper 4870 * remains BY-SOURCE-agnostic. 4871 * 4872 * <p>Skips assignments that aren't shaped as a simple 4873 * {@code assignment_t} expression; those defects are caught by 4874 * {@link #buildMergeUpdateAction} with 4875 * {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}. 4876 */ 4877 private static void rejectBySourceSetRhsRefs(TMergeUpdateClause upd, 4878 NameBindingProvider provider, 4879 Map<String, String> aliasToTableQName, 4880 Map<String, Integer> aliasToSubIdx, 4881 TMergeSqlStatement merge) { 4882 TResultColumnList sets = upd.getUpdateColumnList(); 4883 if (sets == null || sets.size() == 0) { 4884 return; 4885 } 4886 for (int i = 0; i < sets.size(); i++) { 4887 TResultColumn rc = sets.getResultColumn(i); 4888 TExpression assignment = (rc == null) ? null : rc.getExpr(); 4889 if (assignment == null 4890 || assignment.getExpressionType() != EExpressionType.assignment_t) { 4891 continue; 4892 } 4893 TExpression rhs = assignment.getRightOperand(); 4894 if (rhs == null) { 4895 continue; 4896 } 4897 // Subquery RHS would short-circuit later with 4898 // UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED; ignore here. 4899 if (containsAnySubqueryExpression(rhs)) { 4900 continue; 4901 } 4902 List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider); 4903 rejectSourceRefsForBySource(rhsRefs, aliasToTableQName, 4904 aliasToSubIdx, 4905 "MERGE WHEN NOT MATCHED BY SOURCE UPDATE SET " 4906 + "assignment #" + (i + 1) + " RHS", 4907 merge); 4908 } 4909 } 4910 4911 /** 4912 * Slice 94 — process one WHEN MATCHED THEN UPDATE SET action. 4913 * Each {@code TResultColumn} carries an assignment_t TExpression 4914 * whose leftOperand is the SET LHS (target column reference) and 4915 * whose rightOperand is the value expression. We emit one 4916 * {@link LineageEdge} per (target col, RHS source ref) pair. 4917 */ 4918 private static void buildMergeUpdateAction(TMergeUpdateClause upd, 4919 String targetQName, 4920 TTable targetTable, 4921 NameBindingProvider provider, 4922 Map<String, String> seenTargetCols, 4923 List<LineageEdge> lineage, 4924 Set<String> emittedEdgeKeys, 4925 Map<String, String> aliasToTableQName, 4926 Map<String, Integer> aliasToSubIdx, 4927 Set<String> targetAliases, 4928 TMergeSqlStatement merge) { 4929 TResultColumnList sets = upd.getUpdateColumnList(); 4930 if (sets == null || sets.size() == 0) { 4931 return; 4932 } 4933 for (int i = 0; i < sets.size(); i++) { 4934 TResultColumn rc = sets.getResultColumn(i); 4935 TExpression assignment = (rc == null) ? null : rc.getExpr(); 4936 if (assignment == null 4937 || assignment.getExpressionType() != EExpressionType.assignment_t) { 4938 throw new SemanticIRBuildException(Diagnostic.error( 4939 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 4940 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 4941 + " is not a simple column-value assignment_t", 4942 merge)); 4943 } 4944 TExpression lhs = assignment.getLeftOperand(); 4945 TExpression rhs = assignment.getRightOperand(); 4946 if (lhs == null || rhs == null) { 4947 throw new SemanticIRBuildException(Diagnostic.error( 4948 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 4949 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 4950 + " is missing an operand", 4951 merge)); 4952 } 4953 if (lhs.getExpressionType() == EExpressionType.list_t) { 4954 throw new SemanticIRBuildException(Diagnostic.error( 4955 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 4956 "MERGE WHEN MATCHED UPDATE SET tuple assignment " 4957 + "'(a, b) = ...' is not supported", 4958 merge)); 4959 } 4960 if (lhs.getExpressionType() != EExpressionType.simple_object_name_t) { 4961 throw new SemanticIRBuildException(Diagnostic.error( 4962 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 4963 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 4964 + " LHS is expressionType=" + lhs.getExpressionType() 4965 + "; slice 94 admits simple column references only", 4966 merge)); 4967 } 4968 TObjectName targetCol = lhs.getObjectOperand(); 4969 if (targetCol == null) { 4970 throw new SemanticIRBuildException(Diagnostic.error( 4971 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 4972 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 4973 + " LHS has no TObjectName operand", 4974 merge)); 4975 } 4976 // Codex round-1 diff Q3 NO fix: SET LHS qualifier must be 4977 // either the target alias or the target qualified name. 4978 // A foreign qualifier (e.g. `s.name` on a SET LHS pointing 4979 // at source `s`) silently treated as a target column would 4980 // produce a wrong target column spelling. 4981 String rawSpelling = targetCol.toString(); 4982 String colSpelling = validateAndStripSetLhsQualifier( 4983 rawSpelling, targetTable, targetQName, merge); 4984 // Subquery / window on RHS — reuse existing codes per 4985 // plan v3 §B (codex round-1 Q2 NO fix). 4986 if (containsAnySubqueryExpression(rhs)) { 4987 throw new SemanticIRBuildException(Diagnostic.error( 4988 DiagnosticCode.UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED, 4989 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 4990 + " right-hand side contains a subquery; " 4991 + "slice 94 admits scalar-only RHS expressions", 4992 merge)); 4993 } 4994 rejectWindowFunctionInScope(rhs, "MERGE WHEN MATCHED UPDATE SET RHS"); 4995 4996 String lowerKey = colSpelling.toLowerCase(Locale.ROOT); 4997 if (!seenTargetCols.containsKey(lowerKey)) { 4998 seenTargetCols.put(lowerKey, colSpelling); 4999 } 5000 // Per-WHEN action lineage: TABLE_COLUMN(target,col) ← <RHS ref> 5001 List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider); 5002 for (ColumnRef src : rhsRefs) { 5003 emitMergeLineageEdge(targetQName, colSpelling, src, 5004 lineage, emittedEdgeKeys, aliasToTableQName, 5005 aliasToSubIdx, targetAliases); 5006 } 5007 } 5008 } 5009 5010 /** 5011 * Slice 94 — process one WHEN NOT MATCHED THEN INSERT (cols) VALUES (exprs) 5012 * action. Emits one {@link LineageEdge} per (insert col, source ref) 5013 * pair, plus arity validation between the explicit column list and 5014 * VALUES list. If the column list is omitted, we cannot derive 5015 * target column names — the slice rejects defensively. 5016 */ 5017 private static void buildMergeInsertAction(TMergeInsertClause ins, 5018 String targetQName, 5019 TTable targetTable, 5020 NameBindingProvider provider, 5021 Map<String, String> seenTargetCols, 5022 List<LineageEdge> lineage, 5023 Set<String> emittedEdgeKeys, 5024 Map<String, String> aliasToTableQName, 5025 Map<String, Integer> aliasToSubIdx, 5026 Set<String> targetAliases, 5027 TMergeSqlStatement merge) { 5028 TResultColumnList values = ins.getValuelist(); 5029 gudusoft.gsqlparser.nodes.TObjectNameList colList = ins.getColumnList(); 5030 if (values == null || values.size() == 0) { 5031 throw new SemanticIRBuildException(Diagnostic.error( 5032 DiagnosticCode.MERGE_INSERT_DEFAULT_VALUES_NOT_SUPPORTED, 5033 "MERGE WHEN NOT MATCHED INSERT has no VALUES list " 5034 + "(DEFAULT VALUES / row-type forms not supported)", 5035 merge)); 5036 } 5037 // If an explicit column list is present, validate arity. 5038 if (colList != null && colList.size() > 0) { 5039 if (colList.size() != values.size()) { 5040 throw new SemanticIRBuildException(Diagnostic.error( 5041 DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH, 5042 "MERGE WHEN NOT MATCHED INSERT column list has " 5043 + colList.size() + " column(s) but VALUES " 5044 + "list has " + values.size(), 5045 merge)); 5046 } 5047 } 5048 for (int i = 0; i < values.size(); i++) { 5049 TResultColumn rc = values.getResultColumn(i); 5050 TExpression rhs = (rc == null) ? null : rc.getExpr(); 5051 if (rhs == null) { 5052 throw new SemanticIRBuildException(Diagnostic.error( 5053 DiagnosticCode.MERGE_INSERT_DEFAULT_VALUES_NOT_SUPPORTED, 5054 "MERGE WHEN NOT MATCHED INSERT VALUES item #" 5055 + (i + 1) + " has no expression", 5056 merge)); 5057 } 5058 if (containsAnySubqueryExpression(rhs)) { 5059 throw new SemanticIRBuildException(Diagnostic.error( 5060 DiagnosticCode.MERGE_INSERT_VALUES_HAS_SUBQUERY_NOT_SUPPORTED, 5061 "MERGE WHEN NOT MATCHED INSERT VALUES item #" 5062 + (i + 1) + " contains a subquery; slice 94 " 5063 + "admits scalar-only VALUES expressions", 5064 merge)); 5065 } 5066 rejectWindowFunctionInScope(rhs, "MERGE INSERT VALUES"); 5067 5068 // Target column name. If the explicit column list is 5069 // omitted, slice 94 does not synthesize positional target 5070 // column names (the catalog is required to map by 5071 // position; the current builder does not consume catalog 5072 // ordering). We still emit lineage edges from a synth 5073 // "__merge_insert_pos_<i>__" target column so source refs 5074 // are observable; users with an explicit column list get 5075 // the verbatim spelling. 5076 String colSpelling; 5077 if (colList != null && colList.size() > 0) { 5078 TObjectName col = colList.getObjectName(i); 5079 // INSERT column list is conventionally bare (no 5080 // qualifier), but Oracle / SQL Server allow 5081 // target-qualified spellings; strip them and reject 5082 // foreign qualifiers (codex round-1 diff Q3 NO fix). 5083 colSpelling = (col == null) ? ("__merge_insert_pos_" + i + "__") 5084 : validateAndStripSetLhsQualifier(col.toString(), 5085 targetTable, targetQName, merge); 5086 } else { 5087 colSpelling = "__merge_insert_pos_" + i + "__"; 5088 } 5089 String lowerKey = colSpelling.toLowerCase(Locale.ROOT); 5090 if (!seenTargetCols.containsKey(lowerKey)) { 5091 seenTargetCols.put(lowerKey, colSpelling); 5092 } 5093 List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider); 5094 for (ColumnRef src : rhsRefs) { 5095 emitMergeLineageEdge(targetQName, colSpelling, src, 5096 lineage, emittedEdgeKeys, aliasToTableQName, 5097 aliasToSubIdx, targetAliases); 5098 } 5099 } 5100 } 5101 5102 /** 5103 * Slice 94 — emit a single MERGE per-WHEN action lineage edge: 5104 * {@code TABLE_COLUMN(targetQName, colSpelling) ← <src ref>}. 5105 * Deduplicates on a lower-case key so the same (target column, 5106 * source ref) pair appearing in multiple WHEN clauses produces 5107 * one edge (codex round-2 Q4 confirmed YES on this dedup 5108 * strategy). 5109 */ 5110 private static void emitMergeLineageEdge(String targetQName, 5111 String colSpelling, 5112 ColumnRef src, 5113 List<LineageEdge> lineage, 5114 Set<String> emittedEdgeKeys, 5115 Map<String, String> aliasToTableQName, 5116 Map<String, Integer> aliasToSubIdx, 5117 Set<String> targetAliases) { 5118 if (src == null || colSpelling == null || colSpelling.isEmpty()) { 5119 return; 5120 } 5121 String srcAlias = src.getRelationAlias(); 5122 String srcCol = src.getColumnName(); 5123 if (srcCol == null || srcCol.isEmpty()) { 5124 return; 5125 } 5126 if (srcAlias == null) srcAlias = ""; 5127 String aliasKey = srcAlias.toLowerCase(Locale.ROOT); 5128 // Codex round-1 diff Q1 BLOCKING fix: skip only if the alias 5129 // is a target alias, NOT if the alias resolves to a same-named 5130 // table. Self-merge (USING with same name as target) must 5131 // distinguish target from USING by alias identity, not by 5132 // resolved table name. 5133 if (targetAliases.contains(aliasKey)) { 5134 return; 5135 } 5136 // Map alias → LineageRef (TABLE_COLUMN or STATEMENT_OUTPUT). 5137 LineageRef toRef; 5138 if (aliasToSubIdx.containsKey(aliasKey)) { 5139 toRef = LineageRef.statementOutput( 5140 aliasToSubIdx.get(aliasKey), srcCol); 5141 } else if (aliasToTableQName.containsKey(aliasKey)) { 5142 toRef = LineageRef.tableColumn( 5143 aliasToTableQName.get(aliasKey), srcCol); 5144 } else { 5145 // Unknown alias — skip to avoid emitting a bogus edge. 5146 // The ref still surfaces on joinColumnRefs[] (ON / WHEN-AND). 5147 return; 5148 } 5149 // Codex round-1 diff Q2 NO fix: dedup on resolved LineageRef 5150 // identity (not raw alias), so `s.name` (alias) and 5151 // `managers.name` (qualified name) coming from the SAME 5152 // resolved source produce ONE edge. The key embeds the toRef's 5153 // canonical form: STATEMENT_OUTPUT(idx,col) or 5154 // TABLE_COLUMN(qname,col) — both are lower-cased. 5155 String resolvedKey; 5156 if (toRef.getKind() == LineageRef.Kind.STATEMENT_OUTPUT) { 5157 resolvedKey = "STMT_OUT::" + toRef.getStatementIndex() + "::" 5158 + (toRef.getOutputName() == null ? "" : toRef.getOutputName()); 5159 } else { 5160 resolvedKey = "TBL_COL::" 5161 + (toRef.getQualifiedName() == null ? "" : toRef.getQualifiedName()) 5162 + "::" 5163 + (toRef.getColumnName() == null ? "" : toRef.getColumnName()); 5164 } 5165 String key = (targetQName + "::" + colSpelling + "::" 5166 + resolvedKey).toLowerCase(Locale.ROOT); 5167 if (emittedEdgeKeys.add(key)) { 5168 lineage.add(new LineageEdge( 5169 LineageRef.tableColumn(targetQName, colSpelling), 5170 toRef)); 5171 } 5172 } 5173 5174 /** 5175 * Slice 94 — validate the SET LHS / INSERT column-list spelling 5176 * and strip a leading target qualifier. Codex round-1 diff Q3 NO 5177 * fix: previously the helper returned a foreign-qualified spelling 5178 * unchanged, which silently produced a wrong target column (e.g. 5179 * {@code "s.name"} would land in the target columns list verbatim 5180 * instead of being rejected). 5181 * 5182 * <p>Admit rules: 5183 * <ul> 5184 * <li>Unqualified bare name: return unchanged.</li> 5185 * <li>Qualified by target alias or qualified name: strip.</li> 5186 * <li>Qualified by anything else: reject as 5187 * {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED} 5188 * (the same code used for slice-80 UPDATE-LHS shape rejects, 5189 * message text discriminates by mentioning the foreign 5190 * qualifier).</li> 5191 * </ul> 5192 */ 5193 private static String validateAndStripSetLhsQualifier(String spelling, 5194 TTable targetTable, 5195 String targetQName, 5196 TMergeSqlStatement merge) { 5197 if (spelling == null) return spelling; 5198 int dot = spelling.indexOf('.'); 5199 if (dot <= 0) return spelling; 5200 String qualifier = spelling.substring(0, dot); 5201 String bare = spelling.substring(dot + 1); 5202 String targetAlias = effectiveAliasOf(targetTable); 5203 if (targetAlias != null 5204 && qualifier.equalsIgnoreCase(targetAlias)) { 5205 return bare; 5206 } 5207 if (qualifier.equalsIgnoreCase(targetQName)) { 5208 return bare; 5209 } 5210 throw new SemanticIRBuildException(Diagnostic.error( 5211 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 5212 "MERGE SET LHS / INSERT column qualifier '" + qualifier 5213 + "' does not match the target table; slice 94 " 5214 + "admits target-qualified or unqualified target " 5215 + "column references only", 5216 merge)); 5217 } 5218 5219 /** 5220 * Slice 84 — process one FROM-side source table for joined 5221 * {@link #buildDelete}. Mirrors slice-82 {@link #buildUpdateRelation}. 5222 * Applies the slice-84 reject contract for nested-join wrappers, 5223 * then appends a TABLE-kind {@link RelationSource} unless the 5224 * table is the target (reference-identity filter — clean IR 5225 * semantics: relations[] models read-side sources only). For 5226 * subquery sources (already extracted in step 4.7), publishes a 5227 * SUBQUERY-kind {@link RelationSource} so the inScope-enhanced 5228 * provider can route {@code sub.col} references. 5229 * 5230 * <p>Null-driver guard: probed PG 5231 * {@code DELETE FROM e USING (t1 JOIN t2 ON …)} returns 5232 * {@code refJoin[0].getTable() == null}. Silent skip mirrors 5233 * slice-82's null guard for the analogous UPDATE case; 5234 * documented as a known limitation (parenthesized JOIN-in-USING 5235 * is opaque to {@code relations[]} though WHERE refs still bind 5236 * via Resolver2). 5237 */ 5238 private static void buildDeleteRelation(TTable t, TTable targetTable, 5239 List<RelationSource> relations, 5240 TDeleteSqlStatement delete, 5241 Map<String, Integer> cteNameToStatementIndex) { 5242 if (t == null) { 5243 return; // defensive — parenthesized JOIN-in-USING surfaces null 5244 } 5245 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 5246 // Slice 84 — admit FROM-side subqueries. The inner SELECT 5247 // has already been extracted as its own StatementGraph by 5248 // extractDeleteFromSubqueries. Publish a SUBQUERY-kind 5249 // RelationSource so the inScope-enhanced provider routes 5250 // `sub.col` references correctly. Mirrors slice-83 5251 // buildUpdateRelation's subquery branch. 5252 String subAlias = effectiveAliasOf(t); 5253 if (subAlias != null && !subAlias.isEmpty()) { 5254 relations.add(new RelationSource(subAlias, 5255 new RelationBinding(RelationKind.SUBQUERY, subAlias))); 5256 } 5257 return; 5258 } 5259 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 5260 // Defensive: TTable wrapping a TJoin. Not reached by any 5261 // observed parser path on supported dialects (slice-82 5262 // precedent — parenthesized JOIN-in-USING surfaces a null 5263 // driver, not a join-typed TTable). Distinct DiagnosticCode 5264 // per slice-80's message-text-discrimination contract. 5265 throw new SemanticIRBuildException(Diagnostic.error( 5266 DiagnosticCode.DELETE_FROM_NESTED_JOIN_NOT_SUPPORTED, 5267 "DELETE FROM source is a nested join wrapper; " 5268 + "slice 84 admits simple table / subquery " 5269 + "FROM sources only", 5270 delete)); 5271 } 5272 // Reference-identity filter: target's own TTable instance is 5273 // excluded from relations[]. Different TTable instances with 5274 // the same qualified name (e.g. MSSQL `DELETE FROM t FROM t 5275 // spqh JOIN sp` where target identity A and FROM-driver 5276 // identity B share name "t") both stay — the catalog-miss 5277 // WARN walker's pass-1-target-then-pass-2-relations ordering 5278 // (slice 83) deduplicates by qualified name. 5279 if (t == targetTable) { 5280 return; 5281 } 5282 TObjectName tName = t.getTableName(); 5283 if (tName == null) { 5284 return; // defensive 5285 } 5286 // Slice 106 — FROM-side CTE detection. When the FROM-side table 5287 // is an objectname-typed reference whose bare name matches a 5288 // declared CTE in this DELETE's outer WITH clause, emit a 5289 // SUBQUERY-kind RelationSource pointing at the CTE statement 5290 // (mirrors slice-105 buildUpdateRelation). The slice-77 catalog- 5291 // miss WARN walker filters to RelationKind.TABLE so CTE-bound 5292 // relations are naturally skipped, even when the catalog also 5293 // declares the same name (slice-105 §G / §X precedent). 5294 // 5295 // Explicit objectname guard (codex round-1 NICE Q3): subquery / 5296 // join table types are handled by the early returns above; this 5297 // guard documents the contract and makes the branch resilient 5298 // if a future TTable type is added. 5299 if (cteNameToStatementIndex != null 5300 && !cteNameToStatementIndex.isEmpty() 5301 && t.getTableType() 5302 == gudusoft.gsqlparser.ETableSource.objectname) { 5303 String bareName = tName.toString(); 5304 if (bareName != null && !bareName.isEmpty()) { 5305 String bareNameLower = bareName.toLowerCase(Locale.ROOT); 5306 if (cteNameToStatementIndex.containsKey(bareNameLower)) { 5307 String cteAlias = effectiveAliasOf(t); 5308 if (cteAlias == null || cteAlias.isEmpty()) { 5309 cteAlias = bareName; 5310 } 5311 relations.add(new RelationSource(cteAlias, 5312 new RelationBinding(RelationKind.SUBQUERY, cteAlias))); 5313 return; 5314 } 5315 } 5316 } 5317 relations.add(new RelationSource(effectiveAliasOf(t), 5318 new RelationBinding(RelationKind.TABLE, tName.toString()))); 5319 } 5320 5321 /** 5322 * Slice 84 — process one {@link TJoinItem} for joined 5323 * {@link #buildDelete}. Mirrors slice-82 {@link #buildUpdateJoinItem}. 5324 * Applies the slice-84 reject contract for USING / NATURAL / 5325 * subquery-in-ON, processes the right-side table via 5326 * {@link #buildDeleteRelation}, and collects ON-clause column 5327 * refs into {@code joinRefs} via the shared 5328 * {@link #collectColumnRefs} helper. 5329 */ 5330 private static void buildDeleteJoinItem(TJoinItem item, TTable targetTable, 5331 NameBindingProvider provider, 5332 List<RelationSource> relations, 5333 java.util.LinkedHashSet<ColumnRef> joinRefs, 5334 TDeleteSqlStatement delete, 5335 Map<String, Integer> cteNameToStatementIndex) { 5336 if (item == null) return; 5337 if (item.getUsingColumns() != null && item.getUsingColumns().size() > 0) { 5338 throw new SemanticIRBuildException(Diagnostic.error( 5339 DiagnosticCode.DELETE_FROM_JOIN_USING_NOT_SUPPORTED, 5340 "DELETE FROM join uses USING(...); slice 84 admits " 5341 + "JOIN ON / CROSS JOIN / comma-FROM only", 5342 item)); 5343 } 5344 if (isNaturalJoinType(item.getJoinType())) { 5345 throw new SemanticIRBuildException(Diagnostic.error( 5346 DiagnosticCode.DELETE_FROM_JOIN_NATURAL_NOT_SUPPORTED, 5347 "DELETE FROM uses NATURAL JOIN; slice 84 admits " 5348 + "JOIN ON / CROSS JOIN / comma-FROM only", 5349 item)); 5350 } 5351 // Right-side table: apply the same source-shape rejects + 5352 // identity filter as the driver table. Slice 106 — threads 5353 // cteNameToStatementIndex so right-side CTE refs (MSSQL 5354 // `FROM target t JOIN cte ON …`) get SUBQUERY-kind emission. 5355 buildDeleteRelation(item.getTable(), targetTable, relations, delete, 5356 cteNameToStatementIndex); 5357 // ON-clause refs: subquery rejects with slice-84 code; 5358 // window function reuses CLAUSE_WINDOW_FUNCTION_LEAK via the 5359 // shared helper. CROSS JOIN has no ON; skip the walk entirely. 5360 TExpression onCond = item.getOnCondition(); 5361 if (onCond == null) return; 5362 if (containsAnySubqueryExpression(onCond)) { 5363 throw new SemanticIRBuildException(Diagnostic.error( 5364 DiagnosticCode.DELETE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED, 5365 "DELETE FROM JOIN ON condition contains a subquery; " 5366 + "slice 84 admits scalar predicates only", 5367 item)); 5368 } 5369 rejectWindowFunctionInScope(onCond, "DELETE FROM JOIN ON"); 5370 joinRefs.addAll(collectColumnRefs(onCond, provider)); 5371 } 5372 5373 /** 5374 * Slice 84 — extract every FROM-side subquery in 5375 * {@code delete.getReferenceJoins()} as its own 5376 * {@link StatementGraph} appended to {@code stmts} before the 5377 * DELETE itself. Walks both the driver TTable of each TJoin AND 5378 * each JoinItem's right table. Returns an alias → stmts-index 5379 * map so the consuming DELETE can (a) build its in-scope column 5380 * map via {@link #buildDeleteInScopeMap}, and (b) bind 5381 * {@code sub.col} references in WHERE / ON via the 5382 * inScope-enhanced provider. 5383 * 5384 * <p>Mirrors slice-83 {@link #extractUpdateFromSubqueries} but 5385 * walks {@code delete.getReferenceJoins()} instead of 5386 * {@code update.getJoins()}. Reuses the SELECT-side 5387 * {@link #processDirectSubqueryTable} verbatim, forwarding the 5388 * outer-WITH {@code cteNameToStatementIndex} + 5389 * {@code ctePublishedColumns} (slice 106) so a nested SELECT in 5390 * an extracted FROM-subquery body can resolve outer-CTE refs. 5391 * Pre-slice-106 the maps were always empty because slice 81 5392 * rejected top-level WITH on DELETE 5393 * ({@link DiagnosticCode#DELETE_CTE_NOT_SUPPORTED}). 5394 * 5395 * <p>No mutation-guard wrapper: buildDelete owns fresh local 5396 * lists and exceptions propagate cleanly to the caller. 5397 */ 5398 private static Map<String, Integer> extractDeleteFromSubqueries( 5399 TDeleteSqlStatement delete, 5400 NameBindingProvider provider, 5401 List<StatementGraph> stmts, 5402 List<LineageEdge> lineage, 5403 Map<String, Integer> cteNameToStatementIndex, 5404 Map<String, List<String>> ctePublishedColumns) { 5405 Map<String, Integer> aliasToIndex = new HashMap<>(); 5406 TJoinList refJoins = delete.getReferenceJoins(); 5407 if (refJoins == null) return aliasToIndex; 5408 // Slice 106 — forward the outer-WITH CTE maps so a nested SELECT 5409 // inside an extracted FROM-subquery body can resolve outer-WITH 5410 // CTE references. Resolver2 wires CTEScope; the maps are 5411 // forwarded for parity with the SELECT / MERGE / UPDATE call 5412 // sites and so the §N test for 5413 // `USING (SELECT … FROM cte) sub` produces the expected 5414 // cross-stmt lineage edge to the CTE body. 5415 Map<String, Integer> cteMap = cteNameToStatementIndex == null 5416 ? Collections.<String, Integer>emptyMap() 5417 : cteNameToStatementIndex; 5418 Map<String, List<String>> ctePublished = ctePublishedColumns == null 5419 ? Collections.<String, List<String>>emptyMap() 5420 : ctePublishedColumns; 5421 for (int ji = 0; ji < refJoins.size(); ji++) { 5422 TJoin join = refJoins.getJoin(ji); 5423 // Driver table — may be a subquery (PG / SF / BQ / RS 5424 // `DELETE FROM t USING (SELECT …) sub` shape). 5425 processDirectSubqueryTable(join.getTable(), provider, 5426 stmts, lineage, cteMap, ctePublished, aliasToIndex); 5427 TJoinItemList items = join.getJoinItems(); 5428 if (items == null) continue; 5429 for (int i = 0; i < items.size(); i++) { 5430 TJoinItem item = items.getJoinItem(i); 5431 if (item == null) continue; 5432 // Right-side table of a JoinItem — may be a subquery 5433 // (MSSQL / PG `DELETE FROM t FROM x JOIN (SELECT …) 5434 // sub ON …` shape). 5435 processDirectSubqueryTable(item.getTable(), provider, 5436 stmts, lineage, cteMap, ctePublished, aliasToIndex); 5437 } 5438 } 5439 return aliasToIndex; 5440 } 5441 5442 /** 5443 * Slice 84 — build an effective-alias-keyed in-scope map publishing 5444 * each extracted DELETE FROM-subquery's output column names. 5445 * Mirrors slice-83 {@link #buildUpdateInScopeMap} but walks 5446 * {@code delete.getReferenceJoins()}. 5447 * 5448 * <p>Base-table FROM-side relations do not need an entry: their 5449 * column resolution stays on the Resolver2 catalog path 5450 * (probed correct for PG / MSSQL DELETE — see slice-84 plan 5451 * §Codex Q4 + Q11). 5452 */ 5453 private static Map<String, List<String>> buildDeleteInScopeMap( 5454 TDeleteSqlStatement delete, 5455 Map<String, Integer> subqueryAliasToIndex, 5456 List<StatementGraph> stmts, 5457 Map<String, Integer> cteNameToStatementIndex, 5458 Map<String, List<String>> ctePublishedColumns) { 5459 Map<String, List<String>> result = new HashMap<>(); 5460 boolean haveSubq = subqueryAliasToIndex != null 5461 && !subqueryAliasToIndex.isEmpty(); 5462 boolean haveCte = cteNameToStatementIndex != null 5463 && !cteNameToStatementIndex.isEmpty(); 5464 if (!haveSubq && !haveCte) { 5465 return result; 5466 } 5467 TJoinList refJoins = delete.getReferenceJoins(); 5468 if (refJoins == null) return result; 5469 for (int ji = 0; ji < refJoins.size(); ji++) { 5470 TJoin join = refJoins.getJoin(ji); 5471 addDeleteRelationToInScopeMap(join.getTable(), 5472 subqueryAliasToIndex, stmts, result, 5473 cteNameToStatementIndex, ctePublishedColumns); 5474 TJoinItemList items = join.getJoinItems(); 5475 if (items == null) continue; 5476 for (int i = 0; i < items.size(); i++) { 5477 TJoinItem item = items.getJoinItem(i); 5478 if (item == null) continue; 5479 addDeleteRelationToInScopeMap(item.getTable(), 5480 subqueryAliasToIndex, stmts, result, 5481 cteNameToStatementIndex, ctePublishedColumns); 5482 } 5483 } 5484 return result; 5485 } 5486 5487 private static void addDeleteRelationToInScopeMap(TTable t, 5488 Map<String, Integer> subqueryAliasToIndex, 5489 List<StatementGraph> stmts, 5490 Map<String, List<String>> result, 5491 Map<String, Integer> cteNameToStatementIndex, 5492 Map<String, List<String>> ctePublishedColumns) { 5493 if (t == null) return; 5494 // Slice 106 — CTE-as-FROM-relation in-scope publication. When 5495 // the FROM-side table is an objectname-typed reference whose 5496 // bare name matches a declared outer CTE, publish the CTE's 5497 // own column names against the FROM-side effective alias so 5498 // WHERE / ON / RETURNING refs against the CTE alias bind 5499 // correctly. Mirrors slice-105 addUpdateRelationToInScopeMap. 5500 if (cteNameToStatementIndex != null 5501 && !cteNameToStatementIndex.isEmpty() 5502 && ctePublishedColumns != null 5503 && t.getTableType() 5504 == gudusoft.gsqlparser.ETableSource.objectname) { 5505 TObjectName tName = t.getTableName(); 5506 if (tName != null) { 5507 String bare = tName.toString(); 5508 if (bare != null && !bare.isEmpty()) { 5509 String bareLower = bare.toLowerCase(Locale.ROOT); 5510 if (cteNameToStatementIndex.containsKey(bareLower)) { 5511 String aliasKey = effectiveAliasLowerCaseOrNull(t); 5512 if (aliasKey == null) aliasKey = bareLower; 5513 List<String> cols = ctePublishedColumns.get(bareLower); 5514 if (cols != null) { 5515 result.put(aliasKey, cols); 5516 } 5517 return; 5518 } 5519 } 5520 } 5521 } 5522 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) { 5523 return; 5524 } 5525 if (subqueryAliasToIndex == null) { 5526 return; 5527 } 5528 String key = effectiveAliasLowerCaseOrNull(t); 5529 if (key == null) return; 5530 Integer idx = subqueryAliasToIndex.get(key); 5531 if (idx == null) return; 5532 result.put(key, outputColumnNames(stmts.get(idx))); 5533 } 5534 5535 /** 5536 * Slice 92 — returns {@code true} when the MySQL DELETE statement is a 5537 * self-reference single-target form ({@code DELETE T1 FROM T1 [WHERE …]}) 5538 * that is semantically equivalent to {@code DELETE FROM T1 [WHERE …]}. 5539 * 5540 * <p>The check requires ALL of the following (codex plan-review Q1+Q5 5541 * BLOCKING fix — checking only {@code joins[0]} is insufficient because 5542 * {@code DELETE T1 FROM T2} also has {@code joins.size==1} and 5543 * {@code joins[0].table=="T1"==targetQName}, yet the FROM clause points 5544 * to a different table): 5545 * <ol> 5546 * <li>{@code joins.size == 1} — exactly one MySQL target list entry.</li> 5547 * <li>{@code getReferenceJoins().size() == 1} — exactly one FROM clause 5548 * table.</li> 5549 * <li>{@code joins[0]} has no JoinItems — must be a plain table, not a 5550 * JOIN chain.</li> 5551 * <li>{@code refJoins[0]} has no JoinItems — same constraint.</li> 5552 * <li>{@code joins[0].table.name.toLowerCase() == targetQName.toLowerCase()}. 5553 * </li> 5554 * <li>{@code refJoins[0].table.name.toLowerCase() == targetQName.toLowerCase()}. 5555 * </li> 5556 * </ol> 5557 */ 5558 private static boolean isMysqlSelfReferenceDelete( 5559 TDeleteSqlStatement delete, String targetQName) { 5560 if (delete.joins == null || delete.joins.size() != 1) return false; 5561 TJoinList ref = delete.getReferenceJoins(); 5562 if (ref == null || ref.size() != 1) return false; 5563 TJoin join0 = delete.joins.getJoin(0); 5564 if (join0.getJoinItems() != null && join0.getJoinItems().size() > 0) { 5565 return false; 5566 } 5567 TJoin ref0 = ref.getJoin(0); 5568 if (ref0.getJoinItems() != null && ref0.getJoinItems().size() > 0) { 5569 return false; 5570 } 5571 TTable joinTable = join0.getTable(); 5572 if (joinTable == null || joinTable.getTableName() == null) return false; 5573 TTable refTable = ref0.getTable(); 5574 if (refTable == null || refTable.getTableName() == null) return false; 5575 String lowerTarget = targetQName.toLowerCase(java.util.Locale.ROOT); 5576 String joinName = joinTable.getTableName().toString() 5577 .toLowerCase(java.util.Locale.ROOT); 5578 String refName = refTable.getTableName().toString() 5579 .toLowerCase(java.util.Locale.ROOT); 5580 5581 // Codex diff-review P1 fix: MySQL allows the alias in the DELETE 5582 // target list instead of the table name: 5583 // DELETE t FROM T1 AS t WHERE t.id = 1 5584 // In this form joins[0].table.name = "t" (the alias used in the 5585 // delete-list) while targetQName = "T1" (from getTargetTable() 5586 // which the parser resolves to the real table). Accept the target 5587 // table's alias as a valid joins[0] match alongside the table name. 5588 String targetAlias = null; 5589 if (delete.getTargetTable() != null 5590 && delete.getTargetTable().getAliasClause() != null 5591 && delete.getTargetTable().getAliasClause().getAliasName() != null) { 5592 String a = delete.getTargetTable().getAliasClause() 5593 .getAliasName().toString(); 5594 if (a != null && !a.isEmpty()) { 5595 targetAlias = a.toLowerCase(java.util.Locale.ROOT); 5596 } 5597 } 5598 boolean joinMatchesTarget = joinName.equals(lowerTarget) 5599 || (targetAlias != null && joinName.equals(targetAlias)); 5600 // refJoins[0].table must always be the real table name (= targetQName). 5601 return joinMatchesTarget && refName.equals(lowerTarget); 5602 } 5603 5604 /** 5605 * Per-result-column metadata about an extracted scalar-subquery 5606 * projection (slice 11). {@link #statementIndex} points to the 5607 * inner body statement; {@link #innerOutputName} is the inner 5608 * SELECT's single projected output name (used to wire the 5609 * STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edge). 5610 */ 5611 private static final class ScalarInfo { 5612 final int statementIndex; 5613 final String innerOutputName; 5614 ScalarInfo(int statementIndex, String innerOutputName) { 5615 this.statementIndex = statementIndex; 5616 this.innerOutputName = innerOutputName; 5617 } 5618 } 5619 5620 /** 5621 * Walk the consuming SELECT's FROM list. For every {@link TTable} of 5622 * type {@link gudusoft.gsqlparser.ETableSource#subquery}, recursively 5623 * build the inner statement, append it to {@code stmts}, emit its own 5624 * lineage edges, and record alias→statementIndex. The returned map is 5625 * scoped to this single consuming statement so duplicate aliases 5626 * across different scopes do not collide. 5627 * 5628 * <p>Slice 17: extraction now walks BOTH sides of every JOIN 5629 * ({@code TJoin.getTable()} for the left, {@code joinItems[i].getTable()} 5630 * for each right) and recurses into nested FROM-subquery bodies. Each 5631 * recursive level pre-extracts its own children before calling 5632 * {@code buildSelectStatement}, preserving the 5633 * {@code BodyIndexes}-required ordering (innermost body before 5634 * its consumer). FROM-subquery bodies still recurse with 5635 * {@code allowScalarProjectionSubqueries=false} (slice-15 invariant 5636 * pinned by {@code Slice15Test.scalarProjectionInsideFromSubqueryBodyStillRejected}). 5637 * 5638 * <p>Slice 18 lifts CTE bodies (the non-set-op CTE-body branch in 5639 * {@link #build} now invokes this extractor with 5640 * {@code allowFromSubqueries=true}). Still rejected: subqueries with 5641 * no alias, FROM-subqueries inside a scalar body / set-op branch / 5642 * set-op CTE body (each enforced by the caller's 5643 * {@code allowFromSubqueries=false}), and predicate subqueries inside 5644 * the FROM-subquery body's WHERE / JOIN ON / GROUP BY (slice-17 5645 * helper {@link #rejectSubqueriesInFromSubqueryBodyClauses}). 5646 */ 5647 private static Map<String, Integer> extractFromSubqueriesAsStatements( 5648 TSelectSqlStatement consumer, 5649 NameBindingProvider consumerProvider, 5650 List<StatementGraph> stmts, 5651 List<LineageEdge> lineage, 5652 Map<String, Integer> cteNameToStatementIndex, 5653 Map<String, List<String>> ctePublishedColumns) { 5654 Map<String, Integer> aliasToIndex = new HashMap<>(); 5655 if (consumer.joins == null) return aliasToIndex; 5656 // Slice 17 mutation-free preflight: walk the entire direct 5657 // FROM/JOIN list once and reject before any mutation of 5658 // stmts/lineage. Catches comma-FROM, anonymous subqueries, 5659 // unsupported join shapes, and ALL same-level alias collisions 5660 // (base AND subquery, since rejectDuplicateAliases inside 5661 // buildRelations only catches them later, after this level's 5662 // subquery body has already landed in stmts). 5663 preflightDirectFromList(consumer); 5664 5665 // Slice 17: walk both sides of every join and process each 5666 // direct subquery via the same helper so left/right can't drift. 5667 for (TJoin join : consumer.joins) { 5668 processDirectSubqueryTable(join.getTable(), 5669 consumerProvider, stmts, lineage, 5670 cteNameToStatementIndex, ctePublishedColumns, aliasToIndex); 5671 TJoinItemList items = join.getJoinItems(); 5672 if (items == null) continue; 5673 for (int i = 0; i < items.size(); i++) { 5674 TJoinItem item = items.getJoinItem(i); 5675 if (item == null) continue; 5676 processDirectSubqueryTable(item.getTable(), 5677 consumerProvider, stmts, lineage, 5678 cteNameToStatementIndex, ctePublishedColumns, aliasToIndex); 5679 } 5680 } 5681 return aliasToIndex; 5682 } 5683 5684 /** 5685 * Slice 17 mutation-free preflight for the consumer's direct 5686 * FROM/JOIN list. Validates structural invariants BEFORE any 5687 * subquery body is appended to {@code stmts} so a deferred 5688 * failure (e.g. on the second of two siblings) doesn't strand 5689 * earlier-sibling output in the program. 5690 * 5691 * <p>Slice 62 (codex plan-review round 1): the comma-FROM 5692 * reject was removed here. The preflight runs only for 5693 * {@code allowFromSubqueries=true} paths (outer SELECT, CTE 5694 * body, FROM-subquery body recursion) — exactly the paths 5695 * that admit comma-FROM under slice 62. Synthetic body 5696 * contexts (scalar / set-op-branch / set-op-CTE / predicate) 5697 * do not run this preflight; they reach the gated reject in 5698 * {@link #buildRelations} (and predicate bodies hit the 5699 * earlier slice-62 reject inside 5700 * {@link #preflightExistsInnerShape}). 5701 */ 5702 private static void preflightDirectFromList(TSelectSqlStatement consumer) { 5703 if (consumer.joins == null) return; 5704 Set<String> seenSubqueryAliases = new HashSet<>(); 5705 Set<String> seenAllAliases = new HashSet<>(); 5706 for (TJoin join : consumer.joins) { 5707 preflightOneTable(join.getTable(), seenSubqueryAliases, seenAllAliases); 5708 TJoinItemList items = join.getJoinItems(); 5709 if (items == null) continue; 5710 for (int i = 0; i < items.size(); i++) { 5711 TJoinItem item = items.getJoinItem(i); 5712 if (item == null) continue; 5713 rejectUnsupportedJoinShape(item); 5714 preflightOneTable(item.getTable(), seenSubqueryAliases, seenAllAliases); 5715 } 5716 } 5717 } 5718 5719 /** 5720 * Slice 17: validate one direct FROM/JOIN-list TTable in the 5721 * mutation-free preflight. Effective alias is the SQL-written alias 5722 * if present, else the slice-74 synthetic alias for unaliased 5723 * FROM-subqueries (position-keyed), else the table name (matches 5724 * {@link #buildRelation}). 5725 * 5726 * <p>Slice 74: removed the {@code FROM_SUBQUERY_ALIAS_REQUIRED} reject 5727 * for anonymous subqueries; the slot is now filled by 5728 * {@link FromSubqueryNaming#synthAliasFor}. Two unaliased subqueries 5729 * at the same source location are theoretically impossible (the 5730 * parser would have to emit the same start token for both), but if 5731 * it ever happens the {@code DUPLICATE_FROM_SUBQUERY_ALIAS} branch 5732 * below catches it the same way as a literal user-written duplicate. 5733 * 5734 * <p>Still rejects: duplicate subquery aliases (whether user-written 5735 * or synthetic by collision), and any cross-kind alias collision 5736 * (base alias colliding with a subquery alias). 5737 */ 5738 private static void preflightOneTable(TTable t, 5739 Set<String> seenSubqueryAliases, 5740 Set<String> seenAllAliases) { 5741 if (t == null) return; 5742 boolean isSub = t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery; 5743 String effective = effectiveAliasOf(t); 5744 if (effective == null || effective.isEmpty()) return; 5745 String lower = effective.toLowerCase(Locale.ROOT); 5746 if (isSub && !seenSubqueryAliases.add(lower)) { 5747 throw new SemanticIRBuildException( 5748 Diagnostic.error(DiagnosticCode.DUPLICATE_FROM_SUBQUERY_ALIAS, 5749 "duplicate FROM-clause subquery alias '" + effective + "'", (TParseTreeNode) null)); 5750 } 5751 if (!seenAllAliases.add(lower)) { 5752 throw new SemanticIRBuildException( 5753 Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS, 5754 "duplicate relation alias '" + effective 5755 + "' is not supported (would make ColumnRef ambiguous)", (TParseTreeNode) null)); 5756 } 5757 } 5758 5759 /** 5760 * Slice 17: extract one direct subquery TTable as its own 5761 * StatementGraph. Recurses into the inner SELECT first 5762 * (innermost body lands in {@code stmts} BEFORE its consumer, as 5763 * {@code BodyIndexes} requires). Skips non-subquery tables (base 5764 * relations are bound later by {@code buildRelations}). 5765 */ 5766 private static void processDirectSubqueryTable( 5767 TTable t, 5768 NameBindingProvider consumerProvider, 5769 List<StatementGraph> stmts, 5770 List<LineageEdge> lineage, 5771 Map<String, Integer> cteNameToStatementIndex, 5772 Map<String, List<String>> ctePublishedColumns, 5773 Map<String, Integer> aliasToIndex) { 5774 if (t == null) return; 5775 // Slice 136: a PIVOT / UNPIVOT source can itself be a FROM-subquery 5776 // (`FROM (SELECT ...) [alias] PIVOT(...)`). Unwrap the pivoted_table to 5777 // its underlying source relation so the inner SELECT is extracted and 5778 // registered HERE — that lands the source-subquery alias in the 5779 // pre-pass's aliasToIndex (== build()'s outerSubqueryAliasToIndex) 5780 // BEFORE emitLineageForStatement runs, so the pivot→subquery cross-stmt 5781 // edges resolve. A base-table pivot source falls through the 5782 // non-subquery early-return below unchanged. 5783 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 5784 && t.getPivotedTable() != null 5785 && !t.getPivotedTable().getRelations().isEmpty()) { 5786 t = t.getPivotedTable().getRelations().get(0); 5787 if (t == null) return; 5788 } 5789 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) return; 5790 // Alias presence/uniqueness already validated by the preflight. 5791 // Slice 74: anonymous (unaliased) subqueries get a synth name 5792 // from FromSubqueryNaming via effectiveAliasOf so the alias used 5793 // for the aliasToIndex map and inner-stmt name is non-null. 5794 String alias = effectiveAliasOf(t); 5795 String aliasLower = alias.toLowerCase(Locale.ROOT); 5796 TSelectSqlStatement inner = t.getSubquery(); 5797 if (inner == null) { 5798 throw new SemanticIRBuildException( 5799 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_NO_INNER_SELECT, 5800 "FROM-clause subquery '" + alias + "' has no inner SELECT", (TParseTreeNode) null)); 5801 } 5802 // Slice 17 leak guard: predicate subqueries inside the 5803 // FROM-subquery body's WHERE / JOIN ON / GROUP BY would 5804 // otherwise slip past `allowScalarProjectionSubqueries=false` 5805 // (which only guards buildOutputColumns) and leak inner refs 5806 // into the body's filter/join/group ref lists. 5807 rejectSubqueriesInFromSubqueryBodyClauses(inner, alias); 5808 // Recurse into the inner's own FROM-subqueries first so each 5809 // deeper body lands in stmts BEFORE the body that consumes it. 5810 // The recursive call uses `consumerProvider` because the inner 5811 // sees the same CTE-name set as the outer (CTEs are visible 5812 // through FROM-subquery bodies — pinned by 5813 // Slice5Test.cteVisibleInsideFromSubquery). 5814 // Slice 60: thread ctePublishedColumns down unchanged. The 5815 // inner's siblings get registered into innerSubAliasToIndex 5816 // here; below we build the per-level innerInScope BEFORE 5817 // calling buildSelectStatement. 5818 Map<String, Integer> innerSubAliasToIndex = 5819 extractFromSubqueriesAsStatements(inner, consumerProvider, 5820 stmts, lineage, cteNameToStatementIndex, 5821 ctePublishedColumns); 5822 // Slice 60 (codex diff-review): build the inner FROM-subquery 5823 // body's effective-alias-keyed in-scope map by walking the 5824 // inner SELECT's FROM list. Sibling isolation is preserved 5825 // because `innerSubAliasToIndex` contains ONLY this body's 5826 // own children — ancestor siblings are never visited by the 5827 // walk because they're not in the inner's FROM list. 5828 Map<String, List<String>> innerInScope = buildEffectiveAliasInScopeMap( 5829 inner, consumerProvider, ctePublishedColumns, 5830 innerSubAliasToIndex, stmts); 5831 NameBindingProvider innerProviderWithStar = consumerProvider 5832 .withInScopeRelationColumns(innerInScope); 5833 // Slice 120 — switch from the 7-arg buildSelectStatement to the 5834 // 14-arg buildSelectStatementImpl so the FROM-subquery body's 5835 // WHERE clause can extract uncorrelated predicate subqueries 5836 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 5837 // ANY-ALL-SOME) as their own statements (mirrors the slice-114 5838 // CTE-body lift). JOIN-ON predicate subqueries stay rejected (the 5839 // two flags are independent per the slice-113 split) — the 5840 // slice-17 leak guard rejectSubqueriesInFromSubqueryBodyClauses 5841 // above still fires for the body's JOIN-ON / GROUP-BY clauses. 5842 // allowFromSubqueries=true so its buildRelations accepts 5843 // already-extracted subquery aliases; allowScalarProjectionSubqueries 5844 // =false (slice-15 invariant). The snapshot/rollback wrapper 5845 // mirrors the slice-114 CTE-body call site: if the build appends 5846 // predicate bodies and then a later reject fires, stmts/lineage 5847 // truncate back to the pre-call boundary so a partial extraction 5848 // does not leak into the program. processDirectSubqueryTable is 5849 // shared by the SELECT / UPDATE (slice 83) / DELETE (slice 84) 5850 // FROM-subquery extractors, so this single site lifts all three. 5851 int fromBodyStmtsSnapshot = stmts.size(); 5852 int fromBodyLineageSnapshot = lineage.size(); 5853 StatementGraph innerStmt; 5854 try { 5855 if (isPivotSelect(inner)) { 5856 // Slice 138: a nested PIVOT / UNPIVOT in a FROM-subquery body 5857 // (`FROM (SELECT ... FROM base PIVOT(...)) t`). The outer-SELECT 5858 // pivot router in buildSelectStatementImpl is gated to 5859 // `name == null`, so a pivot body extracted here (name == alias) 5860 // would otherwise fall to the normal path and reject with 5861 // TABLE_BINDING_UNRESOLVED ("null(piviot_table)"). Route it to 5862 // buildPivotSelect with the subquery alias as the statement name 5863 // so the extracted statement becomes a proper pivot StatementGraph; 5864 // the outer query then references it as a SUBQUERY-kind relation 5865 // and the slice-136 emitLineageForStatement call below wires the 5866 // pivot output → source edges (base-table TABLE_COLUMN, or — when 5867 // the pivot's own source is a subquery — STATEMENT_OUTPUT via the 5868 // innerSubAliasToIndex already populated by the recursion above). 5869 // PIVOT extra-clause / chained / set-op-branch nesting stay 5870 // deferred via buildPivotSelect's existing rejects. 5871 innerStmt = buildPivotSelect(inner, innerProviderWithStar, alias); 5872 } else { 5873 innerStmt = buildSelectStatementImpl(inner, innerProviderWithStar, alias, 5874 /*hasOuterCteListAlreadyProcessed=*/ false, 5875 /*allowFromSubqueries=*/ true, 5876 /*allowScalarProjectionSubqueries=*/ false, 5877 /*allowWindowProjection=*/ true, 5878 /*allowJoinOnPredicateSubqueries=*/ false, 5879 /*stmtsForExtraction=*/ stmts, 5880 /*lineageForExtraction=*/ lineage, 5881 /*cteMapForExtraction=*/ cteNameToStatementIndex, 5882 /*isPredicateBody=*/ false, 5883 /*whereClauseContext=*/ PredicateClauseContext.FROM_SUBQUERY_BODY_WHERE, 5884 /*allowWherePredicateSubqueries=*/ true); 5885 } 5886 } catch (RuntimeException ex) { 5887 while (stmts.size() > fromBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 5888 while (lineage.size() > fromBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 5889 throw ex; 5890 } 5891 int idx = stmts.size(); 5892 stmts.add(innerStmt); 5893 aliasToIndex.put(aliasLower, idx); 5894 // Emit lineage with the inner's own subquery alias map (so 5895 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edges target the inner's 5896 // children, not the outer's). Scalar map stays empty because 5897 // FROM-subquery bodies still reject scalar projections 5898 // (slice-15 invariant). 5899 emitLineageForStatement(innerStmt, idx, lineage, 5900 cteNameToStatementIndex, 5901 innerSubAliasToIndex, 5902 Collections.<Integer, ScalarInfo>emptyMap()); 5903 } 5904 5905 /** 5906 * Slice 17 leak guard: reject subqueries inside a FROM-subquery 5907 * body's JOIN ON / GROUP BY clauses. Mirrors 5908 * {@link #rejectSubqueriesInScalarBodyClauses} (slice 11) — the 5909 * {@code allowScalarProjectionSubqueries=false} flag only guards 5910 * {@code buildOutputColumns}, so without this helper a SQL like 5911 * {@code SELECT id FROM (SELECT id FROM e JOIN d ON e.x = d.x 5912 * AND EXISTS (...)) sub} would leak the EXISTS subquery's refs into 5913 * the body's join column refs via {@code collectColumnRefs}. 5914 * HAVING / ORDER BY subqueries are caught by the slice-9 / 10 5915 * deep-scan rejecters inside {@code buildSelectStatementImpl}. 5916 * 5917 * <p>Slice 120 — the WHERE branch was removed: uncorrelated WHERE-side 5918 * predicate subqueries in a FROM-subquery body are now extracted as 5919 * their own statements by {@code buildSelectStatementImpl} via 5920 * {@link PredicateClauseContext#FROM_SUBQUERY_BODY_WHERE} (see 5921 * {@link #processDirectSubqueryTable}). {@code FROM_SUBQUERY_INNER_SUBQUERY_IN_WHERE} 5922 * stays declared-but-unreached for public-API stability (slice 5923 * 71/72/82/86/95/101/.../114 retain-for-documentation precedent). 5924 */ 5925 private static void rejectSubqueriesInFromSubqueryBodyClauses( 5926 TSelectSqlStatement inner, String fromAlias) { 5927 if (inner.joins != null) { 5928 for (TJoin join : inner.joins) { 5929 TJoinItemList items = join.getJoinItems(); 5930 if (items == null) continue; 5931 for (int i = 0; i < items.size(); i++) { 5932 TJoinItem item = items.getJoinItem(i); 5933 TExpression onCond = item == null ? null : item.getOnCondition(); 5934 if (onCond != null && containsAnySubqueryExpression(onCond)) { 5935 throw new SemanticIRBuildException( 5936 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_INNER_SUBQUERY_IN_JOIN_ON, 5937 "FROM-clause subquery '" + fromAlias 5938 + "' has a subquery in a JOIN ON clause; not supported yet " 5939 + "(would leak inner refs)", (TParseTreeNode) null)); 5940 } 5941 } 5942 } 5943 } 5944 TGroupBy groupBy = inner.getGroupByClause(); 5945 if (groupBy != null) { 5946 TGroupByItemList items = groupBy.getItems(); 5947 if (items != null && containsAnySubquery(items)) { 5948 throw new SemanticIRBuildException( 5949 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_INNER_SUBQUERY_IN_GROUP_BY, 5950 "FROM-clause subquery '" + fromAlias 5951 + "' has a subquery in a GROUP BY clause; not supported yet " 5952 + "(would leak inner refs)", (TParseTreeNode) null)); 5953 } 5954 } 5955 } 5956 5957 /** 5958 * Walk the consuming SELECT's result-column list. For every 5959 * top-level {@link EExpressionType#subquery_t} projection, build 5960 * the inner SELECT as its own {@link StatementGraph} (mirroring 5961 * slice 5 FROM-subquery extraction), append it to {@code stmts}, 5962 * emit its own lineage edges, and record 5963 * {@code resultColumnOrdinal → ScalarInfo} so the consumer's 5964 * {@code emitLineageForStatement} can wire the 5965 * STATEMENT_OUTPUT → STATEMENT_OUTPUT edge. 5966 * 5967 * <p>Slice 11 disallows: scalar subqueries with no outer alias, 5968 * inner SELECTs that project more than one column, inner columns 5969 * with no alias and no direct column name, scalar subqueries 5970 * whose inner WHERE/JOIN/GROUP BY contains a subquery (predicate 5971 * leak guard), correlated scalar subqueries (inner refs that 5972 * resolve to outer aliases), and nested scalar subqueries. 5973 * 5974 * <p>Slice 14 lifted correlated TABLE-bound; slice 15 lifted CTE- 5975 * and SUBQUERY-bound. Slice 20 lifts <i>nested</i> scalar 5976 * projections inside a scalar body when the 5977 * {@code allowRecursiveScalarSubqueryExtraction} flag is true (passed 5978 * by the outer-build and CTE-body call sites). Set-op-branch call 5979 * sites pass false to keep the slice-12 / slice-16 boundary that 5980 * branch scalar bodies must not host another scalar projection. 5981 * 5982 * <p>Slice 20 wraps the body in a snapshot/rollback so a deeper 5983 * level's failure does not leak appended scalar-body statements at 5984 * shallower levels into {@code stmts}/{@code lineage}. The wrapper 5985 * mirrors slice-16's {@code buildSetOpProgram} and slice-17/18's 5986 * extract wrappers (§14.18 process lesson #21). 5987 */ 5988 private static Map<Integer, ScalarInfo> extractScalarSubqueriesAsStatements( 5989 TSelectSqlStatement consumer, 5990 NameBindingProvider consumerProvider, 5991 List<StatementGraph> stmts, 5992 List<LineageEdge> lineage, 5993 Map<String, Integer> cteNameToStatementIndex, 5994 EnclosingScope enclosingScope, 5995 boolean allowRecursiveScalarSubqueryExtraction) { 5996 // Slice 20: SET-OP-WIDE-style transactional rollback. A failure 5997 // anywhere inside the loop (or inside a recursive call) truncates 5998 // both lists back to the pre-extraction size. The wrapper closes 5999 // the class of "mutation-free check fires after partial mutation" 6000 // (codex round-3..5 finding on slice 16); the recursive scalar 6001 // extraction surfaced it here. 6002 int stmtsSnapshot = stmts.size(); 6003 int lineageSnapshot = lineage.size(); 6004 try { 6005 return extractScalarSubqueriesAsStatementsInternal(consumer, 6006 consumerProvider, stmts, lineage, 6007 cteNameToStatementIndex, enclosingScope, 6008 allowRecursiveScalarSubqueryExtraction); 6009 } catch (RuntimeException ex) { 6010 while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1); 6011 while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1); 6012 throw ex; 6013 } 6014 } 6015 6016 /** 6017 * Internal body of {@link #extractScalarSubqueriesAsStatements}. 6018 * Wrapped with snapshot/rollback by the public entry point; do not 6019 * call directly from non-wrapper sites. 6020 */ 6021 private static Map<Integer, ScalarInfo> extractScalarSubqueriesAsStatementsInternal( 6022 TSelectSqlStatement consumer, 6023 NameBindingProvider consumerProvider, 6024 List<StatementGraph> stmts, 6025 List<LineageEdge> lineage, 6026 Map<String, Integer> cteNameToStatementIndex, 6027 EnclosingScope enclosingScope, 6028 boolean allowRecursiveScalarSubqueryExtraction) { 6029 Map<Integer, ScalarInfo> ordinalToInfo = new HashMap<>(); 6030 TResultColumnList rcl = consumer.getResultColumnList(); 6031 if (rcl == null || rcl.size() == 0) return ordinalToInfo; 6032 6033 // Reject duplicate output aliases when a scalar projection is 6034 // present (codex impl-review round-1 SHOULD 1). Lineage refs are 6035 // keyed by (statementIndex, outputName); two outputs sharing 6036 // the same name would collapse their lineage chains and 6037 // silently merge the scalar dependency with another column's 6038 // dependency. The slice-11 boundary is the cleanest place to 6039 // enforce this since the issue is most acute when a scalar 6040 // body's STATEMENT_OUTPUT → STATEMENT_OUTPUT edge is in play. 6041 boolean hasScalar = false; 6042 for (int i = 0; i < rcl.size(); i++) { 6043 TResultColumn rc = rcl.getResultColumn(i); 6044 if (rc != null && rc.getExpr() != null 6045 && rc.getExpr().getExpressionType() == EExpressionType.subquery_t) { 6046 hasScalar = true; 6047 break; 6048 } 6049 } 6050 if (hasScalar) { 6051 Set<String> seenOutputNames = new HashSet<>(); 6052 for (int i = 0; i < rcl.size(); i++) { 6053 TResultColumn rc = rcl.getResultColumn(i); 6054 if (rc == null) continue; 6055 String alias = rc.getColumnAlias(); 6056 String colName = rc.getColumnNameOnly(); 6057 String name = (alias != null && !alias.isEmpty()) 6058 ? alias 6059 : colName; 6060 if (name == null || name.isEmpty()) continue; 6061 String lower = name.toLowerCase(Locale.ROOT); 6062 if (!seenOutputNames.add(lower)) { 6063 throw new SemanticIRBuildException( 6064 Diagnostic.error(DiagnosticCode.DUPLICATE_OUTPUT_NAME, 6065 "duplicate output name '" + name 6066 + "' in a SELECT containing a scalar subquery projection; " 6067 + "lineage refs are keyed by output name and would collide", rc)); 6068 } 6069 } 6070 } 6071 6072 for (int i = 0; i < rcl.size(); i++) { 6073 TResultColumn rc = rcl.getResultColumn(i); 6074 if (rc == null || rc.getExpr() == null) continue; 6075 if (rc.getExpr().getExpressionType() != EExpressionType.subquery_t) { 6076 continue; 6077 } 6078 String outerAlias = rc.getColumnAlias(); 6079 if (outerAlias == null || outerAlias.isEmpty()) { 6080 throw new SemanticIRBuildException( 6081 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_ALIAS_REQUIRED, 6082 "scalar subquery projection must have an alias", rc)); 6083 } 6084 TSelectSqlStatement inner = rc.getExpr().getSubQuery(); 6085 if (inner == null) { 6086 throw new SemanticIRBuildException( 6087 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_NO_INNER_SELECT, 6088 "scalar subquery projection '" + outerAlias 6089 + "' has no inner SELECT", rc)); 6090 } 6091 // Pre-recursion validation (codex round-2 MUST 2): inspect 6092 // the inner SELECT's projected column count and naming 6093 // BEFORE recursive build so the rejection message is 6094 // scalar-specific instead of bubbling up from 6095 // effectiveOutputName. 6096 TResultColumnList innerRcl = inner.getResultColumnList(); 6097 if (innerRcl == null || innerRcl.size() == 0) { 6098 throw new SemanticIRBuildException( 6099 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT, 6100 "scalar subquery '" + outerAlias 6101 + "' must project exactly one column, got 0", rc)); 6102 } 6103 if (innerRcl.size() != 1) { 6104 throw new SemanticIRBuildException( 6105 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT, 6106 "scalar subquery '" + outerAlias 6107 + "' must project exactly one column, got " 6108 + innerRcl.size(), rc)); 6109 } 6110 TResultColumn innerCol = innerRcl.getResultColumn(0); 6111 String innerAlias = innerCol.getColumnAlias(); 6112 String innerColName = innerCol.getColumnNameOnly(); 6113 boolean innerHasName = 6114 (innerAlias != null && !innerAlias.isEmpty()) 6115 || (innerColName != null && !innerColName.isEmpty()); 6116 if (!innerHasName && !isConstantExpression(innerCol.getExpr())) { 6117 throw new SemanticIRBuildException( 6118 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED, 6119 "scalar subquery '" + outerAlias 6120 + "' inner projection has no alias and no column name; " 6121 + "add an explicit alias inside the subquery", rc)); 6122 } 6123 // Pre-recursion deep-scan (codex round-3 MUST 5, round-4 6124 // MUST 1): reject nested predicate subqueries in the 6125 // scalar body's JOIN ON / GROUP BY before collectColumnRefs 6126 // can descend. Slice 121: the WHERE branch is no longer 6127 // rejected here (allowWherePredicateSubqueries=true) — the 6128 // scalar body's WHERE-side uncorrelated predicate subqueries 6129 // are extracted as their own statements by the 6130 // buildSelectStatementImpl call below via 6131 // PredicateClauseContext.SCALAR_BODY_WHERE. 6132 rejectSubqueriesInScalarBodyClauses(inner, outerAlias, 6133 /*allowWherePredicateSubqueries=*/ true); 6134 6135 // Slice 20: branch on allowRecursiveScalarSubqueryExtraction. 6136 // - true (outer / CTE-body call sites): build the inner's 6137 // own enclosing scope chained to this caller's; recursively 6138 // extract the inner's scalar projections; then build the 6139 // inner with allowScalarProjectionSubqueries=true; 6140 // compute scalarName AFTER the recursive extraction so 6141 // the digit suffix matches the post-extraction stmts.size() 6142 // (slice-16 codex round-1 MUST 2 lesson). 6143 // - false (set-op-branch call site): keep the slice-12 / 6144 // slice-16 boundary — no recursive extraction; the inner 6145 // scalar map stays empty; the inner builds with 6146 // allowScalarProjectionSubqueries=false; promotion still 6147 // uses the caller's enclosing scope so OUTER_REFERENCE-of-* 6148 // correlation works at the branch level. 6149 EnclosingScope innerEnclosing; 6150 Map<Integer, ScalarInfo> innerScalarMap; 6151 String scalarName; 6152 if (allowRecursiveScalarSubqueryExtraction) { 6153 innerEnclosing = buildEnclosingScope(inner, 6154 cteNameToStatementIndex, 6155 Collections.<String, Integer>emptyMap(), 6156 enclosingScope); 6157 innerScalarMap = extractScalarSubqueriesAsStatements(inner, 6158 consumerProvider, stmts, lineage, 6159 cteNameToStatementIndex, innerEnclosing, 6160 /*allowRecursiveScalarSubqueryExtraction=*/ true); 6161 scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">"; 6162 } else { 6163 innerEnclosing = enclosingScope; 6164 innerScalarMap = Collections.<Integer, ScalarInfo>emptyMap(); 6165 scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">"; 6166 } 6167 // Slice 121 — switch from the 7-arg buildSelectStatement to 6168 // the 14-arg buildSelectStatementImpl so the scalar body's 6169 // WHERE clause can extract uncorrelated predicate subqueries 6170 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 6171 // ANY-ALL-SOME) as their own <predicate_subquery_<i>> 6172 // statements (mirrors the slice-113 set-op branch / slice-120 6173 // FROM-subquery body lift). allowJoinOnPredicateSubqueries=false 6174 // keeps JOIN-ON predicate subqueries rejected (the two flags 6175 // are independent per the slice-113 split; the slice-11 leak 6176 // guard above still fires for the body's JOIN-ON / GROUP-BY). 6177 // allowScalarProjectionSubqueries stays gated on 6178 // allowRecursiveScalarSubqueryExtraction (slice-15/16 invariant). 6179 // stmts/lineage/cteMap are threaded for extraction; the 6180 // enclosing extractScalarSubqueriesAsStatements wrapper is the 6181 // snapshot/rollback boundary (no per-site try/catch needed — 6182 // slice-113 enclosed-by-buildSetOpProgram precedent). 6183 // preBuildScalarSize == the digit baked into scalarName above 6184 // (nothing mutates stmts between the scalarName assignment and 6185 // here), so `idx != preBuildScalarSize` below is exactly "the 6186 // build appended predicate bodies" without re-parsing the name. 6187 int preBuildScalarSize = stmts.size(); 6188 StatementGraph innerStmt = buildSelectStatementImpl(inner, consumerProvider, 6189 scalarName, 6190 /*hasOuterCteListAlreadyProcessed=*/ false, 6191 /*allowFromSubqueries=*/ false, 6192 /*allowScalarProjectionSubqueries=*/ allowRecursiveScalarSubqueryExtraction, 6193 /*allowWindowProjection=*/ false, 6194 /*allowJoinOnPredicateSubqueries=*/ false, 6195 /*stmtsForExtraction=*/ stmts, 6196 /*lineageForExtraction=*/ lineage, 6197 /*cteMapForExtraction=*/ cteNameToStatementIndex, 6198 /*isPredicateBody=*/ false, 6199 /*whereClauseContext=*/ PredicateClauseContext.SCALAR_BODY_WHERE, 6200 /*allowWherePredicateSubqueries=*/ true); 6201 // Slice 14: instead of rejecting correlated scalar 6202 // subqueries, promote outer-scope refs into synthesised 6203 // OUTER_REFERENCE relations on the inner statement. 6204 // Non-TABLE-bound outer refs (CTE / SUBQUERY) and 6205 // unknown aliases still throw. (This projection path uses the 6206 // innerEnclosing scope for promotion; the UPDATE SET-RHS scalar 6207 // path in extractScalarSubqueriesFromUpdateSetRhs instead uses 6208 // withTolerantOuterBinding because UPDATE has no enclosing-scope 6209 // promotion — slices 117/119. The two are equivalent for 6210 // uncorrelated bodies; do not cross-port one onto the other.) 6211 // promoteCorrelatedRefsToOuterReference returns a new 6212 // StatementGraph and does NOT append to stmts, so reading 6213 // idx after it is equivalent to reading it right after the 6214 // build (it only reflects predicate bodies the build appended). 6215 innerStmt = promoteCorrelatedRefsToOuterReference( 6216 innerStmt, outerAlias, innerEnclosing); 6217 int idx = stmts.size(); 6218 // Slice 121 — if the WHERE predicate-body extraction appended 6219 // statements during the build, the scalar body's final index 6220 // exceeds the value baked into scalarName (computed before the 6221 // build). Rebuild with the corrected <scalar_subquery_<idx>> 6222 // name so the slice-12/16 name↔index invariant survives 6223 // (slice-113 set-op-branch withRenamedTo precedent). LineageRefs 6224 // are idx-based, not name-based, so the rename is cosmetic. 6225 if (idx != preBuildScalarSize) { 6226 innerStmt = withRenamedTo(innerStmt, SCALAR_BODY_PREFIX + idx + ">"); 6227 } 6228 stmts.add(innerStmt); 6229 String innerOutName = effectiveOutputName(innerCol); 6230 ordinalToInfo.put(i, new ScalarInfo(idx, innerOutName)); 6231 // Slice 20: pass the chained subquery alias map so 6232 // OUTER_REFERENCE-of-SUBQUERY refs in deeply nested scalar 6233 // bodies resolve through ancestor FROM-subquery aliases. 6234 // Pass innerScalarMap (non-empty when recursive extraction 6235 // is allowed) so the inner's own STATEMENT_OUTPUT → 6236 // STATEMENT_OUTPUT edges land. 6237 emitLineageForStatement(innerStmt, idx, lineage, 6238 cteNameToStatementIndex, 6239 innerEnclosing.flattenSubqueryAliasToIndex(), 6240 innerScalarMap); 6241 } 6242 return ordinalToInfo; 6243 } 6244 6245 /** 6246 * Reject nested predicate subqueries inside a scalar body's 6247 * WHERE / JOIN ON / GROUP BY clauses. Scalar bodies are slice-11 6248 * scope; the pre-existing builder leaks predicate-subquery refs 6249 * into {@code filterColumnRefs}/etc. elsewhere, but the scalar-body 6250 * recursion path is guarded so slice-11 outputs stay clean. 6251 */ 6252 private static void rejectSubqueriesInScalarBodyClauses( 6253 TSelectSqlStatement inner, String outerAlias, 6254 boolean allowWherePredicateSubqueries) { 6255 // Slice 121 — the WHERE branch is gated. The projection scalar 6256 // path passes {@code allowWherePredicateSubqueries=true} so its 6257 // WHERE-side uncorrelated predicate subqueries are instead 6258 // extracted as their own {@code <predicate_subquery_<i>>} 6259 // statements by {@code buildSelectStatementImpl} via 6260 // {@link PredicateClauseContext#SCALAR_BODY_WHERE} (see 6261 // {@link #extractScalarSubqueriesAsStatementsInternal}). The 6262 // UPDATE SET-RHS scalar path 6263 // ({@link #extractScalarSubqueriesFromUpdateSetRhs}) keeps passing 6264 // {@code false} so its WHERE subqueries still reject with 6265 // {@code SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE} (the code stays 6266 // reached). JOIN-ON and GROUP-BY always reject (slice 11/23/26). 6267 TWhereClause where = inner.getWhereClause(); 6268 if (!allowWherePredicateSubqueries 6269 && where != null && containsAnySubquery(where)) { 6270 throw new SemanticIRBuildException( 6271 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE, 6272 "scalar subquery '" + outerAlias 6273 + "' has a subquery in its WHERE clause; not supported yet " 6274 + "(would leak inner refs)", (TParseTreeNode) null)); 6275 } 6276 if (inner.joins != null) { 6277 for (TJoin join : inner.joins) { 6278 TJoinItemList items = join.getJoinItems(); 6279 if (items == null) continue; 6280 for (int i = 0; i < items.size(); i++) { 6281 TJoinItem item = items.getJoinItem(i); 6282 TExpression onCond = item == null ? null : item.getOnCondition(); 6283 if (onCond != null && containsAnySubqueryExpression(onCond)) { 6284 throw new SemanticIRBuildException( 6285 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_JOIN_ON, 6286 "scalar subquery '" + outerAlias 6287 + "' has a subquery in a JOIN ON clause; not supported yet " 6288 + "(would leak inner refs)", (TParseTreeNode) null)); 6289 } 6290 } 6291 } 6292 } 6293 TGroupBy groupBy = inner.getGroupByClause(); 6294 if (groupBy != null) { 6295 TGroupByItemList items = groupBy.getItems(); 6296 if (items != null && containsAnySubquery(items)) { 6297 throw new SemanticIRBuildException( 6298 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_GROUP_BY, 6299 "scalar subquery '" + outerAlias 6300 + "' has a subquery in a GROUP BY clause; not supported yet " 6301 + "(would leak inner refs)", (TParseTreeNode) null)); 6302 } 6303 } 6304 // HAVING / ORDER BY subqueries are caught by the slice-9 / 10 6305 // deep-scan rejecters that fire during buildSelectStatement. 6306 } 6307 6308 /** 6309 * True iff any descendant of {@code root} is a {@link TExpression} 6310 * with {@code subquery_t} type or a non-null 6311 * {@link TExpression#getSubQuery()}. Mirrors the slice 9/10 pattern. 6312 */ 6313 private static boolean containsAnySubquery(gudusoft.gsqlparser.nodes.TParseTreeNode root) { 6314 final boolean[] found = {false}; 6315 root.acceptChildren(new TParseTreeVisitor() { 6316 @Override 6317 public void preVisit(TExpression e) { 6318 if (found[0]) return; 6319 if (e.getExpressionType() == EExpressionType.subquery_t 6320 || e.getSubQuery() != null) { 6321 found[0] = true; 6322 } 6323 } 6324 }); 6325 return found[0]; 6326 } 6327 6328 /** Same as {@link #containsAnySubquery} but also checks the root expression itself. */ 6329 private static boolean containsAnySubqueryExpression(TExpression root) { 6330 if (root.getExpressionType() == EExpressionType.subquery_t 6331 || root.getSubQuery() != null) { 6332 return true; 6333 } 6334 return containsAnySubquery(root); 6335 } 6336 6337 /** 6338 * Slice 119 — collect every {@code subquery_t} TExpression node that 6339 * is a direct (top-level) subquery in the compound expression {@code 6340 * root}, without descending into any found subquery's own body. 6341 * 6342 * <p>Uses {@link TExpression#acceptChildren} with a depth counter to 6343 * handle all expression subtypes (arithmetic, CASE, function args) 6344 * without manual branch enumeration. The depth counter increments on 6345 * every {@code subquery_t} preVisit and decrements on every postVisit, 6346 * so nested subqueries inside a found body (e.g. an EXISTS inside a 6347 * scalar's WHERE) are tracked but NOT added to the result set. 6348 * 6349 * <p>Result list is in traversal (preVisit) order for deterministic 6350 * statement-graph numbering across identical SQL texts. 6351 */ 6352 private static List<TExpression> collectNestedSubqueryExpressions(TExpression root) { 6353 if (root == null) return Collections.<TExpression>emptyList(); 6354 // Defensive: root itself is a subquery_t — callers of the 6355 // mixed-expression path should have already taken the slice-115 6356 // top-level branch, but guard anyway. 6357 if (root.getExpressionType() == EExpressionType.subquery_t) { 6358 return Collections.singletonList(root); 6359 } 6360 final List<TExpression> ordered = new ArrayList<>(); 6361 final int[] depth = {0}; 6362 root.acceptChildren(new TParseTreeVisitor() { 6363 @Override 6364 public void preVisit(TExpression e) { 6365 if (e.getExpressionType() == EExpressionType.subquery_t) { 6366 if (depth[0] == 0) ordered.add(e); // top-level only 6367 depth[0]++; 6368 } 6369 } 6370 @Override 6371 public void postVisit(TExpression e) { 6372 if (e.getExpressionType() == EExpressionType.subquery_t) { 6373 depth[0]--; 6374 } 6375 } 6376 }); 6377 return ordered; 6378 } 6379 6380 /** 6381 * Slice-14 enclosing-scope map for correlation promotion. Holds the 6382 * full outer {@link RelationSource} per alias visible from the 6383 * enclosing scope so {@link #promoteCorrelatedRefsToOuterReference} 6384 * can read the outer's binding kind and qualifiedName. 6385 * 6386 * <p>The map is keyed by lower-cased alias for case-insensitive 6387 * lookup. The synthesised OUTER_REFERENCE RelationSource's alias is 6388 * NOT taken from this map's value — it is taken from the inner 6389 * ref's spelling (see §4.3 of the slice-14 plan), because 6390 * {@link gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider} 6391 * records the inner ref's alias verbatim and case-sensitive 6392 * alias-equality elsewhere relies on that spelling. 6393 * 6394 * <p>Slice-15 adds {@link #subqueryAliasToIndex}: when a SUBQUERY-bound 6395 * outer alias is referenced from an inner correlated scalar, the 6396 * inner statement's lineage emission needs to look up the outer's 6397 * FROM-subquery body by alias to wire a STATEMENT_OUTPUT → 6398 * STATEMENT_OUTPUT edge. The map mirrors the outer's alias→index 6399 * map but is filtered to alias keys also present in 6400 * {@link #aliasLowerToOuter} as SUBQUERY entries so the two maps 6401 * cannot drift apart. 6402 * 6403 * <p>Slice-20 generalises slice-14/15's flat map into a chain of 6404 * ancestor scopes (the {@link #parent} field). Lookups walk innermost 6405 * → outermost via {@link #lookupAlias(String)}. The 6406 * {@link #flattenSubqueryAliasToIndex()} helper produces an innermost- 6407 * wins flattened map for {@code emitLineageForStatement} consumers 6408 * that resolve OUTER_REFERENCE-of-SUBQUERY through ancestor 6409 * FROM-subquery aliases. Worst-case asymptotic per build for a 6410 * scalar chain of depth D with K siblings per level is 6411 * {@code O(K · D²)}; in practice D ≤ 4-5 for human-written SQL so the 6412 * constant factor is negligible. If a real-world benchmark surfaces 6413 * the flatten as a hot path, the obvious fix is a memo keyed on 6414 * {@code EnclosingScope} identity — deferred until measured. 6415 */ 6416 private static final class EnclosingScope { 6417 final Map<String, RelationSource> aliasLowerToOuter; 6418 final Map<String, Integer> subqueryAliasToIndex; 6419 /** Slice-20 chain: parent enclosing scope; null at the root. */ 6420 final EnclosingScope parent; 6421 6422 EnclosingScope(Map<String, RelationSource> aliasLowerToOuter, 6423 Map<String, Integer> subqueryAliasToIndex, 6424 EnclosingScope parent) { 6425 this.aliasLowerToOuter = aliasLowerToOuter; 6426 this.subqueryAliasToIndex = subqueryAliasToIndex; 6427 this.parent = parent; 6428 } 6429 6430 static EnclosingScope empty() { 6431 return new EnclosingScope( 6432 Collections.<String, RelationSource>emptyMap(), 6433 Collections.<String, Integer>emptyMap(), 6434 /*parent=*/ null); 6435 } 6436 6437 /** 6438 * Walk the chain innermost → outermost; first match wins 6439 * (shadowing). Defensive cycle guard via identity-keyed visited 6440 * set: the chain is a DAG by construction (parent links never 6441 * loop back), but the guard makes the invariant explicit. 6442 */ 6443 RelationSource lookupAlias(String aliasLower) { 6444 EnclosingScope cur = this; 6445 Set<EnclosingScope> visited = Collections.newSetFromMap( 6446 new IdentityHashMap<EnclosingScope, Boolean>()); 6447 while (cur != null && visited.add(cur)) { 6448 RelationSource r = cur.aliasLowerToOuter.get(aliasLower); 6449 if (r != null) return r; 6450 cur = cur.parent; 6451 } 6452 return null; 6453 } 6454 6455 /** 6456 * Innermost-wins flatten of the SUBQUERY alias → body-index chain. 6457 * Ancestors contribute their own FROM-subquery alias maps; if both 6458 * a parent and a child define the same alias, the child wins 6459 * (innermost shadows outermost). Cycle guard mirrors 6460 * {@link #lookupAlias(String)}. 6461 */ 6462 Map<String, Integer> flattenSubqueryAliasToIndex() { 6463 if (parent == null) return subqueryAliasToIndex; 6464 Deque<EnclosingScope> stack = new ArrayDeque<>(); 6465 Set<EnclosingScope> visited = Collections.newSetFromMap( 6466 new IdentityHashMap<EnclosingScope, Boolean>()); 6467 EnclosingScope cur = this; 6468 while (cur != null && visited.add(cur)) { 6469 stack.push(cur); 6470 cur = cur.parent; 6471 } 6472 // Stack top = outermost. Pop outermost first, emit, then 6473 // innermost overwrites via put(). Result: innermost wins. 6474 Map<String, Integer> out = new LinkedHashMap<>(); 6475 while (!stack.isEmpty()) { 6476 EnclosingScope s = stack.pop(); 6477 out.putAll(s.subqueryAliasToIndex); 6478 } 6479 return out; 6480 } 6481 } 6482 6483 /** 6484 * Build an {@link EnclosingScope} for the consuming SELECT by walking 6485 * its {@link TSelectSqlStatement#tables} list (FROM relations) and 6486 * classifying each entry. Mirrors how 6487 * {@link Resolver2NameBindingProvider#bindRelation} would classify 6488 * the same TTable but produces a full RelationSource per alias so 6489 * the slice-14 promotion can read both kind and qualifiedName. 6490 * 6491 * <p>Slice 20: the {@code parent} parameter chains the new scope to an 6492 * enclosing one so a doubly-nested scalar body can resolve grandparent 6493 * aliases. Top-level callers pass {@code null}; recursive scalar 6494 * extraction passes the caller's enclosing scope. 6495 * 6496 * <p>Classification (precedence on collision: CTE name beats 6497 * base-table name): 6498 * <ul> 6499 * <li>{@code TTable.getTableType() == subquery} AND alias matches 6500 * a key in {@code subqueryAliasToIndex} → SUBQUERY-bound.</li> 6501 * <li>{@code TTable.getName().toLowerCase()} matches a key in 6502 * {@code cteNameToStatementIndex} → CTE-bound.</li> 6503 * <li>Otherwise → TABLE-bound.</li> 6504 * </ul> 6505 */ 6506 private static EnclosingScope buildEnclosingScope(TSelectSqlStatement consumer, 6507 Map<String, Integer> cteNameToStatementIndex, 6508 Map<String, Integer> subqueryAliasToIndex, 6509 EnclosingScope parent) { 6510 if (consumer == null || consumer.tables == null || consumer.tables.size() == 0) { 6511 // Even an empty FROM contributes an empty scope so the chain's 6512 // shape reflects nesting depth uniformly. 6513 return new EnclosingScope( 6514 Collections.<String, RelationSource>emptyMap(), 6515 Collections.<String, Integer>emptyMap(), 6516 parent); 6517 } 6518 Map<String, RelationSource> map = new LinkedHashMap<>(); 6519 Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>(); 6520 for (int i = 0; i < consumer.tables.size(); i++) { 6521 TTable t = consumer.tables.getTable(i); 6522 if (t == null) continue; 6523 // Slice 74: route anonymous FROM-subqueries through 6524 // effectiveAliasOf so the synth name (instead of the 6525 // literal "subquery" returned by t.getName()) flows into the 6526 // OUTER_REFERENCE scope chain. 6527 String aliasOrName = effectiveAliasOf(t); 6528 if (aliasOrName == null || aliasOrName.isEmpty()) continue; 6529 String lower = aliasOrName.toLowerCase(Locale.ROOT); 6530 if (map.containsKey(lower)) continue; // first-occurrence wins (defensive) 6531 6532 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 6533 if (subqueryAliasToIndex != null && subqueryAliasToIndex.containsKey(lower)) { 6534 map.put(lower, new RelationSource(aliasOrName, 6535 new RelationBinding(RelationKind.SUBQUERY, aliasOrName))); 6536 // Slice 15: keep a parallel filtered map of alias→index 6537 // so OUTER_REFERENCE-of-SUBQUERY emit-side dispatch can 6538 // resolve the outer body's statement index. 6539 filteredSubqueryAliasToIndex.put(lower, subqueryAliasToIndex.get(lower)); 6540 } 6541 continue; 6542 } 6543 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) { 6544 continue; // function / rowList / etc. — not modelled 6545 } 6546 String name = t.getName(); 6547 if (name == null || name.isEmpty()) continue; 6548 String nameLower = name.toLowerCase(Locale.ROOT); 6549 if (cteNameToStatementIndex != null 6550 && cteNameToStatementIndex.containsKey(nameLower)) { 6551 map.put(lower, new RelationSource(aliasOrName, 6552 new RelationBinding(RelationKind.CTE, name))); 6553 } else { 6554 map.put(lower, new RelationSource(aliasOrName, 6555 new RelationBinding(RelationKind.TABLE, name))); 6556 } 6557 } 6558 return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent); 6559 } 6560 6561 /** 6562 * Slice 117 — sibling to {@link #buildEnclosingScope} for UPDATE 6563 * SET-RHS scalar-subquery bodies. The UPDATE outer scope is the 6564 * target table (TABLE-bound) plus any FROM-side relations walked 6565 * via {@code update.getJoins()} (TABLE / CTE / SUBQUERY-classified). 6566 * 6567 * <p>The target is added FIRST so on alias collisions with a 6568 * FROM-side relation (e.g. {@code UPDATE t ... FROM other t}) the 6569 * target wins. The {@code first-occurrence wins} rule mirrors 6570 * {@link #buildEnclosingScope}. 6571 * 6572 * <p>Used only by 6573 * {@link #extractScalarSubqueriesFromUpdateSetRhsInternal}. 6574 */ 6575 private static EnclosingScope buildUpdateEnclosingScope( 6576 TUpdateSqlStatement update, 6577 Map<String, Integer> cteNameToStatementIndex, 6578 Map<String, Integer> subqueryAliasToIndex, 6579 EnclosingScope parent) { 6580 Map<String, RelationSource> map = new LinkedHashMap<>(); 6581 Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>(); 6582 if (update == null) { 6583 return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent); 6584 } 6585 // 1) Target — always TABLE-bound. effectiveAliasOf falls back to 6586 // the table's own name when no alias is present. 6587 TTable target = update.getTargetTable(); 6588 if (target != null 6589 && target.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 6590 String targetAlias = effectiveAliasOf(target); 6591 String targetName = target.getName(); 6592 if (targetAlias != null && !targetAlias.isEmpty() 6593 && targetName != null && !targetName.isEmpty()) { 6594 String aliasLower = targetAlias.toLowerCase(Locale.ROOT); 6595 map.put(aliasLower, new RelationSource(targetAlias, 6596 new RelationBinding(RelationKind.TABLE, targetName))); 6597 } 6598 } 6599 // 2) FROM-side joins. 6600 TJoinList joins = update.getJoins(); 6601 if (joins != null) { 6602 for (TJoin join : joins) { 6603 addUpdateRelationToEnclosingScope(join.getTable(), map, 6604 filteredSubqueryAliasToIndex, 6605 cteNameToStatementIndex, subqueryAliasToIndex); 6606 TJoinItemList items = join.getJoinItems(); 6607 if (items == null) continue; 6608 for (int i = 0; i < items.size(); i++) { 6609 TJoinItem item = items.getJoinItem(i); 6610 if (item == null) continue; 6611 addUpdateRelationToEnclosingScope(item.getTable(), map, 6612 filteredSubqueryAliasToIndex, 6613 cteNameToStatementIndex, subqueryAliasToIndex); 6614 } 6615 } 6616 } 6617 return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent); 6618 } 6619 6620 /** 6621 * Slice 117 — classify one FROM-side TTable for 6622 * {@link #buildUpdateEnclosingScope}. SUBQUERY-typed tables go to 6623 * {@link RelationKind#SUBQUERY} if their alias was registered in 6624 * {@code subqueryAliasToIndex} (slice-83 extraction map); objectname 6625 * tables go to {@link RelationKind#CTE} if their bare name is a 6626 * declared CTE, otherwise {@link RelationKind#TABLE}. First- 6627 * occurrence wins. Function / rowList sources are silently skipped 6628 * (not modelled). 6629 */ 6630 private static void addUpdateRelationToEnclosingScope(TTable t, 6631 Map<String, RelationSource> map, 6632 Map<String, Integer> filteredSubqueryAliasToIndex, 6633 Map<String, Integer> cteNameToStatementIndex, 6634 Map<String, Integer> subqueryAliasToIndex) { 6635 if (t == null) return; 6636 String aliasOrName = effectiveAliasOf(t); 6637 if (aliasOrName == null || aliasOrName.isEmpty()) return; 6638 String lower = aliasOrName.toLowerCase(Locale.ROOT); 6639 if (map.containsKey(lower)) return; // first-occurrence wins 6640 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 6641 if (subqueryAliasToIndex != null 6642 && subqueryAliasToIndex.containsKey(lower)) { 6643 map.put(lower, new RelationSource(aliasOrName, 6644 new RelationBinding(RelationKind.SUBQUERY, aliasOrName))); 6645 filteredSubqueryAliasToIndex.put(lower, 6646 subqueryAliasToIndex.get(lower)); 6647 } 6648 return; 6649 } 6650 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) { 6651 return; // function / rowList / etc. — not modelled 6652 } 6653 String name = t.getName(); 6654 if (name == null || name.isEmpty()) return; 6655 String nameLower = name.toLowerCase(Locale.ROOT); 6656 if (cteNameToStatementIndex != null 6657 && cteNameToStatementIndex.containsKey(nameLower)) { 6658 map.put(lower, new RelationSource(aliasOrName, 6659 new RelationBinding(RelationKind.CTE, name))); 6660 } else { 6661 map.put(lower, new RelationSource(aliasOrName, 6662 new RelationBinding(RelationKind.TABLE, name))); 6663 } 6664 } 6665 6666 /** 6667 * Slice 118 — sibling to {@link #buildUpdateEnclosingScope} for MERGE 6668 * per-WHEN action WHERE correlated predicate subqueries. Builds the 6669 * enclosing scope's relation map covering MERGE's target table, USING 6670 * source, and any outer CTEs declared on the MERGE itself. Used only 6671 * by {@link #collectMergeActionWhere}; produced once per MERGE in 6672 * {@link #buildMerge} and threaded through the predicate-subquery 6673 * extractor. 6674 * 6675 * <p>Classification mirrors {@code buildMerge} step 6: 6676 * <ul> 6677 * <li>{@code merge.getTargetTable()} — TABLE-bound (qualifiedName = 6678 * target's table name). First-occurrence wins.</li> 6679 * <li>{@code merge.getUsingTable()}: 6680 * <ul> 6681 * <li>SUBQUERY-typed → SUBQUERY-bound with index pulled from 6682 * {@code aliasToSubIdx}.</li> 6683 * <li>objectname-typed AND name in 6684 * {@code cteNameToStatementIndex} → CTE-bound (slice-101 6685 * USING-as-CTE).</li> 6686 * <li>Else objectname → TABLE-bound.</li> 6687 * </ul></li> 6688 * </ul> 6689 */ 6690 private static EnclosingScope buildMergeEnclosingScope( 6691 TMergeSqlStatement merge, 6692 Map<String, Integer> cteNameToStatementIndex, 6693 Map<String, Integer> aliasToSubIdx) { 6694 Map<String, RelationSource> map = new LinkedHashMap<>(); 6695 Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>(); 6696 if (merge == null) { 6697 return new EnclosingScope(map, filteredSubqueryAliasToIndex, 6698 /*parent=*/ null); 6699 } 6700 // 1) Target — always TABLE-bound. effectiveAliasOf falls back to 6701 // the table's own name when no alias is present. 6702 TTable target = merge.getTargetTable(); 6703 if (target != null 6704 && target.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 6705 String targetAlias = effectiveAliasOf(target); 6706 String targetName = target.getName(); 6707 if (targetAlias != null && !targetAlias.isEmpty() 6708 && targetName != null && !targetName.isEmpty()) { 6709 String aliasLower = targetAlias.toLowerCase(Locale.ROOT); 6710 map.put(aliasLower, new RelationSource(targetAlias, 6711 new RelationBinding(RelationKind.TABLE, targetName))); 6712 } 6713 } 6714 // 2) USING source. 6715 TTable using = merge.getUsingTable(); 6716 if (using != null) { 6717 String usingAlias = effectiveAliasOf(using); 6718 // Fall back to the USING source's bare name when no alias is 6719 // present — matches buildMerge's `usingAlias` initialisation. 6720 if (usingAlias == null || usingAlias.isEmpty()) { 6721 usingAlias = (using.getName() == null 6722 || using.getName().toString().isEmpty()) 6723 ? "__merge_using__" 6724 : using.getName().toString(); 6725 } 6726 String usingAliasLower = usingAlias.toLowerCase(Locale.ROOT); 6727 // First-occurrence wins (defensive — target's alias could 6728 // theoretically shadow if user named USING source identically; 6729 // matches buildUpdateEnclosingScope behaviour). 6730 if (!map.containsKey(usingAliasLower)) { 6731 if (using.getTableType() 6732 == gudusoft.gsqlparser.ETableSource.subquery) { 6733 if (aliasToSubIdx != null 6734 && aliasToSubIdx.containsKey(usingAliasLower)) { 6735 map.put(usingAliasLower, new RelationSource(usingAlias, 6736 new RelationBinding(RelationKind.SUBQUERY, 6737 usingAlias))); 6738 filteredSubqueryAliasToIndex.put(usingAliasLower, 6739 aliasToSubIdx.get(usingAliasLower)); 6740 } 6741 } else if (using.getTableType() 6742 == gudusoft.gsqlparser.ETableSource.objectname) { 6743 String usingName = using.getName(); 6744 if (usingName != null && !usingName.isEmpty()) { 6745 String usingNameLower = 6746 usingName.toLowerCase(Locale.ROOT); 6747 Integer cteIdx = (cteNameToStatementIndex == null) 6748 ? null 6749 : cteNameToStatementIndex.get(usingNameLower); 6750 if (cteIdx != null) { 6751 // Slice 101 USING-as-CTE branch — model as 6752 // SUBQUERY-bound so the promoter classifies 6753 // outerKind=SUBQUERY (mirrors the slice-117 6754 // SUBQUERY classifier for UPDATE FROM-CTE 6755 // sources, also via aliasToSubIdx). 6756 map.put(usingAliasLower, new RelationSource(usingAlias, 6757 new RelationBinding(RelationKind.SUBQUERY, 6758 usingAlias))); 6759 // The MERGE caller pre-populates aliasToSubIdx 6760 // with the CTE's statement index for both 6761 // usingAlias AND bare CTE name. Use whichever 6762 // exists (aliasToSubIdx is keyed on lower-cased 6763 // aliases — matches buildMerge step 6). 6764 if (aliasToSubIdx != null 6765 && aliasToSubIdx.containsKey(usingAliasLower)) { 6766 filteredSubqueryAliasToIndex.put(usingAliasLower, 6767 aliasToSubIdx.get(usingAliasLower)); 6768 } 6769 } else { 6770 map.put(usingAliasLower, new RelationSource(usingAlias, 6771 new RelationBinding(RelationKind.TABLE, usingName))); 6772 } 6773 } 6774 } 6775 // function / rowList sources are silently skipped (not 6776 // modelled — same convention as buildUpdateEnclosingScope). 6777 } 6778 } 6779 return new EnclosingScope(map, filteredSubqueryAliasToIndex, 6780 /*parent=*/ null); 6781 } 6782 6783 /** 6784 * Slice 117 — precompute the lowercased set of inner local FROM 6785 * aliases for a SELECT statement, used by the tolerant-outer- 6786 * binding fallback in 6787 * {@link gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider#bindColumn}. 6788 * Walks {@code select.tables} (which includes both the FROM driver 6789 * and any JOIN sides) and collects each table's effective alias. 6790 * 6791 * <p>Computed BEFORE the inner build's {@code buildRelations} JOIN- 6792 * ON collector runs so the tolerant provider is already scope-aware 6793 * when the collector first calls {@code bindColumn} (codex round-5 6794 * ordering fix). 6795 */ 6796 private static Set<String> precomputeInnerLocalAliases( 6797 TSelectSqlStatement select) { 6798 Set<String> aliases = new HashSet<>(); 6799 if (select == null || select.tables == null) return aliases; 6800 for (int i = 0; i < select.tables.size(); i++) { 6801 TTable t = select.tables.getTable(i); 6802 if (t == null) continue; 6803 String alias = effectiveAliasOf(t); 6804 if (alias != null && !alias.isEmpty()) { 6805 aliases.add(alias.toLowerCase(Locale.ROOT)); 6806 } 6807 } 6808 return aliases; 6809 } 6810 6811 /** 6812 * Slice-14 correlation promotion (slice-15 extended). Walk every 6813 * column ref in the already-built inner statement; any ref whose 6814 * alias is NOT in the inner's local relations is "correlated." 6815 * Look it up in the enclosing scope: 6816 * 6817 * <ul> 6818 * <li>Found AND TABLE / CTE / SUBQUERY-bound → synthesise an 6819 * OUTER_REFERENCE RelationSource with {@code outerKind} 6820 * set to the resolved outer kind, and add to 6821 * {@code inner.relations}.</li> 6822 * <li>Found AND UNION / UNKNOWN-bound → throw defensively 6823 * (no current builder path produces these from a FROM 6824 * relation).</li> 6825 * <li>Not found anywhere → throw (Resolver2 should not give us 6826 * such a ref; defensive).</li> 6827 * </ul> 6828 * 6829 * <p>The synthesised RelationSource's alias is the inner ref's 6830 * spelling (case-sensitive equality is preserved). The 6831 * qualifiedName comes from the outer's existing binding: 6832 * - TABLE: outer's table name. 6833 * - CTE: outer's CTE name (NOT alias — see 6834 * {@code correlatedToCteBoundOuterWithCteAlias}). 6835 * - SUBQUERY: outer's alias (matching slice-14 SUBQUERY 6836 * convention, where {@code buildEnclosingScope} sets 6837 * {@code qualifiedName=aliasOrName}). 6838 * No lower-casing happens here — that happens at projector 6839 * emit-time per slice-1 convention. Multiple inner refs with 6840 * case-variant spellings inherit the same pre-existing slice-1 6841 * limitation. 6842 */ 6843 private static StatementGraph promoteCorrelatedRefsToOuterReference( 6844 StatementGraph innerStmt, String outerAlias, 6845 EnclosingScope enclosingScope) { 6846 Set<String> innerAliasesLower = new HashSet<>(); 6847 for (RelationSource r : innerStmt.getRelations()) { 6848 innerAliasesLower.add(r.getAlias().toLowerCase(Locale.ROOT)); 6849 } 6850 // Per alias-lower: outer's RelationSource (for binding) and the 6851 // inner ref's exact alias spelling (for the synthesised alias). 6852 LinkedHashMap<String, RelationSource> outerByLower = new LinkedHashMap<>(); 6853 LinkedHashMap<String, String> firstRefSpellingByLower = new LinkedHashMap<>(); 6854 6855 for (ColumnRef ref : collectAllInnerRefs(innerStmt)) { 6856 String aliasLower = ref.getRelationAlias().toLowerCase(Locale.ROOT); 6857 if (innerAliasesLower.contains(aliasLower)) continue; // local — not correlated 6858 if (outerByLower.containsKey(aliasLower)) continue; // already promoted 6859 6860 // Slice 20: chain-walking lookup. Walks the EnclosingScope 6861 // chain innermost → outermost; first match wins (shadowing 6862 // semantics). slice 14/15 used a single-level get(); slice 6863 // 20 generalises so inner-inner scalars can resolve 6864 // grandparent (and deeper) aliases. 6865 RelationSource outerRel = enclosingScope.lookupAlias(aliasLower); 6866 if (outerRel == null) { 6867 throw new SemanticIRBuildException( 6868 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS, 6869 "scalar subquery '" + outerAlias 6870 + "' references unknown alias '" + ref.getRelationAlias() 6871 + "' (column '" + ref.getRelationAlias() + "." 6872 + ref.getColumnName() 6873 + "'); not in inner relations or any enclosing scope", (TParseTreeNode) null)); 6874 } 6875 RelationKind outerKind = outerRel.getBinding().getKind(); 6876 if (outerKind != RelationKind.TABLE 6877 && outerKind != RelationKind.CTE 6878 && outerKind != RelationKind.SUBQUERY) { 6879 throw new SemanticIRBuildException( 6880 Diagnostic.error(DiagnosticCode.CORRELATED_SCALAR_SUBQUERY_UNKNOWN_OUTER_BINDING, 6881 "correlated scalar subquery '" + outerAlias 6882 + "' references outer alias '" + ref.getRelationAlias() 6883 + "' bound to a " + outerKind 6884 + "; only TABLE / CTE / SUBQUERY-bound outer correlations supported", (TParseTreeNode) null)); 6885 } 6886 outerByLower.put(aliasLower, outerRel); 6887 firstRefSpellingByLower.put(aliasLower, ref.getRelationAlias()); 6888 } 6889 6890 if (outerByLower.isEmpty()) return innerStmt; 6891 6892 List<RelationSource> augmented = new ArrayList<>(innerStmt.getRelations()); 6893 for (String key : outerByLower.keySet()) { 6894 RelationSource outerRel = outerByLower.get(key); 6895 augmented.add(new RelationSource( 6896 firstRefSpellingByLower.get(key), 6897 new RelationBinding(RelationKind.OUTER_REFERENCE, 6898 outerRel.getBinding().getQualifiedName(), 6899 outerRel.getBinding().getKind()))); 6900 } 6901 return rebuildStatementGraphWithRelations(innerStmt, augmented); 6902 } 6903 6904 /** 6905 * Collect every {@link ColumnRef} reachable from {@code innerStmt}'s 6906 * seven clause-bearing fields. Used by 6907 * {@link #promoteCorrelatedRefsToOuterReference} (slice-11 scalar 6908 * bodies) and the JOIN-ON EXISTS predicate-body correlation walker 6909 * (slice 23+); mirrors the old rejecter's clause coverage exactly 6910 * (output sources, filter, join, groupBy, having, orderBy, and 6911 * slice-73 distinctOn). 6912 */ 6913 private static List<ColumnRef> collectAllInnerRefs(StatementGraph innerStmt) { 6914 List<ColumnRef> all = new ArrayList<>(); 6915 for (OutputColumn out : innerStmt.getOutputColumns()) { 6916 all.addAll(out.getSources()); 6917 } 6918 all.addAll(innerStmt.getFilterColumnRefs()); 6919 all.addAll(innerStmt.getJoinColumnRefs()); 6920 all.addAll(innerStmt.getGroupByColumnRefs()); 6921 all.addAll(innerStmt.getHavingColumnRefs()); 6922 all.addAll(innerStmt.getOrderByColumnRefs()); 6923 all.addAll(innerStmt.getDistinctOnColumnRefs()); 6924 return all; 6925 } 6926 6927 /** 6928 * Copy a {@link StatementGraph} replacing only its relations list. 6929 * StatementGraph is otherwise immutable. 6930 */ 6931 private static StatementGraph rebuildStatementGraphWithRelations( 6932 StatementGraph stmt, List<RelationSource> relations) { 6933 return new StatementGraph( 6934 stmt.getName(), 6935 stmt.getKind(), 6936 relations, 6937 stmt.getOutputColumns(), 6938 stmt.getFilterColumnRefs(), 6939 stmt.getJoinColumnRefs(), 6940 stmt.getGroupByColumnRefs(), 6941 stmt.getHavingColumnRefs(), 6942 stmt.getOrderByColumnRefs(), 6943 stmt.getDistinctOnColumnRefs(), 6944 stmt.isDistinct(), 6945 stmt.getSetOperator(), 6946 stmt.getRowLimit()) 6947 // Slice 180 (R5): preserve the block span the legacy ctor 6948 // would otherwise drop (this copy only swaps relations). 6949 .withSourceSpan(stmt.getSourceSpan()); 6950 } 6951 6952 /** 6953 * Slice 16 preflight: reject any FROM-clause subquery directly on a 6954 * set-op branch's FROM/JOIN list, BEFORE 6955 * {@link #extractScalarSubqueriesAsStatements} can append scalar-body 6956 * statements to {@code stmts}/{@code lineage}. Without this preflight, 6957 * a branch with both a FROM-subquery and a scalar projection would 6958 * extract the scalar (mutating shared state) and then fail later in 6959 * {@code buildSelectStatement}, producing a confusing scalar-correlation 6960 * error message instead of the slice-12 FROM-subquery boundary. 6961 * 6962 * <p>Inspects only the branch's DIRECT FROM/JOIN entries — does NOT 6963 * recurse into result-column expressions or scalar-body inner SELECTs 6964 * (those are handled by the recursive scalar-body build's own 6965 * {@code allowFromSubqueries=false} guard). The error message contains 6966 * both {@code set-op branch} and {@code FROM-clause subquery} keywords 6967 * so the slice-12 boundary is surfaced explicitly. 6968 */ 6969 private static void rejectFromSubqueriesInSetOpBranch(TSelectSqlStatement br) { 6970 if (br.joins == null) return; 6971 for (TJoin join : br.joins) { 6972 TTable left = join.getTable(); 6973 if (left != null 6974 && left.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 6975 throw new SemanticIRBuildException( 6976 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM, 6977 "FROM-clause subquery directly in a set-op branch FROM is not " 6978 + "supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null)); 6979 } 6980 // Slice 140: a PIVOT/UNPIVOT branch whose SOURCE is a FROM-subquery 6981 // (`SELECT ... FROM (SELECT ...) PIVOT(...) UNION ALL ...`) is 6982 // deferred. The slice-138 `processDirectSubqueryTable` pre-extraction 6983 // is NOT called from the set-op branch path (set-op branches set 6984 // `allowFromSubqueries=false`), so routing such a shape to 6985 // `buildPivotSelect` would land in `buildRelation` on the inner 6986 // subquery TTable with empty `subqueryAliasToIndex` and emit a 6987 // less-informative second-step `TABLE_BINDING_UNRESOLVED` against 6988 // the inner subquery. Reuse the existing 6989 // `FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM` code with a discriminated 6990 // message; no new DiagnosticCode. 6991 if (left != null 6992 && left.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 6993 && left.getPivotedTable() != null 6994 && !left.getPivotedTable().getRelations().isEmpty() 6995 && left.getPivotedTable().getRelations().get(0) != null 6996 && left.getPivotedTable().getRelations().get(0).getTableType() 6997 == gudusoft.gsqlparser.ETableSource.subquery) { 6998 throw new SemanticIRBuildException( 6999 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM, 7000 "FROM-clause subquery as a PIVOT source in a set-op branch is " 7001 + "not supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null)); 7002 } 7003 TJoinItemList items = join.getJoinItems(); 7004 if (items == null) continue; 7005 for (int i = 0; i < items.size(); i++) { 7006 TTable r = items.getJoinItem(i).getTable(); 7007 if (r != null 7008 && r.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 7009 throw new SemanticIRBuildException( 7010 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_ON_JOIN_SIDE_IN_SET_OP_BRANCH, 7011 "FROM-clause subquery on a JOIN side in a set-op branch is " 7012 + "not supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null)); 7013 } 7014 } 7015 } 7016 } 7017 7018 // ==================================================================== 7019 // Slice 12: Set operations (UNION / INTERSECT / MINUS / EXCEPT). 7020 // 7021 // Algorithm: each branch of the set-op tree is built as its own 7022 // synthetically-named StatementGraph; the outer set-op statement has 7023 // empty relations and per-output `STATEMENT_OUTPUT → STATEMENT_OUTPUT` 7024 // lineage edges to each branch's corresponding output. The flatten 7025 // walks the left-leaning AST iteratively (CLAUDE.md mandates no 7026 // recursion on leftStmt/rightStmt; would StackOverflow on 2000+ UNIONs). 7027 // 7028 // Internal-node modifiers (ORDER BY / row-limits) are rejected on every 7029 // set-op node visited (root + internal), not just the root, because 7030 // parenthesized inner combined nodes can carry those modifiers (per 7031 // /tmp/SetOpInnerModifierProbe: `(A UNION B ORDER BY id) UNION C` 7032 // attaches ORDER BY to the inner node in Oracle / PostgreSQL / MSSQL; 7033 // PostgreSQL maps inner FETCH FIRST → LIMIT). 7034 // ==================================================================== 7035 7036 /** 7037 * Build a set-op program: flatten branches, build each as its own 7038 * StatementGraph, construct the outer set-op statement, emit lineage. 7039 * Returns the outer set-op statement's index in {@code stmts}. 7040 * 7041 * @param setOp the {@link TSelectSqlStatement} carrying 7042 * {@code setOperatorType != none}. 7043 * @param setOpName non-null when the set-op is a CTE body (the outer 7044 * statement is named with the CTE name); null when the set-op is 7045 * the program's top-level outer. 7046 * @param hasOuterCteListAlreadyProcessed true when the caller already 7047 * processed the set-op root's CTE list (top-level dispatch); 7048 * false when this is a recursive context (set-op CTE body) where 7049 * a non-empty CTE list on the set-op root is rejected as a 7050 * nested WITH. 7051 */ 7052 private static int buildSetOpProgram(TSelectSqlStatement setOp, 7053 NameBindingProvider provider, 7054 List<StatementGraph> stmts, 7055 List<LineageEdge> lineage, 7056 Map<String, Integer> cteNameToStatementIndex, 7057 String setOpName, 7058 boolean hasOuterCteListAlreadyProcessed) { 7059 // Nested-WITH guard (rd-5 MUST 1): when called from a CTE body 7060 // context, the set-op root must not carry its own CTE list. 7061 if (!hasOuterCteListAlreadyProcessed 7062 && setOp.getCteList() != null 7063 && setOp.getCteList().size() > 0) { 7064 throw new SemanticIRBuildException( 7065 Diagnostic.error(DiagnosticCode.NESTED_WITH_NOT_SUPPORTED, 7066 "nested WITH/CTE inside a CTE body or subquery is not supported yet " 7067 + "(set-op CTE body has its own CTE list)", (TParseTreeNode) null)); 7068 } 7069 // Slice 21: ORDER BY is now collected from the outer set-op 7070 // (see buildSetOpOuterOrderByColumnRefs). 7071 // Slice 72: outer row-limit lifted via buildSetOpRowLimit. 7072 // The internal-node reject (parenthesized inner set-ops with 7073 // row-limit) fires inside flattenSetOpTreeIteratively. Compute 7074 // BEFORE the snapshot block so a defensive throw (Hive/Vertica/ 7075 // ANSI-DB2 guards, MSSQL null-valued TOrderBy slots) propagates 7076 // without leaving stmts/lineage partially populated. 7077 RowLimit setOpRowLimit = buildSetOpRowLimit(setOp); 7078 7079 // Slice 16: SET-OP-WIDE TRANSACTIONAL ROLLBACK (codex rounds 3-5 7080 // adversarial findings). Snapshot `stmts.size()` and 7081 // `lineage.size()` BEFORE any branch mutation. On any 7082 // SemanticIRBuildException thrown by the per-branch loop or 7083 // post-build validation, truncate both lists back to the snapshot 7084 // and rethrow. This addresses the full class of "mutation-free 7085 // branch validation that fires after earlier-branch mutations": 7086 // FROM-subquery preflight (round 3), column-count check (round 4), 7087 // multi-scalar-projection per-branch validation (round 5), branch 7088 // duplicate-output-names check (round 5), and any future check 7089 // that may join the same class. The pre-loop preflight below 7090 // remains for fast-fail with better error messages on common 7091 // shapes, but the rollback is the safety net. 7092 int stmtsSnapshot = stmts.size(); 7093 int lineageSnapshot = lineage.size(); 7094 try { 7095 return buildSetOpProgramInternal(setOp, provider, stmts, lineage, 7096 cteNameToStatementIndex, setOpName, 7097 hasOuterCteListAlreadyProcessed, setOpRowLimit); 7098 } catch (RuntimeException e) { 7099 while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1); 7100 while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1); 7101 throw e; 7102 } 7103 } 7104 7105 /** 7106 * Internal body of {@link #buildSetOpProgram}. Wrapped with 7107 * snapshot/rollback by the public entry point; do not call directly. 7108 */ 7109 private static int buildSetOpProgramInternal(TSelectSqlStatement setOp, 7110 NameBindingProvider provider, 7111 List<StatementGraph> stmts, 7112 List<LineageEdge> lineage, 7113 Map<String, Integer> cteNameToStatementIndex, 7114 String setOpName, 7115 boolean hasOuterCteListAlreadyProcessed, 7116 RowLimit setOpRowLimit) { 7117 7118 SetOperator setOpKind = resolveSetOperator(setOp); 7119 List<TSelectSqlStatement> branches = flattenSetOpTreeIteratively(setOp, setOpKind); 7120 if (branches.size() < 2) { 7121 throw new SemanticIRBuildException( 7122 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COUNT_TOO_FEW, 7123 "set-op flatten produced " + branches.size() 7124 + " branches; expected at least 2", (TParseTreeNode) null)); 7125 } 7126 7127 // Slice 16: PRE-LOOP PREFLIGHT (codex round-3 + round-4 adversarial 7128 // findings — medium). Run all mutation-free branch validation across 7129 // EVERY branch BEFORE the main build loop runs. Without this, a 7130 // scalar-bearing earlier branch can append scalar-body statements to 7131 // `stmts`/`lineage` BEFORE a later branch's rejection fires, leaving 7132 // partial state on the rejection path. The slice-16 safety claim is 7133 // "no half-built scalar bodies leak when a branch is rejected"; that 7134 // claim only holds when ALL mutation-free branch checks are 7135 // set-op-wide. 7136 // 7137 // Checks bundled here (each is AST-only and side-effect-free): 7138 // - Defensive nested-set-op leaf check (slice 12). 7139 // - Direct branch FROM-subquery rejection (slice 16 round 3). 7140 // - Result-column-count compatibility across branches (slice 12, 7141 // moved here in slice 16 round 4 — uses AST 7142 // getResultColumnList().size() since BUILT outputColumns.size() 7143 // is unavailable pre-build; the post-loop check on BUILT 7144 // outputs stays as a defensive backup if AST-vs-built ever 7145 // diverges for a future shape). 7146 int expectedAstCols = -1; 7147 for (int i = 0; i < branches.size(); i++) { 7148 TSelectSqlStatement br = branches.get(i); 7149 if (br.getSetOperatorType() != null 7150 && br.getSetOperatorType() != ESetOperatorType.none) { 7151 throw new SemanticIRBuildException( 7152 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_IS_SET_OP, 7153 "set-op branch is itself a set operation; nested set " 7154 + "operations in branches are not supported yet", (TParseTreeNode) null)); 7155 } 7156 rejectFromSubqueriesInSetOpBranch(br); 7157 int astCols = br.getResultColumnList() == null 7158 ? 0 7159 : br.getResultColumnList().size(); 7160 if (i == 0) { 7161 expectedAstCols = astCols; 7162 } else if (astCols != expectedAstCols) { 7163 throw new SemanticIRBuildException( 7164 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COLUMN_COUNT_MISMATCH, 7165 "set-op branch column-count mismatch: branch[0] has " 7166 + expectedAstCols + " columns, branch[" + i + "] has " 7167 + astCols, (TParseTreeNode) null)); 7168 } 7169 } 7170 7171 int[] branchIdxs = new int[branches.size()]; 7172 for (int i = 0; i < branches.size(); i++) { 7173 TSelectSqlStatement br = branches.get(i); 7174 7175 // Slice 16: per-branch enclosing scope. allowFromSubqueries 7176 // stays false at the branch level so subqueryAliasToIndex 7177 // is empty (no OUTER_REFERENCE-of-SUBQUERY in branches). 7178 // CTE-aware so a branch's scalar can correlate to a CTE 7179 // visible in scope (top-level set-ops have all outer CTEs 7180 // indexed; CTE-body set-ops have only prior visible CTEs 7181 // indexed — the current CTE is registered AFTER this method 7182 // returns, by design, mirroring the non-set-op CTE body path). 7183 EnclosingScope branchEnclosing = buildEnclosingScope(br, 7184 cteNameToStatementIndex, 7185 Collections.<String, Integer>emptyMap(), 7186 /*parent=*/ null); 7187 // Slice 20: pass `false` so the slice-12 / slice-16 boundary 7188 // holds — branch scalar bodies must NOT host another scalar 7189 // projection. The branch's TOP-LEVEL scalar projection is 7190 // still allowed (slice 16); deeper recursion is not. 7191 Map<Integer, ScalarInfo> branchScalarMap = 7192 extractScalarSubqueriesAsStatements(br, provider, 7193 stmts, lineage, cteNameToStatementIndex, branchEnclosing, 7194 /*allowRecursiveScalarSubqueryExtraction=*/ false); 7195 7196 // Slice 16: compute branchName AFTER scalar extraction so the 7197 // digit suffix in `<set_op_branch_<idx>>` matches the branch's 7198 // final position in `stmts`. Scalar bodies appended by 7199 // extractScalarSubqueriesAsStatements come BEFORE the branch 7200 // in `stmts`, so pre-extraction `stmts.size()` would be wrong 7201 // by (number of scalar bodies in this branch) — breaking the 7202 // slice-12 invariant that branch synthetic names round-trip 7203 // to their statement index. 7204 // 7205 // Slice 113 — predicate bodies extracted from the branch's 7206 // WHERE clause via {@link PredicateClauseContext#SET_OP_BRANCH_WHERE} 7207 // also land in {@code stmts} BEFORE the branch, INSIDE the 7208 // {@code buildSelectStatementImpl} call below. So the 7209 // pre-build {@code stmts.size()} can still understate the 7210 // branch's final position. The slice-12/16 invariant is 7211 // preserved by computing a tentative name pre-build (best 7212 // guess used by inner consumers that need a non-null name), 7213 // then rebuilding the StatementGraph with the corrected 7214 // name AFTER the build via {@link #withRenamedTo} if any 7215 // predicate body was extracted. 7216 int preBuildStmtsSize = stmts.size(); 7217 String tentativeBranchName = SET_OP_BRANCH_PREFIX + preBuildStmtsSize + ">"; 7218 StatementGraph branchStmt; 7219 if (isPivotSelect(br)) { 7220 // Slice 140: a nested PIVOT / UNPIVOT in a set-op branch 7221 // (`SELECT ... FROM base PIVOT(...) UNION ALL ...`). The 7222 // slice-129 pivot router in {@link #buildSelectStatementImpl} 7223 // is gated to `name == null && !isPredicateBody`; a set-op 7224 // branch carries the synthetic `<set_op_branch_N>` name, so 7225 // the gate misses and the branch would otherwise fall to the 7226 // normal path which can't bind a `pivoted_table` (rejects 7227 // with the misleading TABLE_BINDING_UNRESOLVED 7228 // "null(piviot_table)"). Route it to `buildPivotSelect` with 7229 // the synthetic branch name so the branch becomes a proper 7230 // pivot StatementGraph. PIVOT extra-clause / chained / 7231 // malformed-UNPIVOT / bare-`*`-without-catalog rejects stay 7232 // deferred via `buildPivotSelect`'s own guards. The 7233 // FROM-subquery-source case is pre-flight-rejected by the 7234 // extended {@link #rejectFromSubqueriesInSetOpBranch} above, 7235 // so `buildPivotSelect.buildRelation` never lands on an 7236 // unresolvable subquery TTable here. No 7237 // `withRenamedTo` rebuild block is needed below because 7238 // `buildPivotSelect` does not extract predicate bodies during 7239 // its build (its `rejectPivotExtraClauses` fires on WHERE / 7240 // GROUP BY / HAVING / ORDER BY / QUALIFY / DISTINCT / 7241 // row-limit / set-op). 7242 // 7243 // Direct analogue of slice 138 (FROM-subquery body via 7244 // `processDirectSubqueryTable`) and slice 139 (CTE body 7245 // via the four CTE walkers). This is the FIFTH and final 7246 // SemanticIRBuilder routing site that previously missed 7247 // the pivot router. 7248 branchStmt = buildPivotSelect(br, provider, tentativeBranchName); 7249 } else { 7250 branchStmt = buildSelectStatementImpl(br, provider, tentativeBranchName, 7251 /*hasOuterCteListAlreadyProcessed=*/ false, 7252 /*allowFromSubqueries=*/ false, 7253 /*allowScalarProjectionSubqueries=*/ true, // ← slice-16 lift 7254 /*allowWindowProjection=*/ true, 7255 // Slice 113 keeps JOIN-ON predicate subqueries rejected 7256 // in set-op branches (slice 23 / 26 contract pinned by 7257 // existsInSetOpBranchJoinOnStillRejected / 7258 // lhsSubqueryInSetOpBranchRejected) — the lift is 7259 // WHERE-only. The two flags are now independent. 7260 /*allowJoinOnPredicateSubqueries=*/ false, 7261 /*stmtsForExtraction=*/ stmts, // ← slice-113 7262 /*lineageForExtraction=*/ lineage, // ← slice-113 7263 /*cteMapForExtraction=*/ cteNameToStatementIndex, // ← slice-113 7264 /*isPredicateBody=*/ false, 7265 /*whereClauseContext=*/ PredicateClauseContext.SET_OP_BRANCH_WHERE, 7266 /*allowWherePredicateSubqueries=*/ true); // ← slice-113 lift 7267 } 7268 int idx = stmts.size(); 7269 if (idx != preBuildStmtsSize) { 7270 // Slice 113 — predicate bodies were appended during 7271 // the branch build. Rebuild the StatementGraph with 7272 // the corrected name so the slice-12/16 invariant 7273 // (digit suffix == final position) survives. The 7274 // rebuild copies all 15 fields; no LineageRef is 7275 // affected because they are idx-based, not name-based 7276 // (codex round-1 Q4 resolution). 7277 String finalBranchName = SET_OP_BRANCH_PREFIX + idx + ">"; 7278 branchStmt = withRenamedTo(branchStmt, finalBranchName); 7279 } 7280 rejectDuplicateOutputNames(branchStmt, branchStmt.getName()); 7281 branchIdxs[i] = idx; 7282 stmts.add(branchStmt); 7283 // Branch's own per-output, filter, and join lineage. Pass 7284 // the branch's scalar map so STATEMENT_OUTPUT → 7285 // STATEMENT_OUTPUT edges to scalar bodies are emitted. 7286 // subqueryAliasToIndex stays empty (allowFromSubqueries=false 7287 // for branches, so no FROM-subquery aliases exist at this scope). 7288 emitLineageForStatement(branchStmt, idx, lineage, 7289 cteNameToStatementIndex, 7290 Collections.<String, Integer>emptyMap(), 7291 branchScalarMap); 7292 } 7293 7294 // Validate column-count alignment via BUILT statements. 7295 int expectedCols = stmts.get(branchIdxs[0]).getOutputColumns().size(); 7296 for (int i = 1; i < branches.size(); i++) { 7297 int n = stmts.get(branchIdxs[i]).getOutputColumns().size(); 7298 if (n != expectedCols) { 7299 throw new SemanticIRBuildException( 7300 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COLUMN_COUNT_MISMATCH, 7301 "set-op branch column-count mismatch: branch[0] has " 7302 + expectedCols + " columns, branch[" + i + "] has " + n, (TParseTreeNode) null)); 7303 } 7304 } 7305 7306 // Build outer outputs from branch[0]'s built outputs. 7307 StatementGraph branch0 = stmts.get(branchIdxs[0]); 7308 List<OutputColumn> outerOutputs = new ArrayList<>(expectedCols); 7309 Set<String> seenOuter = new HashSet<>(); 7310 for (int i = 0; i < expectedCols; i++) { 7311 OutputColumn b0 = branch0.getOutputColumns().get(i); 7312 String name = b0.getName(); 7313 if (name == null || name.isEmpty()) { 7314 throw new SemanticIRBuildException( 7315 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_OUTPUT_NAME_UNUSABLE, 7316 "set-op output position " + i + " has no usable name in branch[0]; " 7317 + "add an alias to the SELECT-list expression", (TParseTreeNode) null)); 7318 } 7319 if (!seenOuter.add(name.toLowerCase(Locale.ROOT))) { 7320 throw new SemanticIRBuildException( 7321 Diagnostic.error(DiagnosticCode.SET_OP_DUPLICATE_OUTER_OUTPUT_NAME, 7322 "set-op outer output name '" + name + "' is duplicated " 7323 + "(branch[0] has duplicate output names)", null)); 7324 } 7325 outerOutputs.add(new OutputColumn(name, 7326 /*derived=*/ true, 7327 /*aggregate=*/ false, 7328 /*sources=*/ Collections.<ColumnRef>emptyList(), 7329 /*windowSpec=*/ null)); 7330 } 7331 7332 // Slice 21: collect outer ORDER BY refs from branches' base sources. 7333 // A throw here unwinds via the slice-16 snapshot/rollback wrapper 7334 // in buildSetOpProgram(), so partially-built branches/scalar-bodies 7335 // do not leak into stmts/lineage on rejection. 7336 List<ColumnRef> outerOrderByRefs = buildSetOpOuterOrderByColumnRefs( 7337 setOp, outerOutputs, stmts, branchIdxs); 7338 7339 StatementGraph outer = new StatementGraph(setOpName, "SELECT", 7340 /*relations=*/ Collections.<RelationSource>emptyList(), 7341 outerOutputs, 7342 /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(), 7343 /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(), 7344 /*groupByColumnRefs=*/Collections.<ColumnRef>emptyList(), 7345 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 7346 /*orderByColumnRefs=*/outerOrderByRefs, 7347 /*distinctOnColumnRefs=*/Collections.<ColumnRef>emptyList(), 7348 /*distinct=*/ false, 7349 /*setOperator=*/ setOpKind, 7350 /*rowLimit=*/ setOpRowLimit); 7351 // Slice 180 (R5): the set-op outer block's span covers the whole 7352 // set-op statement. 7353 outer = outer.withSourceSpan(SourceSpan.of(setOp)); 7354 int outerIdx = stmts.size(); 7355 stmts.add(outer); 7356 7357 // Lineage: outer.outputs[i] → each branch.outputs[i] (in branch order). 7358 for (int i = 0; i < expectedCols; i++) { 7359 OutputColumn out = outer.getOutputColumns().get(i); 7360 for (int b = 0; b < branches.size(); b++) { 7361 StatementGraph branchStmt = stmts.get(branchIdxs[b]); 7362 String branchOutName = branchStmt.getOutputColumns().get(i).getName(); 7363 lineage.add(new LineageEdge( 7364 LineageRef.statementOutput(outerIdx, out.getName()), 7365 LineageRef.statementOutput(branchIdxs[b], branchOutName))); 7366 } 7367 } 7368 return outerIdx; 7369 } 7370 7371 /** 7372 * Reject row-limit clauses on an INTERNAL (non-root) set-op node. 7373 * Slice 9 / 12 rationale: with ORDER BY they decide which rows 7374 * survive, so the canonical-model exclusion of ORDER BY is only 7375 * sound when no row-limit is present. 7376 * 7377 * <p>Slice 21 split this from {@code rejectSetOpInternalOrderBy} 7378 * because the OUTER set-op node lifts its ORDER BY (collected via 7379 * {@link #buildSetOpOuterOrderByColumnRefs}). Slice 72 narrows the 7380 * row-limit guard the same way: the OUTER set-op node now lifts 7381 * row-limit metadata (collected via {@link #buildSetOpRowLimit}), 7382 * while parenthesized inner combined operations carrying a 7383 * row-limit (e.g. {@code (A UNION B LIMIT 3) UNION C}) remain 7384 * rejected because the intermediate limit is destroyed by the 7385 * outer set operation. 7386 */ 7387 private static void rejectSetOpRowLimit(TSelectSqlStatement node) { 7388 if (node.getLimitClause() != null) { 7389 throw new SemanticIRBuildException( 7390 Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED, 7391 "row-limit clause LIMIT on a non-root set-op node is not supported yet", (TParseTreeNode) null)); 7392 } 7393 if (node.getTopClause() != null) { 7394 throw new SemanticIRBuildException( 7395 Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED, 7396 "row-limit clause TOP on a non-root set-op node is not supported yet", (TParseTreeNode) null)); 7397 } 7398 if (node.getFetchFirstClause() != null) { 7399 throw new SemanticIRBuildException( 7400 Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED, 7401 "row-limit clause FETCH FIRST on a non-root set-op node is not supported yet", (TParseTreeNode) null)); 7402 } 7403 if (node.getOffsetClause() != null) { 7404 throw new SemanticIRBuildException( 7405 Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED, 7406 "row-limit clause OFFSET on a non-root set-op node is not supported yet", (TParseTreeNode) null)); 7407 } 7408 } 7409 7410 /** 7411 * Reject ORDER BY on an INTERNAL (non-root) set-op node. Slice 21 7412 * lifted ORDER BY on the OUTER (root) set-op, but parenthesized 7413 * inner combined nodes like 7414 * {@code (A UNION B ORDER BY id) UNION C} remain rejected: the 7415 * intermediate sort is destroyed by the outer set operation 7416 * (UNION does not preserve order), so the inner ORDER BY has no 7417 * observable effect. Lifting requires modelling intermediate sort 7418 * semantics — a future slice. 7419 */ 7420 private static void rejectSetOpInternalOrderBy(TSelectSqlStatement node) { 7421 if (node.getOrderbyClause() != null) { 7422 throw new SemanticIRBuildException( 7423 Diagnostic.error(DiagnosticCode.SET_OP_NON_ROOT_ORDER_BY_NOT_SUPPORTED, 7424 "ORDER BY on a non-root set-op node is not supported yet " 7425 + "(intermediate sort would be discarded by the outer set operation)", (TParseTreeNode) null)); 7426 } 7427 } 7428 7429 /** 7430 * Collect physical column refs for the outer set-op's ORDER BY 7431 * clause. Slice 21 lifts the slice-12 rejection on set-op outer 7432 * ORDER BY using the slice-9 single-SELECT pattern, generalised: 7433 * 7434 * <ul> 7435 * <li>Each sort-key item passes the same shape rejections as 7436 * {@link #buildOrderByColumnRefs} (ordinals, constants, 7437 * scalar / predicate subqueries, window functions, ORDER 7438 * SIBLINGS BY, RESET WHEN, in-clause OFFSET/FETCH). 7439 * <li>Each {@link TObjectName} reference dispatches via a 7440 * four-case fail-closed taxonomy: {@code column_alias} → 7441 * lookup via {@code toString()}; unqualified {@code column} 7442 * → lookup via {@code getColumnNameOnly()}; qualified 7443 * {@code column} → reject (set-op outer scope is the 7444 * unioned outputs, not branches' tables); other 7445 * {@code dbObjectType} → reject as unsupported. 7446 * <li>The lookup is positional against {@code outerOutputs} (= 7447 * branch[0].outputs by slice-12 design) — NOT per-branch 7448 * name search. Per-branch name search would mis-bind 7449 * swapped-name branches and silently accept names present 7450 * only in non-branch[0]. Slice-21 codex rounds 1-2 MUSTs. 7451 * <li>Each branch contributes its 7452 * {@code outputColumns[pos].sources} for the matched 7453 * position. Branches with empty sources at the matched 7454 * position (scalar / fully-derived projection) reject the 7455 * sort key with a tuned message — silent omission would 7456 * lose dependency information (slice-21 codex round 1 7457 * MUST 5). 7458 * <li>Each branch-local {@link ColumnRef} is normalised to its 7459 * catalog name via {@link RelationSource#getBinding()}'s 7460 * {@code qualifiedName}. The set-op outer's relations list 7461 * is empty, so branch-local aliases are not resolvable in 7462 * the owning statement; normalisation yields self-contained 7463 * refs (slice-21 codex round 1 MUST 4 + round 2 MUST 1). 7464 * <li>A per-item empty-refs guard rejects sort keys that 7465 * contributed zero physical refs (e.g. {@code ORDER BY 7466 * 1+0}, {@code ORDER BY UPPER('x')}). Mirrors slice-9 7467 * single-SELECT invariant. Operates on a per-item local 7468 * set, so duplicate cross-item refs ({@code ORDER BY id, 7469 * id}) survive global LinkedHashSet de-duplication 7470 * (slice-21 codex round 4 MUST 1). 7471 * </ul> 7472 * 7473 * <p>Like slice-9 ORDER BY for single-SELECT, this list does NOT 7474 * contribute to the canonical model — it is presentation metadata 7475 * only. The dlineage XML probe ({@code /tmp/SetOpOrderByLimitProbe}) 7476 * confirmed dlineage emits no parity edges for set-op outer 7477 * ORDER BY. 7478 */ 7479 private static List<ColumnRef> buildSetOpOuterOrderByColumnRefs( 7480 TSelectSqlStatement setOp, 7481 List<OutputColumn> outerOutputs, 7482 List<StatementGraph> stmts, 7483 int[] branchIdxs) { 7484 TOrderBy orderBy = setOp.getOrderbyClause(); 7485 if (orderBy == null) { 7486 return new ArrayList<>(); 7487 } 7488 if (orderBy.isSiblings()) { 7489 throw new SemanticIRBuildException( 7490 Diagnostic.error(DiagnosticCode.ORDER_SIBLINGS_BY_NOT_SUPPORTED, 7491 "ORDER SIBLINGS BY is not supported yet " 7492 + "(Oracle hierarchical ordering)", orderBy)); 7493 } 7494 if (orderBy.getResetWhenCondition() != null) { 7495 throw new SemanticIRBuildException( 7496 Diagnostic.error(DiagnosticCode.ORDER_BY_RESET_WHEN_NOT_SUPPORTED, 7497 "ORDER BY ... RESET WHEN is not supported yet " 7498 + "(Teradata window-style restart)", orderBy)); 7499 } 7500 // Slice 72: TOrderBy in-clause OFFSET/FETCH slots are admitted 7501 // for MSSQL set-op outer via buildSetOpRowLimit's TOrderBy 7502 // fallback (the MSSQL parser routes set-op outer OFFSET/FETCH 7503 // EXCLUSIVELY onto TOrderBy, not duplicated onto the SELECT 7504 // node as in single-SELECT). Removing the previous defensive 7505 // throws here so the slice-72 admit shapes aren't false- 7506 // rejected. The unused codes 7507 // ORDER_BY_FETCH_FIRST_NOT_SUPPORTED and 7508 // ORDER_BY_OFFSET_NOT_SUPPORTED stay as documentation of a 7509 // known reject taxonomy. 7510 TOrderByItemList items = orderBy.getItems(); 7511 if (items == null || items.size() == 0) { 7512 return new ArrayList<>(); 7513 } 7514 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 7515 for (int i = 0; i < items.size(); i++) { 7516 TOrderByItem item = items.getOrderByItem(i); 7517 if (item == null) continue; 7518 TExpression sortKey = item.getSortKey(); 7519 if (sortKey == null) continue; 7520 // Same shape rejections as slice-9 single-SELECT. 7521 rejectOrderByOrdinalOrConstant(sortKey); 7522 rejectOrderByScalarSubquery(sortKey); 7523 rejectOrderByWindowFunction(sortKey); 7524 // (NOT rejectOrderByAliasReference — alias refs are valid 7525 // at set-op outer scope; they ARE the branch-output names, 7526 // looked up positionally below.) 7527 7528 // Per-item local set so the empty-refs guard counts refs 7529 // FOUND for this sort key, not refs ADDED to the global 7530 // set after de-dup. Otherwise `ORDER BY id, id` would 7531 // false-reject the second item (slice-21 codex round 4 MUST 1). 7532 LinkedHashSet<ColumnRef> itemRefs = new LinkedHashSet<>(); 7533 collectSetOpOuterRefsForSortKey(sortKey, outerOutputs, 7534 stmts, branchIdxs, itemRefs); 7535 if (itemRefs.isEmpty()) { 7536 throw new SemanticIRBuildException( 7537 Diagnostic.error(DiagnosticCode.SET_OP_OUTER_ORDER_BY_NO_PHYSICAL_COLUMN_REFS, 7538 "ORDER BY sort key '" + sortKey 7539 + "' has no physical column references at set-op " 7540 + "outer (constant or non-column expressions are " 7541 + "not supported yet)", sortKey)); 7542 } 7543 refs.addAll(itemRefs); 7544 } 7545 return new ArrayList<>(refs); 7546 } 7547 7548 /** 7549 * Collect refs for one set-op outer ORDER BY sort key. Walks the 7550 * sort-key expression for {@link TObjectName} nodes and dispatches 7551 * each through {@link #processSetOpOrderByObjectName}. Includes a 7552 * top-level fast path for the common {@code ORDER BY x} case where 7553 * the entire sort key IS the {@link TObjectName}. 7554 * 7555 * <p>The visitor filters its dispatch to {@code column}, 7556 * {@code column_alias}, and {@code unknown} dbObjectTypes — these 7557 * are the shapes that represent sort-key column references. Other 7558 * TObjectName nodes (function names, schema qualifications) are 7559 * part of the surrounding expression structure and skipped 7560 * silently. The {@code unknown} case is included so the four-case 7561 * fail-closed taxonomy in {@link #processSetOpOrderByObjectName} 7562 * rejects vendor-typed unknown qualified refs (e.g. 7563 * {@code foo.id + id}, slice-21 codex round 2 MUST 2). 7564 */ 7565 private static void collectSetOpOuterRefsForSortKey( 7566 TExpression sortKey, 7567 final List<OutputColumn> outerOutputs, 7568 final List<StatementGraph> stmts, 7569 final int[] branchIdxs, 7570 final LinkedHashSet<ColumnRef> outRefs) { 7571 // Top-level fast path: the visitor's `acceptChildren` may not 7572 // visit the root TObjectName when the sort key is itself a 7573 // bare TObjectName. Mirrors slice-9 rejectOrderByAliasReference. 7574 if (sortKey.getExpressionType() == EExpressionType.simple_object_name_t) { 7575 TObjectName op = sortKey.getObjectOperand(); 7576 if (op != null) { 7577 processSetOpOrderByObjectName(op, outerOutputs, stmts, 7578 branchIdxs, outRefs); 7579 return; 7580 } 7581 } 7582 sortKey.acceptChildren(new TParseTreeVisitor() { 7583 @Override 7584 public void preVisit(TObjectName node) { 7585 EDbObjectType ot = node.getDbObjectType(); 7586 // Skip non-column-like TObjectNames (function names, 7587 // schema/server qualifications). The four-case 7588 // fail-closed taxonomy still runs for column / 7589 // column_alias / unknown to handle the slice-21 codex 7590 // round 2 MUST 2 partial-accept case (e.g. 7591 // `foo.id + id` rejects via `foo.id`'s `unknown` 7592 // dbObjectType). 7593 if (ot != EDbObjectType.column 7594 && ot != EDbObjectType.column_alias 7595 && ot != EDbObjectType.unknown) { 7596 return; 7597 } 7598 processSetOpOrderByObjectName(node, outerOutputs, stmts, 7599 branchIdxs, outRefs); 7600 } 7601 }); 7602 } 7603 7604 /** 7605 * Resolve one {@link TObjectName} sort-key reference at set-op 7606 * outer scope. Four-case fail-closed taxonomy (slice-21 codex 7607 * round 2 MUST 2): column_alias / unqualified column / qualified 7608 * column / other. 7609 */ 7610 private static void processSetOpOrderByObjectName( 7611 TObjectName node, 7612 List<OutputColumn> outerOutputs, 7613 List<StatementGraph> stmts, 7614 int[] branchIdxs, 7615 LinkedHashSet<ColumnRef> outRefs) { 7616 EDbObjectType ot = node.getDbObjectType(); 7617 String name; 7618 if (ot == EDbObjectType.column_alias) { 7619 // Aliases at set-op outer carry tableToken=alias-name (the 7620 // /tmp/SetOpQualifiedRefProbe finding); accept regardless. 7621 name = node.toString(); 7622 } else if (ot == EDbObjectType.column) { 7623 if (node.getTableToken() != null) { 7624 throw new SemanticIRBuildException( 7625 Diagnostic.error(DiagnosticCode.ORDER_BY_QUALIFIED_REFERENCE_NOT_SUPPORTED, 7626 "qualified column reference '" + node 7627 + "' in set-op outer ORDER BY not supported " 7628 + "(scope is the unioned outputs, not branches' tables)", node)); 7629 } 7630 name = node.getColumnNameOnly(); 7631 } else { 7632 throw new SemanticIRBuildException( 7633 Diagnostic.error(DiagnosticCode.ORDER_BY_OBJECT_REFERENCE_UNSUPPORTED, 7634 "unsupported ORDER BY object reference '" + node 7635 + "' (dbObjectType=" + ot + ") in set-op outer", node)); 7636 } 7637 if (name == null || name.isEmpty() || "*".equals(name)) { 7638 throw new SemanticIRBuildException( 7639 Diagnostic.error(DiagnosticCode.ORDER_BY_OBJECT_REFERENCE_NO_USABLE_NAME, 7640 "ORDER BY object reference '" + node + "' has no usable name", node)); 7641 } 7642 String key = name.toLowerCase(Locale.ROOT); 7643 int pos = -1; 7644 for (int i = 0; i < outerOutputs.size(); i++) { 7645 String outName = outerOutputs.get(i).getName(); 7646 if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) { 7647 pos = i; 7648 break; 7649 } 7650 } 7651 if (pos < 0) { 7652 throw new SemanticIRBuildException( 7653 Diagnostic.error(DiagnosticCode.ORDER_BY_NAME_NOT_MATCHED_IN_SET_OP_OUTPUT, 7654 "ORDER BY '" + name + "' does not match any set-op output " 7655 + "column (set-op outer column names come from branch[0])", node)); 7656 } 7657 for (int b = 0; b < branchIdxs.length; b++) { 7658 StatementGraph br = stmts.get(branchIdxs[b]); 7659 OutputColumn oc = br.getOutputColumns().get(pos); 7660 if (oc.getSources().isEmpty()) { 7661 throw new SemanticIRBuildException( 7662 Diagnostic.error(DiagnosticCode.SET_OP_ORDER_BY_BRANCH_OUTPUT_NO_SOURCES, 7663 "ORDER BY '" + name + "' references branch[" + b 7664 + "] output '" + oc.getName() 7665 + "' which has no physical sources " 7666 + "(derived/scalar projection); cannot " 7667 + "capture this dependency yet", node)); 7668 } 7669 for (ColumnRef cr : oc.getSources()) { 7670 outRefs.add(normaliseSetOpBranchRef(cr, br)); 7671 } 7672 } 7673 } 7674 7675 /** 7676 * Normalise a branch-local {@link ColumnRef} to a self-contained 7677 * ref using the underlying {@link RelationBinding#getQualifiedName()}. 7678 * 7679 * <p>Slice-21 invariant (codex round 2 MUST 1): the set-op outer's 7680 * {@code relations} list is empty. Branch-local aliases (like 7681 * {@code e} for {@code FROM employees e}) are not resolvable in 7682 * the outer statement, so {@code orderByColumnRefs} normalises to 7683 * the catalog name. Fail-closed if no matching RelationSource — 7684 * this would indicate corrupt branch lineage state. 7685 */ 7686 private static ColumnRef normaliseSetOpBranchRef(ColumnRef cr, 7687 StatementGraph branch) { 7688 String alias = cr.getRelationAlias(); 7689 String aliasKey = alias.toLowerCase(Locale.ROOT); 7690 for (RelationSource rs : branch.getRelations()) { 7691 if (rs.getAlias().toLowerCase(Locale.ROOT).equals(aliasKey)) { 7692 return new ColumnRef(rs.getBinding().getQualifiedName(), 7693 cr.getColumnName()); 7694 } 7695 } 7696 throw new SemanticIRBuildException( 7697 Diagnostic.error(DiagnosticCode.BRANCH_COLUMN_REF_UNKNOWN_RELATION, 7698 "internal: branch ColumnRef relationAlias '" + alias 7699 + "' does not match any RelationSource in the " 7700 + "branch's relations list", null)); 7701 } 7702 7703 /** 7704 * Reject duplicate output names within a single statement. 7705 * Lineage refs are keyed by {@code (statementIndex, outputName)}; two 7706 * outputs sharing a name silently merge their lineage chains. 7707 */ 7708 private static void rejectDuplicateOutputNames(StatementGraph stmt, String label) { 7709 Set<String> seen = new HashSet<>(); 7710 for (OutputColumn c : stmt.getOutputColumns()) { 7711 String name = c.getName(); 7712 if (name == null || name.isEmpty()) continue; 7713 if (!seen.add(name.toLowerCase(Locale.ROOT))) { 7714 throw new SemanticIRBuildException( 7715 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_DUPLICATE_OUTPUT_NAME, 7716 "set-op branch '" + label + "' has duplicate output name '" 7717 + name + "'; lineage refs are keyed by output name " 7718 + "and would collide", null)); 7719 } 7720 } 7721 } 7722 7723 /** 7724 * Map ({@link ESetOperatorType}, {@code isAll()}) to the IR 7725 * {@link SetOperator} enum. The exhaustive switch makes a future 7726 * {@code ESetOperatorType} value fail loudly at build time 7727 * (mirrors slice-8 {@code resolveDistinctFlag} pattern). 7728 */ 7729 private static SetOperator resolveSetOperator(TSelectSqlStatement setOp) { 7730 ESetOperatorType type = setOp.getSetOperatorType(); 7731 if (type == null) { 7732 throw new SemanticIRBuildException( 7733 Diagnostic.error(DiagnosticCode.SET_OP_ROOT_TYPE_NULL, 7734 "expected non-null set-op type on the set-op root", (TParseTreeNode) null)); 7735 } 7736 boolean all = setOp.isAll(); 7737 switch (type) { 7738 case union: return all ? SetOperator.UNION_ALL : SetOperator.UNION; 7739 case intersect: return all ? SetOperator.INTERSECT_ALL : SetOperator.INTERSECT; 7740 case minus: return all ? SetOperator.MINUS_ALL : SetOperator.MINUS; 7741 case except: return all ? SetOperator.EXCEPT_ALL : SetOperator.EXCEPT; 7742 case none: 7743 throw new SemanticIRBuildException( 7744 Diagnostic.error(DiagnosticCode.SET_OP_ROOT_TYPE_NONE, 7745 "expected non-none set operator type on the set-op root", (TParseTreeNode) null)); 7746 default: 7747 throw new SemanticIRBuildException( 7748 Diagnostic.error(DiagnosticCode.SET_OP_UNKNOWN_OPERATOR_TYPE, 7749 "unknown set operator type: " + type, (TParseTreeNode) null)); 7750 } 7751 } 7752 7753 /** 7754 * Iteratively flatten the left-leaning set-op tree into a list of 7755 * leaf SELECT statements (CLAUDE.md mandates no recursion on 7756 * {@code leftStmt}/{@code rightStmt}; would StackOverflow on 2000+ 7757 * UNIONs). 7758 * 7759 * <p>On every internal set-op node visited: 7760 * <ol> 7761 * <li>Reject row-limit modifiers ({@link #rejectSetOpRowLimit}) 7762 * on every node (root + internal). Slice 12 + 7763 * {@code /tmp/SetOpInnerModifierProbe}: parenthesized inner 7764 * combined nodes can carry row-limits in Oracle / PostgreSQL / 7765 * MSSQL.</li> 7766 * <li>Reject ORDER BY ({@link #rejectSetOpInternalOrderBy}) only 7767 * on INTERNAL (non-root) nodes. Slice 21 lifted ORDER BY on 7768 * the root via {@link #buildSetOpOuterOrderByColumnRefs}; an 7769 * internal {@code (A UNION B ORDER BY id) UNION C} sort is 7770 * still discarded by the outer set operation, so it has no 7771 * observable effect and remains rejected.</li> 7772 * <li>Reject mixed-operator and mixed-{@code ALL} chains by checking 7773 * the resolved kind matches the root's kind.</li> 7774 * <li>Hard-reject malformed AST (null left/right child).</li> 7775 * </ol> 7776 * 7777 * <p>Push order is right-then-left so leaves emerge in left-to-right 7778 * declaration order. 7779 */ 7780 private static List<TSelectSqlStatement> flattenSetOpTreeIteratively( 7781 TSelectSqlStatement root, SetOperator expected) { 7782 List<TSelectSqlStatement> leaves = new ArrayList<>(); 7783 Deque<TSelectSqlStatement> stack = new ArrayDeque<>(); 7784 stack.push(root); 7785 while (!stack.isEmpty()) { 7786 TSelectSqlStatement cur = stack.pop(); 7787 ESetOperatorType t = cur.getSetOperatorType(); 7788 if (t != null && t != ESetOperatorType.none) { 7789 // Slice 21: ORDER BY guard fires only on INTERNAL nodes. 7790 // The root (`cur == root`) lifts ORDER BY; the collection 7791 // happens in buildSetOpOuterOrderByColumnRefs. 7792 // Slice 72: row-limit guard ALSO fires only on INTERNAL 7793 // nodes. The root lifts via buildSetOpRowLimit (called 7794 // by buildSetOpProgram before this method). 7795 if (cur != root) { 7796 rejectSetOpRowLimit(cur); 7797 rejectSetOpInternalOrderBy(cur); 7798 } 7799 SetOperator curKind = resolveSetOperator(cur); 7800 if (curKind != expected) { 7801 throw new SemanticIRBuildException( 7802 Diagnostic.error(DiagnosticCode.MIXED_SET_OPERATORS_NOT_SUPPORTED, 7803 "mixed set operators in a single chain are not supported yet " 7804 + "(root=" + expected + ", inner=" + curKind + ")", (TParseTreeNode) null)); 7805 } 7806 if (cur.getLeftStmt() == null || cur.getRightStmt() == null) { 7807 throw new SemanticIRBuildException( 7808 Diagnostic.error(DiagnosticCode.MALFORMED_SET_OP_AST, 7809 "malformed set-op AST: null left/right child", (TParseTreeNode) null)); 7810 } 7811 stack.push(cur.getRightStmt()); 7812 stack.push(cur.getLeftStmt()); 7813 } else { 7814 leaves.add(cur); 7815 } 7816 } 7817 return leaves; 7818 } 7819 7820 private static Set<String> collectCteNames(TCTEList cteList) { 7821 if (cteList == null || cteList.size() == 0) return Collections.emptySet(); 7822 Set<String> names = new HashSet<>(); 7823 for (int i = 0; i < cteList.size(); i++) { 7824 String name = cteList.getCTE(i).getTableName().toString(); 7825 if (name != null && !name.isEmpty()) { 7826 names.add(name.toLowerCase(Locale.ROOT)); 7827 } 7828 } 7829 return names; 7830 } 7831 7832 /** 7833 * Slice 107 — return the first CTE name shared between the outer-WITH 7834 * and inner-WITH CTE lists on an INSERT (case-insensitive, lowercase 7835 * via {@code toLowerCase(Locale.ROOT)} matching the slice-15/103 7836 * duplicate-name walker convention), or {@code null} if the name sets 7837 * are disjoint. Used by {@code buildInsert} to keep the 7838 * shared-name case rejecting (PG/Oracle/Snowflake nested-WITH 7839 * inner-shadows-outer semantics not yet supported) while admitting 7840 * the disjoint case via flat-merge. 7841 * 7842 * <p>Pathological edge case (codex round-2 diff-review Q3): if one of 7843 * the two lists also contains an INTRA-list duplicate AND that 7844 * duplicated name happens to also appear in the other list, this 7845 * helper short-circuits with 7846 * INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED and masks the 7847 * more precise same-scope DUPLICATE_CTE_NAME the slice-103 walker 7848 * would have emitted. Accepted limitation — the diagnostic still 7849 * tells the user the shape is unsupported, and both codes point at 7850 * the same offending name. A future slice can pre-walk each list 7851 * for intra-list duplicates before the boundary check if a 7852 * customer reports confusion. 7853 */ 7854 private static String findFirstSharedCteName(TCTEList outer, TCTEList inner) { 7855 Set<String> outerNames = new HashSet<>(); 7856 for (int i = 0; i < outer.size(); i++) { 7857 outerNames.add(outer.getCTE(i).getTableName().toString().toLowerCase(Locale.ROOT)); 7858 } 7859 for (int i = 0; i < inner.size(); i++) { 7860 String name = inner.getCTE(i).getTableName().toString(); 7861 if (outerNames.contains(name.toLowerCase(Locale.ROOT))) { 7862 return name; 7863 } 7864 } 7865 return null; 7866 } 7867 7868 /** 7869 * Reject the case where a CTE body references a sibling CTE declared 7870 * <i>after</i> it. SQL chain semantics only allow left-to-right 7871 * references, but the bind-by-name provider would happily classify a 7872 * forward-declared CTE name as a base {@code TABLE} (because it's not 7873 * yet in {@code visibleSoFar}). Catching it here turns the silent 7874 * mislabeling into a clear error. 7875 */ 7876 private static void rejectForwardCteReferences(final TCTE cte, 7877 final Set<String> allCteNames, 7878 final Set<String> visibleSoFar) { 7879 TSelectSqlStatement body = cte.getSubquery(); 7880 if (body == null) return; 7881 final String selfName = cte.getTableName().toString().toLowerCase(Locale.ROOT); 7882 final List<String> forwards = new ArrayList<>(); 7883 body.acceptChildren(new TParseTreeVisitor() { 7884 @Override 7885 public void preVisit(TTable t) { 7886 String tname = bareName(t); 7887 if (tname == null) return; 7888 String lower = tname.toLowerCase(Locale.ROOT); 7889 if (allCteNames.contains(lower) 7890 && !visibleSoFar.contains(lower) 7891 && !lower.equals(selfName)) { 7892 forwards.add(tname); 7893 } 7894 } 7895 }); 7896 if (!forwards.isEmpty()) { 7897 throw new SemanticIRBuildException( 7898 Diagnostic.error(DiagnosticCode.CTE_FORWARD_REFERENCE, 7899 "CTE '" + cte.getTableName() + "' forward-references later CTE(s) " 7900 + forwards + "; only left-to-right CTE chains are supported", cte)); 7901 } 7902 } 7903 7904 private static String bareName(TTable t) { 7905 if (t == null) return null; 7906 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) return null; 7907 return t.getName(); 7908 } 7909 7910 /** 7911 * Reject {@code WITH RECURSIVE}. Slice 4 supports chained 7912 * (forward-referencing) CTEs; recursion is left for a later slice that 7913 * can model the fixpoint semantics. 7914 */ 7915 private static void rejectRecursiveCtes(TCTEList cteList) { 7916 if (cteList == null) return; 7917 for (int i = 0; i < cteList.size(); i++) { 7918 TCTE cte = cteList.getCTE(i); 7919 if (cte.isRecursive()) { 7920 throw new SemanticIRBuildException( 7921 Diagnostic.error(DiagnosticCode.CTE_WITH_RECURSIVE_NOT_SUPPORTED, 7922 "WITH RECURSIVE is not supported yet (CTE: " + cte.getTableName() + ")", cte)); 7923 } 7924 } 7925 } 7926 7927 /** 7928 * Slice 101 — walk the WITH clause on a MERGE statement and append 7929 * each CTE body to {@code stmts} as a preceding statement. Mirrors 7930 * the SELECT-side build() at lines ~516-653. 7931 * 7932 * <p>Returns a {@code cteNameToStatementIndex} map keyed by 7933 * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated 7934 * with each CTE's output column names so the {@code buildMerge} 7935 * USING-as-CTE branch can install them via 7936 * {@link NameBindingProvider#withInScopeRelationColumns}. 7937 * 7938 * <p>Rejects (chronological): 7939 * <ol> 7940 * <li>WITH RECURSIVE — reuses {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}. 7941 * Currently no admitting vendor (PG parser PARSE_FAILED, probe 7942 * 2026-05-17); defensive reject for forward compatibility.</li> 7943 * <li>CTE with explicit column list — rejects with new 7944 * {@link DiagnosticCode#MERGE_CTE_EXPLICIT_COLUMN_LIST_NOT_SUPPORTED}. 7945 * PG and MSSQL parsers admit this shape; slice 101 defers 7946 * because the inner CTE body output names ≠ user-visible CTE 7947 * column names.</li> 7948 * <li>Duplicate CTE name — reuses {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li> 7949 * <li>Forward CTE reference — reuses {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li> 7950 * </ol> 7951 * 7952 * <p>Set-op CTE bodies route through {@link #buildSetOpProgram}; 7953 * non-set-op CTE bodies route through {@link #buildSelectStatement}. 7954 * Each CTE's published columns are added to {@code ctePublishedColumnsOut} 7955 * after its body is built so a CTE cannot self-reference (mirrors 7956 * SELECT-side slice 60). 7957 */ 7958 private static Map<String, Integer> buildMergeCteList( 7959 TMergeSqlStatement merge, 7960 NameBindingProvider provider, 7961 List<StatementGraph> stmts, 7962 List<LineageEdge> lineage, 7963 Map<String, List<String>> ctePublishedColumnsOut) { 7964 TCTEList cteList = merge.getCteList(); 7965 Map<String, Integer> cteNameToStatementIndex = new HashMap<>(); 7966 if (cteList == null || cteList.size() == 0) { 7967 return cteNameToStatementIndex; 7968 } 7969 rejectRecursiveCtes(cteList); 7970 // Slice 102 — explicit-column-list shapes (PG/MSSQL `WITH cte(a, b) AS 7971 // (...) MERGE ...`) are admitted by rebuilding the body's 7972 // StatementGraph with the explicit-list names and rewriting outgoing 7973 // STATEMENT_OUTPUT lineage refs. The slice-101 upfront reject is 7974 // replaced by per-CTE rename application below. The slice-101 code 7975 // (MERGE_CTE_EXPLICIT_COLUMN_LIST_NOT_SUPPORTED) stays declared for 7976 // API stability. 7977 Set<String> allCteNames = collectCteNames(cteList); 7978 Set<String> visibleSoFar = new HashSet<>(); 7979 for (int i = 0; i < cteList.size(); i++) { 7980 TCTE cte = cteList.getCTE(i); 7981 String cteName = cte.getTableName().toString(); 7982 String cteNameLower = cteName.toLowerCase(Locale.ROOT); 7983 if (visibleSoFar.contains(cteNameLower)) { 7984 throw new SemanticIRBuildException( 7985 Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME, 7986 "duplicate CTE name '" + cteName 7987 + "' in WITH clause; CTE names must be unique", 7988 cte)); 7989 } 7990 rejectForwardCteReferences(cte, allCteNames, visibleSoFar); 7991 NameBindingProvider bodyProvider = 7992 provider.withCteContext(visibleSoFar); 7993 // Slice 102 — snapshot the lineage size BEFORE either branch so 7994 // the rename helper can rewrite outgoing STATEMENT_OUTPUT refs 7995 // in [lineageSize0, lineage.size()) without touching prior CTE 7996 // bodies' edges. Covers BOTH set-op and non-set-op branches 7997 // (codex round-1 plan-review BLOCKING). 7998 int lineageSize0 = lineage.size(); 7999 int bodyIdx; 8000 TSelectSqlStatement cteBody = cte.getSubquery(); 8001 if (cteBody != null 8002 && cteBody.getSetOperatorType() != null 8003 && cteBody.getSetOperatorType() != ESetOperatorType.none) { 8004 bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, 8005 lineage, cteNameToStatementIndex, cteName, 8006 /*hasOuterCteListAlreadyProcessed=*/ false); 8007 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8008 } else { 8009 int cteStmtsSize0 = stmts.size(); 8010 int cteLineageSize0 = lineage.size(); 8011 Map<String, Integer> cteSubqueryAliasToIndex; 8012 try { 8013 cteSubqueryAliasToIndex = 8014 extractFromSubqueriesAsStatements(cteBody, 8015 bodyProvider, stmts, lineage, 8016 cteNameToStatementIndex, 8017 ctePublishedColumnsOut); 8018 } catch (RuntimeException ex) { 8019 while (stmts.size() > cteStmtsSize0) { 8020 stmts.remove(stmts.size() - 1); 8021 } 8022 while (lineage.size() > cteLineageSize0) { 8023 lineage.remove(lineage.size() - 1); 8024 } 8025 throw ex; 8026 } 8027 EnclosingScope cteEnclosing = buildEnclosingScope(cteBody, 8028 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8029 /*parent=*/ null); 8030 Map<Integer, ScalarInfo> cteScalarMap = 8031 extractScalarSubqueriesAsStatements(cteBody, 8032 bodyProvider, stmts, lineage, 8033 cteNameToStatementIndex, cteEnclosing, 8034 /*allowRecursiveScalarSubqueryExtraction=*/ true); 8035 Map<String, List<String>> cteBodyInScope = 8036 buildEffectiveAliasInScopeMap(cteBody, bodyProvider, 8037 ctePublishedColumnsOut, 8038 cteSubqueryAliasToIndex, stmts); 8039 NameBindingProvider cteBodyProviderWithStar = bodyProvider 8040 .withInScopeRelationColumns(cteBodyInScope); 8041 // Slice 114 — switch to buildSelectStatementImpl with 8042 // snapshot/rollback (see the matching SELECT-side 8043 // CTE site for full rationale). 8044 int cteBodyStmtsSnapshot = stmts.size(); 8045 int cteBodyLineageSnapshot = lineage.size(); 8046 StatementGraph body; 8047 try { 8048 if (isPivotSelect(cteBody)) { 8049 // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body 8050 // (mirrors the SELECT-side buildSelectCteList branch). 8051 // The slice-129 pivot router in buildSelectStatementImpl 8052 // is gated to the OUTER SELECT (name == null), so a pivot 8053 // built here (name == cteName) would otherwise fall to the 8054 // normal path and reject with TABLE_BINDING_UNRESOLVED 8055 // ("null(piviot_table)"). The pivot CTE becomes its own 8056 // StatementGraph; the DML's CTE-as-relation path (fed from 8057 // ctePublishedColumns ← body.getOutputColumns()) carries 8058 // the cross-stmt lineage. All pivot deferrals preserved by 8059 // buildPivotSelect's own guards. 8060 body = buildPivotSelect(cteBody, 8061 cteBodyProviderWithStar, cteName); 8062 } else { 8063 body = buildSelectStatementImpl(cteBody, 8064 cteBodyProviderWithStar, cteName, 8065 /*hasOuterCteListAlreadyProcessed=*/ false, 8066 /*allowFromSubqueries=*/ true, 8067 /*allowScalarProjectionSubqueries=*/ true, 8068 /*allowWindowProjection=*/ true, 8069 /*allowJoinOnPredicateSubqueries=*/ false, 8070 /*stmtsForExtraction=*/ stmts, 8071 /*lineageForExtraction=*/ lineage, 8072 /*cteMapForExtraction=*/ cteNameToStatementIndex, 8073 /*isPredicateBody=*/ false, 8074 /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE, 8075 /*allowWherePredicateSubqueries=*/ true); 8076 } 8077 } catch (RuntimeException ex) { 8078 while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 8079 while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 8080 throw ex; 8081 } 8082 bodyIdx = stmts.size(); 8083 stmts.add(body); 8084 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8085 emitLineageForStatement(body, bodyIdx, lineage, 8086 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8087 cteScalarMap); 8088 } 8089 // Slice 102 — apply explicit-column-list rename if present. 8090 // Rebuilds stmts[bodyIdx] with renamed OutputColumns and 8091 // rewrites STATEMENT_OUTPUT(bodyIdx, oldName) refs in 8092 // lineage[lineageSize0..) to use the renamed name. Returns the 8093 // published column list (renamed if explicit list applied, 8094 // else inner names from the body). 8095 List<String> publishedCols = applyExplicitCteColumnListRename( 8096 cte, stmts, lineage, bodyIdx, lineageSize0, "MERGE"); 8097 ctePublishedColumnsOut.put(cteNameLower, publishedCols); 8098 visibleSoFar.add(cteNameLower); 8099 } 8100 return cteNameToStatementIndex; 8101 } 8102 8103 /** 8104 * Slice 105 — walk the WITH clause on an UPDATE statement and append 8105 * each CTE body to {@code stmts} as a preceding statement. Mirrors 8106 * the slice-101 MERGE walker {@link #buildMergeCteList} verbatim 8107 * except for the source of the CTE list and the 8108 * {@link #applyExplicitCteColumnListRename} {@code dmlKind} argument. 8109 * 8110 * <p>Returns a {@code cteNameToStatementIndex} map keyed by 8111 * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated 8112 * with each CTE's output column names so {@link #buildUpdateRelation} 8113 * + {@link #buildUpdateInScopeMap} can route FROM-side references to 8114 * the matching CTE as SUBQUERY-kind relations with the CTE's columns 8115 * published into the in-scope map. 8116 * 8117 * <p>The slice-103 SELECT-side CTE walker contract is reused via the 8118 * {@link #applyExplicitCteColumnListRename} helper with 8119 * {@code dmlKind="SELECT"} so the SELECT-side 8120 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} code 8121 * fires on arity mismatch (codex round-1 Q2 confirmed YES — UPDATE is 8122 * closer to ordinary SELECT than to MERGE for CTE rename semantics). 8123 * 8124 * <p>Rejects (chronological): 8125 * <ol> 8126 * <li>{@code WITH RECURSIVE} — {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}. 8127 * Currently no admitting vendor (Oracle PARSE_FAILED on outer-WITH-UPDATE).</li> 8128 * <li>Duplicate CTE name — {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li> 8129 * <li>Forward CTE reference — {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li> 8130 * <li>Explicit-column-list arity mismatch — handled by 8131 * {@link #applyExplicitCteColumnListRename} via 8132 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.</li> 8133 * </ol> 8134 */ 8135 private static Map<String, Integer> buildUpdateCteList( 8136 TUpdateSqlStatement update, 8137 NameBindingProvider provider, 8138 List<StatementGraph> stmts, 8139 List<LineageEdge> lineage, 8140 Map<String, List<String>> ctePublishedColumnsOut) { 8141 TCTEList cteList = update.getCteList(); 8142 Map<String, Integer> cteNameToStatementIndex = new HashMap<>(); 8143 if (cteList == null || cteList.size() == 0) { 8144 return cteNameToStatementIndex; 8145 } 8146 rejectRecursiveCtes(cteList); 8147 Set<String> allCteNames = collectCteNames(cteList); 8148 Set<String> visibleSoFar = new HashSet<>(); 8149 for (int i = 0; i < cteList.size(); i++) { 8150 TCTE cte = cteList.getCTE(i); 8151 String cteName = cte.getTableName().toString(); 8152 String cteNameLower = cteName.toLowerCase(Locale.ROOT); 8153 if (visibleSoFar.contains(cteNameLower)) { 8154 throw new SemanticIRBuildException( 8155 Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME, 8156 "duplicate CTE name '" + cteName 8157 + "' in WITH clause; CTE names must be unique", 8158 cte)); 8159 } 8160 rejectForwardCteReferences(cte, allCteNames, visibleSoFar); 8161 NameBindingProvider bodyProvider = 8162 provider.withCteContext(visibleSoFar); 8163 int lineageSize0 = lineage.size(); 8164 int bodyIdx; 8165 TSelectSqlStatement cteBody = cte.getSubquery(); 8166 if (cteBody != null 8167 && cteBody.getSetOperatorType() != null 8168 && cteBody.getSetOperatorType() != ESetOperatorType.none) { 8169 bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, 8170 lineage, cteNameToStatementIndex, cteName, 8171 /*hasOuterCteListAlreadyProcessed=*/ false); 8172 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8173 } else { 8174 int cteStmtsSize0 = stmts.size(); 8175 int cteLineageSize0 = lineage.size(); 8176 Map<String, Integer> cteSubqueryAliasToIndex; 8177 try { 8178 cteSubqueryAliasToIndex = 8179 extractFromSubqueriesAsStatements(cteBody, 8180 bodyProvider, stmts, lineage, 8181 cteNameToStatementIndex, 8182 ctePublishedColumnsOut); 8183 } catch (RuntimeException ex) { 8184 while (stmts.size() > cteStmtsSize0) { 8185 stmts.remove(stmts.size() - 1); 8186 } 8187 while (lineage.size() > cteLineageSize0) { 8188 lineage.remove(lineage.size() - 1); 8189 } 8190 throw ex; 8191 } 8192 EnclosingScope cteEnclosing = buildEnclosingScope(cteBody, 8193 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8194 /*parent=*/ null); 8195 Map<Integer, ScalarInfo> cteScalarMap = 8196 extractScalarSubqueriesAsStatements(cteBody, 8197 bodyProvider, stmts, lineage, 8198 cteNameToStatementIndex, cteEnclosing, 8199 /*allowRecursiveScalarSubqueryExtraction=*/ true); 8200 Map<String, List<String>> cteBodyInScope = 8201 buildEffectiveAliasInScopeMap(cteBody, bodyProvider, 8202 ctePublishedColumnsOut, 8203 cteSubqueryAliasToIndex, stmts); 8204 NameBindingProvider cteBodyProviderWithStar = bodyProvider 8205 .withInScopeRelationColumns(cteBodyInScope); 8206 // Slice 114 — switch to buildSelectStatementImpl with 8207 // snapshot/rollback (see the matching SELECT-side 8208 // CTE site for full rationale). 8209 int cteBodyStmtsSnapshot = stmts.size(); 8210 int cteBodyLineageSnapshot = lineage.size(); 8211 StatementGraph body; 8212 try { 8213 if (isPivotSelect(cteBody)) { 8214 // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body 8215 // (mirrors the SELECT-side buildSelectCteList branch). 8216 // The slice-129 pivot router in buildSelectStatementImpl 8217 // is gated to the OUTER SELECT (name == null), so a pivot 8218 // built here (name == cteName) would otherwise fall to the 8219 // normal path and reject with TABLE_BINDING_UNRESOLVED 8220 // ("null(piviot_table)"). The pivot CTE becomes its own 8221 // StatementGraph; the DML's CTE-as-relation path (fed from 8222 // ctePublishedColumns ← body.getOutputColumns()) carries 8223 // the cross-stmt lineage. All pivot deferrals preserved by 8224 // buildPivotSelect's own guards. 8225 body = buildPivotSelect(cteBody, 8226 cteBodyProviderWithStar, cteName); 8227 } else { 8228 body = buildSelectStatementImpl(cteBody, 8229 cteBodyProviderWithStar, cteName, 8230 /*hasOuterCteListAlreadyProcessed=*/ false, 8231 /*allowFromSubqueries=*/ true, 8232 /*allowScalarProjectionSubqueries=*/ true, 8233 /*allowWindowProjection=*/ true, 8234 /*allowJoinOnPredicateSubqueries=*/ false, 8235 /*stmtsForExtraction=*/ stmts, 8236 /*lineageForExtraction=*/ lineage, 8237 /*cteMapForExtraction=*/ cteNameToStatementIndex, 8238 /*isPredicateBody=*/ false, 8239 /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE, 8240 /*allowWherePredicateSubqueries=*/ true); 8241 } 8242 } catch (RuntimeException ex) { 8243 while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 8244 while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 8245 throw ex; 8246 } 8247 bodyIdx = stmts.size(); 8248 stmts.add(body); 8249 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8250 emitLineageForStatement(body, bodyIdx, lineage, 8251 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8252 cteScalarMap); 8253 } 8254 // Slice 105 — explicit column-list rename uses dmlKind="SELECT" 8255 // so the SELECT-side CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH 8256 // code fires (codex Q2 confirmed YES — UPDATE is closer to 8257 // ordinary SELECT than MERGE for CTE rename semantics). 8258 List<String> publishedCols = applyExplicitCteColumnListRename( 8259 cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT"); 8260 ctePublishedColumnsOut.put(cteNameLower, publishedCols); 8261 visibleSoFar.add(cteNameLower); 8262 } 8263 return cteNameToStatementIndex; 8264 } 8265 8266 /** 8267 * Slice 106 — walk the WITH clause on a DELETE statement and append 8268 * each CTE body to {@code stmts} as a preceding statement. Mirrors 8269 * the slice-105 UPDATE walker {@link #buildUpdateCteList} verbatim 8270 * except for the source of the CTE list ({@code delete.getCteList()}). 8271 * 8272 * <p>Returns a {@code cteNameToStatementIndex} map keyed by 8273 * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated 8274 * with each CTE's output column names so {@link #buildDeleteRelation} 8275 * + {@link #buildDeleteInScopeMap} can route FROM-side references to 8276 * the matching CTE as SUBQUERY-kind relations with the CTE's columns 8277 * published into the in-scope map. 8278 * 8279 * <p>The slice-103 SELECT-side CTE walker contract is reused via the 8280 * {@link #applyExplicitCteColumnListRename} helper with 8281 * {@code dmlKind="SELECT"} so the SELECT-side 8282 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} code 8283 * fires on arity mismatch (slice-105 precedent: UPDATE/DELETE are 8284 * closer to ordinary SELECT than to MERGE for CTE rename semantics). 8285 * 8286 * <p>Rejects (chronological): 8287 * <ol> 8288 * <li>{@code WITH RECURSIVE} — 8289 * {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}. 8290 * PG / MySQL admit the parse shape but slice 106 rejects at the 8291 * semantic layer (mirrors slice-105 boundary).</li> 8292 * <li>Duplicate CTE name — {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li> 8293 * <li>Forward CTE reference — {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li> 8294 * <li>Explicit-column-list arity mismatch — handled by 8295 * {@link #applyExplicitCteColumnListRename} via 8296 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.</li> 8297 * </ol> 8298 */ 8299 private static Map<String, Integer> buildDeleteCteList( 8300 TDeleteSqlStatement delete, 8301 NameBindingProvider provider, 8302 List<StatementGraph> stmts, 8303 List<LineageEdge> lineage, 8304 Map<String, List<String>> ctePublishedColumnsOut) { 8305 TCTEList cteList = delete.getCteList(); 8306 Map<String, Integer> cteNameToStatementIndex = new HashMap<>(); 8307 if (cteList == null || cteList.size() == 0) { 8308 return cteNameToStatementIndex; 8309 } 8310 rejectRecursiveCtes(cteList); 8311 Set<String> allCteNames = collectCteNames(cteList); 8312 Set<String> visibleSoFar = new HashSet<>(); 8313 for (int i = 0; i < cteList.size(); i++) { 8314 TCTE cte = cteList.getCTE(i); 8315 String cteName = cte.getTableName().toString(); 8316 String cteNameLower = cteName.toLowerCase(Locale.ROOT); 8317 if (visibleSoFar.contains(cteNameLower)) { 8318 throw new SemanticIRBuildException( 8319 Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME, 8320 "duplicate CTE name '" + cteName 8321 + "' in WITH clause; CTE names must be unique", 8322 cte)); 8323 } 8324 rejectForwardCteReferences(cte, allCteNames, visibleSoFar); 8325 NameBindingProvider bodyProvider = 8326 provider.withCteContext(visibleSoFar); 8327 int lineageSize0 = lineage.size(); 8328 int bodyIdx; 8329 TSelectSqlStatement cteBody = cte.getSubquery(); 8330 if (cteBody != null 8331 && cteBody.getSetOperatorType() != null 8332 && cteBody.getSetOperatorType() != ESetOperatorType.none) { 8333 bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, 8334 lineage, cteNameToStatementIndex, cteName, 8335 /*hasOuterCteListAlreadyProcessed=*/ false); 8336 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8337 } else { 8338 int cteStmtsSize0 = stmts.size(); 8339 int cteLineageSize0 = lineage.size(); 8340 Map<String, Integer> cteSubqueryAliasToIndex; 8341 try { 8342 cteSubqueryAliasToIndex = 8343 extractFromSubqueriesAsStatements(cteBody, 8344 bodyProvider, stmts, lineage, 8345 cteNameToStatementIndex, 8346 ctePublishedColumnsOut); 8347 } catch (RuntimeException ex) { 8348 while (stmts.size() > cteStmtsSize0) { 8349 stmts.remove(stmts.size() - 1); 8350 } 8351 while (lineage.size() > cteLineageSize0) { 8352 lineage.remove(lineage.size() - 1); 8353 } 8354 throw ex; 8355 } 8356 EnclosingScope cteEnclosing = buildEnclosingScope(cteBody, 8357 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8358 /*parent=*/ null); 8359 Map<Integer, ScalarInfo> cteScalarMap = 8360 extractScalarSubqueriesAsStatements(cteBody, 8361 bodyProvider, stmts, lineage, 8362 cteNameToStatementIndex, cteEnclosing, 8363 /*allowRecursiveScalarSubqueryExtraction=*/ true); 8364 Map<String, List<String>> cteBodyInScope = 8365 buildEffectiveAliasInScopeMap(cteBody, bodyProvider, 8366 ctePublishedColumnsOut, 8367 cteSubqueryAliasToIndex, stmts); 8368 NameBindingProvider cteBodyProviderWithStar = bodyProvider 8369 .withInScopeRelationColumns(cteBodyInScope); 8370 // Slice 114 — switch to buildSelectStatementImpl with 8371 // snapshot/rollback (see the matching SELECT-side 8372 // CTE site for full rationale). 8373 int cteBodyStmtsSnapshot = stmts.size(); 8374 int cteBodyLineageSnapshot = lineage.size(); 8375 StatementGraph body; 8376 try { 8377 if (isPivotSelect(cteBody)) { 8378 // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body 8379 // (mirrors the SELECT-side buildSelectCteList branch). 8380 // The slice-129 pivot router in buildSelectStatementImpl 8381 // is gated to the OUTER SELECT (name == null), so a pivot 8382 // built here (name == cteName) would otherwise fall to the 8383 // normal path and reject with TABLE_BINDING_UNRESOLVED 8384 // ("null(piviot_table)"). The pivot CTE becomes its own 8385 // StatementGraph; the DML's CTE-as-relation path (fed from 8386 // ctePublishedColumns ← body.getOutputColumns()) carries 8387 // the cross-stmt lineage. All pivot deferrals preserved by 8388 // buildPivotSelect's own guards. 8389 body = buildPivotSelect(cteBody, 8390 cteBodyProviderWithStar, cteName); 8391 } else { 8392 body = buildSelectStatementImpl(cteBody, 8393 cteBodyProviderWithStar, cteName, 8394 /*hasOuterCteListAlreadyProcessed=*/ false, 8395 /*allowFromSubqueries=*/ true, 8396 /*allowScalarProjectionSubqueries=*/ true, 8397 /*allowWindowProjection=*/ true, 8398 /*allowJoinOnPredicateSubqueries=*/ false, 8399 /*stmtsForExtraction=*/ stmts, 8400 /*lineageForExtraction=*/ lineage, 8401 /*cteMapForExtraction=*/ cteNameToStatementIndex, 8402 /*isPredicateBody=*/ false, 8403 /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE, 8404 /*allowWherePredicateSubqueries=*/ true); 8405 } 8406 } catch (RuntimeException ex) { 8407 while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 8408 while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 8409 throw ex; 8410 } 8411 bodyIdx = stmts.size(); 8412 stmts.add(body); 8413 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8414 emitLineageForStatement(body, bodyIdx, lineage, 8415 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8416 cteScalarMap); 8417 } 8418 // Slice 106 — explicit column-list rename uses dmlKind="SELECT" 8419 // so the SELECT-side CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH 8420 // code fires (slice-105 precedent: UPDATE/DELETE are closer 8421 // to ordinary SELECT than MERGE for CTE rename semantics). 8422 List<String> publishedCols = applyExplicitCteColumnListRename( 8423 cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT"); 8424 ctePublishedColumnsOut.put(cteNameLower, publishedCols); 8425 visibleSoFar.add(cteNameLower); 8426 } 8427 return cteNameToStatementIndex; 8428 } 8429 8430 /** 8431 * Slice 105 — combine the slice-83 subqueryAliasToIndex with the 8432 * slice-105 CTE-as-FROM-relation alias→cteIdx entries so 8433 * {@link #emitUpdateSubquerySourceEdges} produces cross-stmt 8434 * lineage edges for SET RHS references resolving to a CTE column. 8435 * 8436 * <p>Without this merge the visible {@link OutputColumn#getSources} 8437 * stays correct (CTE refs surface as {@link ColumnRef}s) but 8438 * {@code lineage[]} silently loses the canonical 8439 * {@code STATEMENT_OUTPUT(update,col) → STATEMENT_OUTPUT(cte,col)} 8440 * edge (codex round-2 Q5 silent-correctness bug). 8441 * 8442 * <p>Walks {@code update.getJoins()} the same way 8443 * {@link #buildUpdateRelation} does to keep the alias resolution 8444 * identical: CTE-bound FROM-side relations are detected by their 8445 * bare name (case-insensitive) and registered under their effective 8446 * alias. Subquery aliases stay keyed lowercase to match the 8447 * slice-83 contract. 8448 */ 8449 private static Map<String, Integer> buildUpdateCombinedAliasToSubIdx( 8450 TUpdateSqlStatement update, 8451 Map<String, Integer> subqueryAliasToIndex, 8452 Map<String, Integer> cteNameToStatementIndex) { 8453 Map<String, Integer> combined = new HashMap<>(); 8454 if (subqueryAliasToIndex != null) { 8455 combined.putAll(subqueryAliasToIndex); 8456 } 8457 if (cteNameToStatementIndex == null 8458 || cteNameToStatementIndex.isEmpty()) { 8459 return combined; 8460 } 8461 TJoinList joins = update.getJoins(); 8462 if (joins == null) return combined; 8463 for (TJoin join : joins) { 8464 addCteAliasToCombinedMap(join.getTable(), 8465 cteNameToStatementIndex, combined); 8466 TJoinItemList items = join.getJoinItems(); 8467 if (items == null) continue; 8468 for (int i = 0; i < items.size(); i++) { 8469 TJoinItem item = items.getJoinItem(i); 8470 if (item == null) continue; 8471 addCteAliasToCombinedMap(item.getTable(), 8472 cteNameToStatementIndex, combined); 8473 } 8474 } 8475 return combined; 8476 } 8477 8478 private static void addCteAliasToCombinedMap(TTable t, 8479 Map<String, Integer> cteNameToStatementIndex, 8480 Map<String, Integer> combined) { 8481 if (t == null) return; 8482 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) { 8483 return; 8484 } 8485 TObjectName tName = t.getTableName(); 8486 if (tName == null) return; 8487 String bare = tName.toString(); 8488 if (bare == null || bare.isEmpty()) return; 8489 String bareLower = bare.toLowerCase(Locale.ROOT); 8490 Integer cteIdx = cteNameToStatementIndex.get(bareLower); 8491 if (cteIdx == null) return; 8492 String aliasKey = effectiveAliasLowerCaseOrNull(t); 8493 if (aliasKey == null) aliasKey = bareLower; 8494 combined.put(aliasKey, cteIdx); 8495 } 8496 8497 /** 8498 * Slice 123 — DELETE analogue of 8499 * {@link #buildUpdateCombinedAliasToSubIdx}: combine the slice-84 8500 * {@code subqueryAliasToIndex} (USING-(SELECT) FROM-subqueries) with the 8501 * slice-106 CTE-as-FROM-relation alias→cteIdx entries so the 8502 * slice-85 {@link #buildReturningColumns} walker can promote a 8503 * RETURNING/OUTPUT source ref that resolves to a SUBQUERY-kind FROM-side 8504 * relation to a cross-stmt {@code STATEMENT_OUTPUT → STATEMENT_OUTPUT} 8505 * edge. 8506 * 8507 * <p>Walks {@code delete.getReferenceJoins()} (NOT {@code getJoins()}, 8508 * which catches MySQL self-ref / multi-target shapes that still reject) 8509 * the same way {@link #buildDeleteRelation} does so alias resolution is 8510 * identical. Subquery aliases stay keyed lowercase (slice-84 contract); 8511 * CTE-as-relation entries are keyed by their effective alias via the 8512 * shared {@link #addCteAliasToCombinedMap} helper. 8513 * 8514 * <p>The {@code putAll} then {@code put} order is collision-free: the two 8515 * entry sources are disjoint in valid SQL. {@code subqueryAliasToIndex} 8516 * keys are aliases of {@code (SELECT …) sub} FROM-subqueries (non-{@code 8517 * objectname} table sources), while {@code addCteAliasToCombinedMap} only 8518 * adds {@code objectname}-typed tables whose bare name matches a declared 8519 * CTE — a single alias cannot be both, and two FROM relations sharing one 8520 * alias is a SQL error the parser/resolver rejects. Mirrors the slice-105 8521 * {@link #buildUpdateCombinedAliasToSubIdx} contract. 8522 */ 8523 private static Map<String, Integer> buildDeleteCombinedAliasToSubIdx( 8524 TDeleteSqlStatement delete, 8525 Map<String, Integer> subqueryAliasToIndex, 8526 Map<String, Integer> cteNameToStatementIndex) { 8527 Map<String, Integer> combined = new HashMap<>(); 8528 if (subqueryAliasToIndex != null) { 8529 combined.putAll(subqueryAliasToIndex); 8530 } 8531 if (cteNameToStatementIndex == null 8532 || cteNameToStatementIndex.isEmpty()) { 8533 return combined; 8534 } 8535 TJoinList refJoins = delete.getReferenceJoins(); 8536 if (refJoins == null) return combined; 8537 for (int ji = 0; ji < refJoins.size(); ji++) { 8538 TJoin join = refJoins.getJoin(ji); 8539 addCteAliasToCombinedMap(join.getTable(), 8540 cteNameToStatementIndex, combined); 8541 TJoinItemList items = join.getJoinItems(); 8542 if (items == null) continue; 8543 for (int i = 0; i < items.size(); i++) { 8544 TJoinItem item = items.getJoinItem(i); 8545 if (item == null) continue; 8546 addCteAliasToCombinedMap(item.getTable(), 8547 cteNameToStatementIndex, combined); 8548 } 8549 } 8550 return combined; 8551 } 8552 8553 /** 8554 * Slice 102 / Slice 103 — when a WITH-clause CTE declares an explicit 8555 * column list ({@code WITH cte(a, b) AS (SELECT x, y FROM t)}), rebuild 8556 * {@code stmts[bodyIdx]} so its {@link OutputColumn} names match the 8557 * explicit list at each ordinal and rewrite outgoing 8558 * {@link LineageRef.Kind#STATEMENT_OUTPUT} refs in 8559 * {@code lineage[lineageSize0..lineage.size())} so the inner-projection 8560 * names are replaced by the explicit-list names. 8561 * 8562 * <p>Returns the published column list for the caller's 8563 * {@code ctePublishedColumns} map: the renamed list when an explicit list 8564 * is present; otherwise the body's inner names (matching pre-slice-102 8565 * behavior). Slice 103 reuses this helper from the outer SELECT CTE 8566 * walker via {@code dmlKind="SELECT"} (slice-100 cross-DML reuse 8567 * precedent). 8568 * 8569 * <p>Rejects: 8570 * <ul> 8571 * <li>Arity mismatch — explicit-list size != body output count → 8572 * {@link DiagnosticCode#MERGE_CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} 8573 * when {@code dmlKind="MERGE"}, otherwise 8574 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}. 8575 * Slice 103 cannot rename the MERGE-side code (it is pinned by 8576 * {@code Slice102Test.valueOfPinsResolveBothCodes} and adopting 8577 * it on the SELECT side would also miswire the message text); 8578 * the SELECT-side gets its own parallel code (codex round-1 8579 * plan-review BLOCKING).</li> 8580 * <li>Duplicate explicit name ({@code WITH cte(a, a) AS ...}) → 8581 * {@link DiagnosticCode#DUPLICATE_OUTPUT_NAME}. STATEMENT_OUTPUT 8582 * refs are keyed by output name; duplicates would collide 8583 * (codex round-2 plan-review advisory).</li> 8584 * </ul> 8585 * 8586 * <p>{@link OutputColumn} and {@link StatementGraph} are immutable; the 8587 * rebuild uses the slice-85 15-arg primary constructor copying every 8588 * field unchanged except {@code outputColumns}. {@link LineageEdge} and 8589 * {@link LineageRef} are immutable; the rewrite walker constructs new 8590 * instances and replaces them in the mutable {@code lineage} list via 8591 * {@link List#set}. 8592 */ 8593 private static List<String> applyExplicitCteColumnListRename( 8594 TCTE cte, 8595 List<StatementGraph> stmts, 8596 List<LineageEdge> lineage, 8597 int bodyIdx, 8598 int lineageSize0, 8599 String dmlKind) { 8600 StatementGraph body = stmts.get(bodyIdx); 8601 if (cte.getColumnList() == null || cte.getColumnList().size() == 0) { 8602 return outputColumnNames(body); 8603 } 8604 // Materialize the explicit list of renamed names (in declaration order). 8605 boolean isMerge = "MERGE".equals(dmlKind); 8606 String dmlLabel = isMerge ? "MERGE CTE" : "CTE"; 8607 String withClauseLabel = isMerge ? "MERGE WITH clause CTE" : "WITH clause CTE"; 8608 List<String> renamed = new ArrayList<>(cte.getColumnList().size()); 8609 Set<String> seenLower = new HashSet<>(); 8610 for (int k = 0; k < cte.getColumnList().size(); k++) { 8611 TObjectName col = cte.getColumnList().getObjectName(k); 8612 String name = (col == null) ? null : col.getColumnNameOnly(); 8613 if (name == null || name.isEmpty()) { 8614 // Defensive — parser normally fills these; if not, fall 8615 // back to a synthetic name so the constructor invariant 8616 // (non-empty name) holds, and the arity check still works. 8617 name = "col" + (k + 1); 8618 } 8619 String lower = name.toLowerCase(Locale.ROOT); 8620 if (!seenLower.add(lower)) { 8621 throw new SemanticIRBuildException(Diagnostic.error( 8622 DiagnosticCode.DUPLICATE_OUTPUT_NAME, 8623 "duplicate column name '" + name + "' in " + dmlLabel + " '" 8624 + cte.getTableName() 8625 + "' explicit column list; output names must " 8626 + "be unique within a CTE published column list", 8627 cte)); 8628 } 8629 renamed.add(name); 8630 } 8631 List<OutputColumn> bodyOutputs = body.getOutputColumns(); 8632 if (bodyOutputs.size() != renamed.size()) { 8633 DiagnosticCode arityCode = isMerge 8634 ? DiagnosticCode.MERGE_CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH 8635 : DiagnosticCode.CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH; 8636 throw new SemanticIRBuildException(Diagnostic.error( 8637 arityCode, 8638 withClauseLabel + " '" + cte.getTableName() 8639 + "' declares " + renamed.size() 8640 + " explicit column(s) but the body's SELECT " 8641 + "publishes " + bodyOutputs.size() + " column(s); " 8642 + "the explicit list must have exactly one entry " 8643 + "per body output column", 8644 cte)); 8645 } 8646 // Capture the old → new name mapping by ordinal BEFORE building the 8647 // new OutputColumns, so the lineage rewrite can look up the 8648 // substitution by old (inner) name. Codex round-1 diff-review 8649 // (non-blocking → upgraded to defensive guard): if the body has 8650 // duplicate inner output names (e.g. `SELECT id, id`), name-keyed 8651 // rewrite collapses both old refs to the last mapping and 8652 // produces wrong lineage. The IR contract already states output 8653 // names must be unique (see DUPLICATE_OUTPUT_NAME javadoc and the 8654 // line-4378 scalar-subquery guard) but is not enforced 8655 // generically. Reject here so explicit-rename paths cannot 8656 // silently break lineage. 8657 Set<String> seenInnerLower = new HashSet<>(); 8658 for (OutputColumn oc : bodyOutputs) { 8659 String n = oc.getName(); 8660 if (n == null || n.isEmpty()) continue; 8661 String lower = n.toLowerCase(Locale.ROOT); 8662 if (!seenInnerLower.add(lower)) { 8663 throw new SemanticIRBuildException(Diagnostic.error( 8664 DiagnosticCode.DUPLICATE_OUTPUT_NAME, 8665 dmlLabel + " '" + cte.getTableName() 8666 + "' body publishes duplicate inner column " 8667 + "name '" + n + "'; the explicit column " 8668 + "list rename requires unique inner names " 8669 + "because lineage refs are keyed by output " 8670 + "name and would collide", 8671 cte)); 8672 } 8673 } 8674 Map<String, String> oldToNewLower = new HashMap<>(); 8675 List<OutputColumn> newOutputs = new ArrayList<>(bodyOutputs.size()); 8676 for (int k = 0; k < bodyOutputs.size(); k++) { 8677 OutputColumn oc = bodyOutputs.get(k); 8678 String oldName = oc.getName(); 8679 String newName = renamed.get(k); 8680 if (oldName != null && !oldName.isEmpty()) { 8681 oldToNewLower.put(oldName.toLowerCase(Locale.ROOT), newName); 8682 } 8683 newOutputs.add(new OutputColumn(newName, oc.isDerived(), 8684 oc.isAggregate(), oc.getSources(), oc.getWindowSpec())); 8685 } 8686 // Rebuild the body's StatementGraph using the slice-85 15-arg primary 8687 // constructor — copies every field (including the slice-85 8688 // returningColumns slot per codex round-1 plan-review BLOCKING) 8689 // except outputColumns. 8690 StatementGraph renamedBody = new StatementGraph( 8691 body.getName(), body.getKind(), body.getRelations(), 8692 newOutputs, body.getReturningColumns(), 8693 body.getFilterColumnRefs(), body.getJoinColumnRefs(), 8694 body.getGroupByColumnRefs(), body.getHavingColumnRefs(), 8695 body.getOrderByColumnRefs(), body.getDistinctOnColumnRefs(), 8696 body.isDistinct(), body.getSetOperator(), body.getRowLimit(), 8697 body.getTarget()) 8698 // Slice 180 (R5): the rebuild via the legacy ctor would strip 8699 // the block span the original carried; preserve it so the 8700 // renamed body keeps its scope span. (The legacy ctor also 8701 // resets joinAnalysisFacts; that pre-existing facts-strip is 8702 // left as-is here — out of R5 scope — to avoid changing the 8703 // emitted JSON for these renamed bodies.) 8704 .withSourceSpan(body.getSourceSpan()); 8705 stmts.set(bodyIdx, renamedBody); 8706 // Rewrite outgoing STATEMENT_OUTPUT refs in the window. Both `from` 8707 // and `to` are checked because edges can place the body-output ref 8708 // on either side (producer-side: from=TABLE_COLUMN, to=STATEMENT_OUTPUT; 8709 // consumer-side from a deeper inner stmt: from=STATEMENT_OUTPUT, 8710 // to=STATEMENT_OUTPUT — neither shape today places bodyIdx on 8711 // `from` for THIS body, but the symmetric check is cheap and 8712 // future-proof). LineageRef and LineageEdge are immutable, so new 8713 // instances are constructed and `lineage.set` replaces in place. 8714 for (int idx = lineageSize0; idx < lineage.size(); idx++) { 8715 LineageEdge edge = lineage.get(idx); 8716 LineageRef from = edge.getFrom(); 8717 LineageRef to = edge.getTo(); 8718 LineageRef newFrom = maybeRewriteStatementOutputRef( 8719 from, bodyIdx, oldToNewLower); 8720 LineageRef newTo = maybeRewriteStatementOutputRef( 8721 to, bodyIdx, oldToNewLower); 8722 if (newFrom != from || newTo != to) { 8723 lineage.set(idx, new LineageEdge(newFrom, newTo)); 8724 } 8725 } 8726 return Collections.unmodifiableList(renamed); 8727 } 8728 8729 /** 8730 * Slice 113 — copy a {@link StatementGraph} with a new {@code name} 8731 * field. Every other field is preserved verbatim. Used by the 8732 * set-op branch loop to assign the synthetic 8733 * {@code <set_op_branch_<idx>>} name AFTER the branch build, in case 8734 * the branch's WHERE-side predicate-subquery extraction 8735 * (slice 113 via {@link PredicateClauseContext#SET_OP_BRANCH_WHERE}) 8736 * appended predicate-body statements to {@code stmts}, which would 8737 * otherwise leave the pre-computed digit suffix lagging behind the 8738 * branch's final position. 8739 * 8740 * <p>The rebuild is purely cosmetic on the {@link StatementGraph#getName()} 8741 * field. No {@link LineageRef} is affected because all lineage refs 8742 * are idx-based (see {@link LineageRef#statementOutput(int, String)}), 8743 * not name-based. {@code outputColumns}, {@code relations}, 8744 * {@code filterColumnRefs}, {@code joinColumnRefs} and every other 8745 * field are reused unchanged. 8746 */ 8747 private static StatementGraph withRenamedTo(StatementGraph s, String newName) { 8748 return new StatementGraph(newName, s.getKind(), 8749 s.getRelations(), s.getOutputColumns(), s.getReturningColumns(), 8750 s.getFilterColumnRefs(), s.getJoinColumnRefs(), 8751 s.getGroupByColumnRefs(), s.getHavingColumnRefs(), 8752 s.getOrderByColumnRefs(), s.getDistinctOnColumnRefs(), 8753 s.isDistinct(), s.getSetOperator(), s.getRowLimit(), 8754 s.getTarget()) 8755 // Slice 180 (R5): preserve the block span the legacy ctor 8756 // would otherwise drop, so this rename keeps the scope span. 8757 .withSourceSpan(s.getSourceSpan()); 8758 } 8759 8760 /** 8761 * Slice 102 — return a new STATEMENT_OUTPUT {@link LineageRef} with the 8762 * output name substituted when {@code ref} targets {@code bodyIdx} and 8763 * its current output name is a key in {@code oldToNewLower}. Otherwise 8764 * return {@code ref} unchanged (identity-comparable so the caller can 8765 * skip the {@code lineage.set} for no-op rewrites). 8766 */ 8767 private static LineageRef maybeRewriteStatementOutputRef( 8768 LineageRef ref, int bodyIdx, 8769 Map<String, String> oldToNewLower) { 8770 if (ref == null) return null; 8771 if (ref.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) return ref; 8772 if (ref.getStatementIndex() != bodyIdx) return ref; 8773 String oldName = ref.getOutputName(); 8774 if (oldName == null || oldName.isEmpty()) return ref; 8775 String newName = oldToNewLower.get(oldName.toLowerCase(Locale.ROOT)); 8776 if (newName == null) return ref; 8777 return LineageRef.statementOutput(bodyIdx, newName); 8778 } 8779 8780 /** 8781 * Emit one lineage edge per (output, source) pair. Edges target a 8782 * {@link LineageRef.Kind#STATEMENT_OUTPUT} when the source's relation 8783 * is a CTE or a FROM-clause subquery, or a 8784 * {@link LineageRef.Kind#TABLE_COLUMN} when it's a base table. 8785 * Multi-source derived columns produce one edge per source. 8786 * 8787 * <p>{@code subqueryAliasToStatementIndex} is statement-local; the 8788 * caller supplies the alias map for this statement's own FROM list. 8789 * That avoids cross-scope alias collisions. 8790 */ 8791 private static void emitLineageForStatement(StatementGraph stmt, 8792 int statementIndex, 8793 List<LineageEdge> lineage, 8794 Map<String, Integer> cteNameToStatementIndex, 8795 Map<String, Integer> subqueryAliasToStatementIndex, 8796 Map<Integer, ScalarInfo> ordinalToScalarInfo) { 8797 // Slice 87: lowercase alias keys so SQL identifiers written with 8798 // different casing in the FROM clause vs. SELECT qualifier resolve 8799 // correctly (e.g. `SELECT t.name FROM employees T`). Mirrors the 8800 // same fix in emitUpdateSubquerySourceEdges (slice 83). When two 8801 // relations collide after lowercasing (unusual, but not guaranteed 8802 // caught by the duplicate-alias preflight in all call paths per 8803 // codex Q1 advisory), last-write-wins — the same policy as slice 83. 8804 Map<String, RelationSource> aliasToRelation = new HashMap<>(); 8805 for (RelationSource r : stmt.getRelations()) { 8806 String key = r.getAlias(); 8807 if (key == null || key.isEmpty()) continue; 8808 aliasToRelation.put(key.toLowerCase(Locale.ROOT), r); 8809 } 8810 List<OutputColumn> outputs = stmt.getOutputColumns(); 8811 for (int outOrdinal = 0; outOrdinal < outputs.size(); outOrdinal++) { 8812 OutputColumn out = outputs.get(outOrdinal); 8813 // Slice 11: scalar-subquery projections have empty sources 8814 // by construction; their lineage edge is a single 8815 // STATEMENT_OUTPUT → STATEMENT_OUTPUT pointing at the 8816 // extracted scalar body's only output. Emit it once and 8817 // skip the per-source loop (which would be a no-op anyway). 8818 ScalarInfo scalar = ordinalToScalarInfo.get(outOrdinal); 8819 if (scalar != null) { 8820 lineage.add(new LineageEdge( 8821 LineageRef.statementOutput(statementIndex, out.getName()), 8822 LineageRef.statementOutput(scalar.statementIndex, 8823 scalar.innerOutputName))); 8824 continue; 8825 } 8826 for (ColumnRef src : out.getSources()) { 8827 String srcAlias = src.getRelationAlias(); 8828 RelationSource rel = aliasToRelation.get( 8829 srcAlias == null ? null : srcAlias.toLowerCase(Locale.ROOT)); 8830 if (rel == null) { 8831 throw new SemanticIRBuildException( 8832 Diagnostic.error(DiagnosticCode.OUTPUT_REFERENCES_UNKNOWN_RELATION, 8833 "output '" + out.getName() + "' references unknown relation '" 8834 + src.getRelationAlias() + "'", null)); 8835 } 8836 LineageRef from = LineageRef.statementOutput(statementIndex, out.getName()); 8837 LineageRef to; 8838 // Slice 15: resolved-kind dispatch. For OUTER_REFERENCE 8839 // bindings the underlying outerKind decides which 8840 // table-column or statement-output edge we emit. 8841 // Codex round-1 MUST 2 / round-2 MUST 1: exhaustive 8842 // dispatch instead of catch-all. 8843 RelationKind kind = rel.getBinding().getKind(); 8844 RelationKind resolvedKind = (kind == RelationKind.OUTER_REFERENCE) 8845 ? rel.getBinding().getOuterKind() 8846 : kind; 8847 if (resolvedKind == RelationKind.CTE) { 8848 Integer cteIndex = cteNameToStatementIndex.get( 8849 rel.getBinding().getQualifiedName().toLowerCase(Locale.ROOT)); 8850 if (cteIndex == null) { 8851 throw new SemanticIRBuildException( 8852 Diagnostic.error(DiagnosticCode.CTE_BODY_MISSING, 8853 "CTE '" + rel.getBinding().getQualifiedName() + "' has no body statement", null)); 8854 } 8855 to = LineageRef.statementOutput(cteIndex, src.getColumnName()); 8856 } else if (resolvedKind == RelationKind.SUBQUERY) { 8857 Integer subIndex = subqueryAliasToStatementIndex.get( 8858 rel.getAlias().toLowerCase(Locale.ROOT)); 8859 if (subIndex == null) { 8860 throw new SemanticIRBuildException( 8861 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_BINDING_UNRESOLVED, 8862 "FROM-clause subquery '" + rel.getAlias() 8863 + "' has no body statement registered", null)); 8864 } 8865 to = LineageRef.statementOutput(subIndex, src.getColumnName()); 8866 } else if (resolvedKind == RelationKind.TABLE 8867 || resolvedKind == RelationKind.FUNCTION) { 8868 // FUNCTION (table-valued function) is an opaque source: 8869 // lineage terminates at the function's qualified name, 8870 // same shape as a base table column. 8871 to = LineageRef.tableColumn( 8872 rel.getBinding().getQualifiedName(), 8873 src.getColumnName()); 8874 } else { 8875 throw new SemanticIRBuildException( 8876 Diagnostic.error(DiagnosticCode.OUTPUT_REFERENCES_UNSUPPORTED_BINDING_KIND, 8877 "output '" + out.getName() 8878 + "' references relation '" + rel.getAlias() 8879 + "' with unsupported binding kind " + kind 8880 + (kind == RelationKind.OUTER_REFERENCE 8881 ? " (outerKind=" + rel.getBinding().getOuterKind() + ")" 8882 : ""), null)); 8883 } 8884 lineage.add(new LineageEdge(from, to)); 8885 } 8886 } 8887 } 8888 8889 /** 8890 * Build one SELECT statement (CTE body or outer). The {@code name} 8891 * argument is non-null for a CTE body, null otherwise. When 8892 * {@code hasOuterCteListAlreadyProcessed} is true, the SELECT's own 8893 * {@code getCteList()} is not rejected because the caller has already 8894 * extracted those CTEs into separate statements; in all other cases a 8895 * non-empty CTE list on this node is rejected (so nested WITH inside 8896 * a CTE body does not silently slip through). 8897 */ 8898 private static StatementGraph buildSelectStatement(TSelectSqlStatement select, 8899 NameBindingProvider provider, 8900 String name, 8901 boolean hasOuterCteListAlreadyProcessed, 8902 boolean allowFromSubqueries, 8903 boolean allowScalarProjectionSubqueries, 8904 boolean allowWindowProjection) { 8905 // Slice 23: legacy 7-arg call site. Predicate-subquery extraction is 8906 // disabled (allowJoinOnPredicateSubqueries=false) and this is not a 8907 // predicate body itself (isPredicateBody=false). All non-outer call 8908 // sites use this overload — the slice-17 `rejectSubqueriesInJoinOn` 8909 // continues to fire at every non-outer JOIN-ON site. 8910 return buildSelectStatementImpl(select, provider, name, 8911 hasOuterCteListAlreadyProcessed, 8912 allowFromSubqueries, 8913 allowScalarProjectionSubqueries, 8914 allowWindowProjection, 8915 /*allowJoinOnPredicateSubqueries=*/ false, 8916 /*stmtsForExtraction=*/ null, 8917 /*lineageForExtraction=*/ null, 8918 /*cteMapForExtraction=*/ null, 8919 /*isPredicateBody=*/ false, 8920 /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE, 8921 /*allowWherePredicateSubqueries=*/ false); 8922 } 8923 8924 /** 8925 * Internal body shared between the legacy 7-arg overload and the 8926 * outer-SELECT entry point used by {@link #build}. Slice 23 added two new 8927 * concepts; slice 24 added one more. 8928 * <ul> 8929 * <li>{@code allowJoinOnPredicateSubqueries} + {@code stmts}/{@code lineage} 8930 * — when {@code allow...} is {@code true}, JOIN-ON uncorrelated 8931 * EXISTS subqueries are extracted as their own 8932 * {@code <predicate_subquery_<i>>} statements appended to 8933 * {@code stmts} (slice-11/12 synthetic-name pattern). Outer-SELECT 8934 * entry only.</li> 8935 * <li>{@code isPredicateBody} — when {@code true}, this statement IS 8936 * the inner SELECT of an extracted EXISTS body. The constant-only 8937 * projection rejection in {@link #buildOutputColumns} is bypassed 8938 * and a single synthetic OutputColumn is emitted in its place.</li> 8939 * <li>{@code cteMapForExtraction} (slice 24) — outer's CTE 8940 * name-to-statement-index map, plumbed in only when 8941 * {@code allowJoinOnPredicateSubqueries=true}. Required so the 8942 * slice-24 column-bearing inner projection can emit 8943 * STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edges into outer- 8944 * visible CTE bodies. Non-outer call sites pass {@code null}.</li> 8945 * </ul> 8946 */ 8947 private static StatementGraph buildSelectStatementImpl( 8948 TSelectSqlStatement select, 8949 NameBindingProvider provider, 8950 String name, 8951 boolean hasOuterCteListAlreadyProcessed, 8952 boolean allowFromSubqueries, 8953 boolean allowScalarProjectionSubqueries, 8954 boolean allowWindowProjection, 8955 boolean allowJoinOnPredicateSubqueries, 8956 List<StatementGraph> stmtsForExtraction, 8957 List<LineageEdge> lineageForExtraction, 8958 Map<String, Integer> cteMapForExtraction, 8959 boolean isPredicateBody, 8960 PredicateClauseContext whereClauseContext, 8961 boolean allowWherePredicateSubqueries) { 8962 rejectUnsupportedShape(select, hasOuterCteListAlreadyProcessed); 8963 // Slice 129: PIVOT sub-slice (a). A SELECT whose single FROM source 8964 // is a PIVOT routes to the dedicated pivot builder, which binds the 8965 // underlying source relation and captures the consumed FOR / 8966 // aggregation-arg columns into StatementGraph.pivotColumnRefs. Scoped 8967 // to the outer SELECT context (name == null, not a predicate body); 8968 // nested / non-outer PIVOTs are deferred to a later sub-slice and 8969 // fall through to the normal path (which rejects with the existing 8970 // TABLE_BINDING_UNRESOLVED as before). 8971 if (name == null && !isPredicateBody && isPivotSelect(select)) { 8972 return buildPivotSelect(select, provider, name); 8973 } 8974 // Slice 143: a PIVOT/UNPIVOT driver that ALSO has a JOIN partner 8975 // (e.g. FROM sales PIVOT(...) AS p JOIN dim d ON ...) trips 8976 // isPivotSelect's "no trailing JOIN" guard and would otherwise fall to 8977 // the normal path and reject with the GENERIC TABLE_BINDING_UNRESOLVED. 8978 // Emit a PRECISE deferral reusing the existing 8979 // PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED code (no new lineage admitted), 8980 // mirroring the precise chained-PIVOT reject. 8981 // 8982 // Slice 145: this precise routing no longer carries the slice-143/144 8983 // `name == null && !isPredicateBody` guard, so it also fires in NESTED 8984 // contexts (CTE body / FROM-subquery body / set-op branch / 8985 // predicate-subquery body) where a pivot-with-join reaches 8986 // buildSelectStatementImpl under a non-null name (or isPredicateBody). 8987 // The slice-129 ADMISSION router above keeps its outer-only guard — 8988 // nested ADMITTED pivots (isPivotSelect) are routed to buildPivotSelect 8989 // at their own call sites and never reach here; only NON-admitted 8990 // pivot-with-join shapes do, which were previously rejected with the 8991 // generic TABLE_BINDING_UNRESOLVED. The helpers only match when a 8992 // pivoted_table is genuinely present, so a plain JOIN is untouched. 8993 if (isPivotWithJoinPartner(select)) { 8994 TTable pivotTable = select.joins.getJoin(0).getTable(); 8995 // Slice 146: name the ACTUAL operator (PIVOT vs UNPIVOT) so an 8996 // UNPIVOT-then-JOIN does not mis-report "PIVOT". 8997 throw new SemanticIRBuildException( 8998 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 8999 pivotOperatorKeyword(pivotTable) + " combined with a JOIN is not supported yet", 9000 pivotTable)); 9001 } 9002 // Slice 144: the symmetric positions — a PIVOT/UNPIVOT appearing as a 9003 // JOIN PARTNER rather than the FROM driver: the right-hand side of an 9004 // explicit JOIN (FROM dim JOIN sales PIVOT(...) ON ...) or a comma-join 9005 // sibling (FROM sales PIVOT(...), dim). Slice 143 closed the 9006 // pivot-as-driver case; these shapes previously fell to the normal path 9007 // and rejected with the GENERIC TABLE_BINDING_UNRESOLVED. Emit the same 9008 // PRECISE deferral reusing PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED (no new 9009 // lineage admitted). Slice 145 (see above) lifts the outer-only guard so 9010 // this also fires for a pivot join partner in any nesting context. 9011 TTable pivotPartner = findPivotJoinPartner(select); 9012 if (pivotPartner != null) { 9013 // Slice 146: name the ACTUAL operator (PIVOT vs UNPIVOT). 9014 throw new SemanticIRBuildException( 9015 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 9016 pivotOperatorKeyword(pivotPartner) + " combined with a JOIN is not supported yet", 9017 pivotPartner)); 9018 } 9019 boolean distinct = resolveDistinctFlag(select); 9020 // Slice 65 — reset using scope at entry so a parent SELECT's 9021 // scope cannot leak into recursive nested builds. The using 9022 // scope for THIS SELECT is installed only AFTER buildRelations 9023 // completes (see below) so the predicate-subquery extraction 9024 // walk inside buildRelations does not inherit the outer scope 9025 // (codex slice-65 diff-review round-1 P2 #1: an inner 9026 // {@code EXISTS (SELECT SUM(x.v) FILTER (WHERE k > 0) FROM x)} 9027 // would have its bare `k` expand to outer's merged sources, 9028 // causing a valid uncorrelated body to be rejected as 9029 // correlated). The slice-64 → 65 JOIN-ON merged-key reject 9030 // also runs BEFORE buildRelations so ON-clause refs aren't 9031 // collected with a stale or future scope. 9032 provider = provider.withUsingScope(UsingScope.EMPTY); 9033 // Reset the join-graph structural anchor at entry (mirrors the 9034 // UsingScope reset above) so a parent SELECT's anchor cannot leak into 9035 // this recursive nested build. It is re-installed below for THIS 9036 // SELECT's own FROM clause once relations are bound. 9037 provider = provider.withJoinStructureAnchor(false); 9038 rejectUnqualifiedMergedKeyInJoinOn(select, provider); 9039 List<ColumnRef> joinRefs = new ArrayList<>(); 9040 List<RelationSource> relations; 9041 if (isSetOpBranchSyntheticName(name) 9042 && hasNoFromSource(select) 9043 && allResultColumnsAreConstantExpressions(select)) { 9044 // Slice 61: allow FROM-less constant-only set-op branches 9045 // such as SELECT 1 UNION ALL SELECT 2. The general SELECT 9046 // boundary remains unchanged: non-branch SELECT 1 still 9047 // fails in buildRelations with "must have at least one 9048 // FROM source". 9049 relations = Collections.emptyList(); 9050 } else { 9051 relations = buildRelations(select, provider, joinRefs, 9052 allowFromSubqueries, 9053 allowJoinOnPredicateSubqueries, 9054 stmtsForExtraction, lineageForExtraction, cteMapForExtraction); 9055 } 9056 // Slice 65 — install this SELECT's own using scope AFTER 9057 // buildRelations / predicate-subquery extraction. From here 9058 // forward the clause collectors (output / filter / groupBy / 9059 // having / orderBy) see the merged-key scope for THIS SELECT. 9060 UsingScope ownScope = buildUsingScope(select, provider); 9061 if (!ownScope.isEmpty()) { 9062 provider = provider.withUsingScope(ownScope); 9063 } 9064 // Install the join-graph structural anchor for THIS SELECT: two or more 9065 // bound FROM endpoints mean the join graph is fully determined (an 9066 // unqualified column can only be NON_EXACT when ≥2 candidate relations 9067 // exist, and reaching here means every relation bound — buildRelations 9068 // throws TABLE_BINDING_UNRESOLVED otherwise). The anchor lets 9069 // rejectNonExactBindings degrade an unqualified, catalog-less column 9070 // miss over an explicit ON / CROSS / comma join to a warning instead of 9071 // discarding the whole statement graph — the same treatment the USING 9072 // merged-key anchor already gives. 9073 // 9074 // CATALOG-LESS GATE: the degrade exists only because, WITHOUT a catalog, 9075 // we cannot know which side an unqualified column belongs to. When every 9076 // FROM relation has a known column list (catalog or in-scope derived 9077 // columns), the catalog CAN adjudicate — an unqualified column matching 9078 // no side is a genuine error and must stay fatal (a multi-relation 9079 // resolver returns a null binding either way, so the reject result alone 9080 // cannot distinguish the two). So anchor only when at least one endpoint's 9081 // columns are unknown. 9082 if (relations.size() >= 2 && !fromRelationColumnsFullyKnown(select, provider)) { 9083 provider = provider.withJoinStructureAnchor(true); 9084 } 9085 List<OutputColumn> outputColumns = buildOutputColumns(select, provider, 9086 allowScalarProjectionSubqueries, allowWindowProjection, 9087 isPredicateBody, name); 9088 // Slice 112 — thread the SELECT path's outer extraction context 9089 // through buildFilterColumnRefs so top-level SELECT WHERE can 9090 // lift uncorrelated predicate-subquery wrappers via the 9091 // slice-23+ extraction pipeline (PredicateClauseContext.SELECT_WHERE). 9092 // Slice 113 — the same threading extends to set-op branch WHERE 9093 // via PredicateClauseContext.SET_OP_BRANCH_WHERE, distinguished 9094 // only by clauseLabel for diagnostic messages (codes are shared). 9095 // 9096 // {@code allowWherePredicateSubqueries} is INDEPENDENT of 9097 // {@code allowJoinOnPredicateSubqueries} (slice 113 split): 9098 // set-op branches admit WHERE-side predicate subqueries while 9099 // KEEPING JOIN-ON predicate subqueries rejected (slice 23 / 26 9100 // contract — pinned by Slice23Test#existsInSetOpBranchJoinOnStillRejected 9101 // and Slice26Test#lhsSubqueryInSetOpBranchRejected). Nested 9102 // SELECTs without extraction context 9103 // (allowWherePredicateSubqueries=false) keep the slice-80 9104 // blanket reject inside buildFilterColumnRefs. 9105 // R8 — collector for predicate-derived semi-joins (EXISTS / IN / 9106 // NOT EXISTS / NOT IN in WHERE). Populated by buildFilterColumnRefs' 9107 // extraction; converted to SEMI / ANTI_SEMI JoinEntities at 9108 // JoinGraph assembly below (which has `relations` to resolve the 9109 // outer left endpoint). 9110 List<SemiJoinFact> semiFacts = new ArrayList<>(); 9111 // R8 — the outer FROM relation aliases (lower-cased) gate the 9112 // correlated-EXISTS degrade: only a correlation to a genuine outer 9113 // relation degrades; an unknown alias still rejects. 9114 Set<String> outerRelationAliases = new HashSet<>(); 9115 for (RelationSource rs : relations) { 9116 if (rs.getAlias() != null && !rs.getAlias().isEmpty()) { 9117 outerRelationAliases.add(rs.getAlias().toLowerCase(Locale.ROOT)); 9118 } 9119 } 9120 List<ColumnRef> filterRefs = buildFilterColumnRefs(select, provider, 9121 allowWherePredicateSubqueries, 9122 stmtsForExtraction, lineageForExtraction, cteMapForExtraction, 9123 whereClauseContext, semiFacts, outerRelationAliases); 9124 List<ColumnRef> groupByRefs = buildGroupByColumnRefs(select, provider); 9125 List<ColumnRef> havingRefs = buildHavingColumnRefs(select, provider); 9126 List<ColumnRef> orderByRefs = buildOrderByColumnRefs(select, provider, outputColumns); 9127 // Slice 73: DISTINCT ON refs collected here so they observe the 9128 // same {@code provider} (with UsingScope already installed) used 9129 // by buildGroupByColumnRefs / buildHavingColumnRefs / 9130 // buildOrderByColumnRefs. This keeps `DISTINCT ON (k)` over 9131 // `JOIN ... USING (k)` consistent with slice-65 merged-key 9132 // semantics and prevents parent-scope leakage into nested 9133 // builds. 9134 List<ColumnRef> distinctOnRefs = buildDistinctOnColumnRefs(select, provider); 9135 // Slice 125: QUALIFY filter refs. Needs outputColumns for the 9136 // projection-alias form (QUALIFY rn = 1); observes the same 9137 // provider (with UsingScope already installed) as the other 9138 // clause-ref builders so QUALIFY over JOIN ... USING (k) stays 9139 // consistent with slice-65 merged-key semantics. 9140 List<ColumnRef> qualifyRefs = buildQualifyColumnRefs(select, provider, outputColumns); 9141 // Slice 128: structured GROUP BY view. Built after 9142 // buildGroupByColumnRefs so its slice-61/slice-13 guards have 9143 // already fired; observes the same provider as the other clause-ref 9144 // builders. Empty unless the statement has a GROUP BY. 9145 List<GroupingElement> groupingElements = buildGroupingElements(select, provider); 9146 RowLimit rowLimit = buildRowLimit(select); 9147 StatementGraph sg = new StatementGraph(name, "SELECT", relations, outputColumns, 9148 filterRefs, joinRefs, groupByRefs, havingRefs, orderByRefs, 9149 distinctOnRefs, 9150 qualifyRefs, 9151 groupingElements, 9152 distinct, 9153 /*setOperator=*/ null, 9154 rowLimit); 9155 // Slice 179 (R5): carry this block's own source span (covers the 9156 // full select [... where ...]). All SELECT blocks — main, CTE bodies, 9157 // FROM-subquery bodies — funnel through this site, so each gets its 9158 // span from its own parse node. Read by attachQueryBlockScopes. 9159 sg = sg.withSourceSpan(SourceSpan.of(select)); 9160 // Slice 167 (S6): attach the ordered structured JoinGraph (GAP 1). 9161 // Slice 169 (S8): attach the WHERE filter predicate tree (GAP 2/3). 9162 // Both additive — the flat joinColumnRefs / filterColumnRefs above 9163 // are untouched. 9164 JoinGraph joinGraph = buildJoinGraph(select, provider); 9165 // R8 — append predicate-derived semi-join entities (EXISTS / IN / 9166 // NOT EXISTS / NOT IN) after the FROM-clause joins, continuing the 9167 // order numbering. The left endpoint is the outer relation owning 9168 // the correlated outer column (falling back to the first FROM 9169 // relation); the right endpoint is the lifted subquery block. 9170 joinGraph = appendSemiJoins(joinGraph, semiFacts, relations); 9171 // Oracle-style implicit-join predicate promotion: when a WHERE conjunct 9172 // links both sides of a comma-join (IMPLICIT_CROSS) entity, move it from 9173 // the filter list into that join's conditions and upgrade the join to 9174 // INNER. A two-sided predicate — equi, non-equi, or a BETWEEN/band range 9175 // across both relations — is a join condition, not a cartesian filter. 9176 // The entity keeps sourceSyntax=COMMA so its implicit origin stays 9177 // distinguishable from an explicit ON join. Single-sided predicates 9178 // remain filters; an IMPLICIT_CROSS with no linking predicate stays a 9179 // true cross product. 9180 TExpression implicitWhere = (select.getWhereClause() != null) 9181 ? select.getWhereClause().getCondition() : null; 9182 List<Predicate> filterPredicates; 9183 if (implicitWhere != null && hasImplicitCrossJoin(joinGraph)) { 9184 ImplicitJoinPromotion promo = promoteImplicitJoinConditions( 9185 joinGraph, implicitWhere, provider); 9186 joinGraph = promo.graph; 9187 filterPredicates = promo.filterPredicates; 9188 } else { 9189 filterPredicates = buildFilterPredicates(select, provider); 9190 } 9191 JoinAnalysisFacts facts = sg.getJoinAnalysisFacts(); 9192 if (!joinGraph.isEmpty()) { 9193 facts = facts.withJoinGraph(joinGraph); 9194 } 9195 if (!filterPredicates.isEmpty()) { 9196 facts = facts.withFilterPredicates(filterPredicates); 9197 } 9198 if (!facts.isEmpty()) { 9199 sg = sg.withJoinAnalysisFacts(facts); 9200 } 9201 return sg; 9202 } 9203 9204 /** 9205 * Slice 169 (S8) — decompose the WHERE clause into a resolved filter 9206 * predicate list (GAP 2/3), reusing the slice-166 PredicateTreeBuilder. 9207 * Tolerant by construction: unknown shapes (including predicate 9208 * subqueries) fold into COMPLEX without leaking inner columns or 9209 * throwing. The flat {@code filterColumnRefs} are unaffected. 9210 */ 9211 private static List<Predicate> buildFilterPredicates(TSelectSqlStatement select, 9212 NameBindingProvider provider) { 9213 if (select == null || select.getWhereClause() == null 9214 || select.getWhereClause().getCondition() == null) { 9215 return Collections.emptyList(); 9216 } 9217 return PredicateTreeBuilder.build(select.getWhereClause().getCondition(), provider); 9218 } 9219 9220 /** True iff {@code graph} contains a comma-join {@link SemanticJoinType#IMPLICIT_CROSS} entity. */ 9221 private static boolean hasImplicitCrossJoin(JoinGraph graph) { 9222 for (JoinEntity e : graph.getJoins()) { 9223 if (e.getJoinType() == SemanticJoinType.IMPLICIT_CROSS) { 9224 return true; 9225 } 9226 } 9227 return false; 9228 } 9229 9230 /** Result of {@link #promoteImplicitJoinConditions}: the rewritten graph and residual WHERE filters. */ 9231 private static final class ImplicitJoinPromotion { 9232 final JoinGraph graph; 9233 final List<Predicate> filterPredicates; 9234 9235 ImplicitJoinPromotion(JoinGraph graph, List<Predicate> filterPredicates) { 9236 this.graph = graph; 9237 this.filterPredicates = filterPredicates; 9238 } 9239 } 9240 9241 /** 9242 * Oracle implicit-join predicate promotion. For each top-level WHERE 9243 * conjunct that references aliases from BOTH sides of an 9244 * {@link SemanticJoinType#IMPLICIT_CROSS} comma-join entity, move its 9245 * {@link Predicate} into that entity's conditions and upgrade the entity's 9246 * join type. Aliases are read from the conjunct's AST (not the decomposed 9247 * predicate) so a {@code BETWEEN}'s range bounds — which collapse into a 9248 * single COMPLEX operand — are still seen as the right side. 9249 * 9250 * <p>The upgraded type depends on Oracle's {@code (+)} outer-join marker: 9251 * <ul> 9252 * <li>no {@code (+)} → {@link SemanticJoinType#INNER};</li> 9253 * <li>{@code (+)} on the column of the entity's RIGHT endpoint (the 9254 * right side is null-producing) → {@link SemanticJoinType#LEFT};</li> 9255 * <li>{@code (+)} on a column of the entity's LEFT endpoint (the left 9256 * side is null-producing) → {@link SemanticJoinType#RIGHT}.</li> 9257 * </ul> 9258 * Direction is relative to the entity's endpoints, not operand position; 9259 * for GSP's left-deep comma model the LEFT endpoint is the accumulated 9260 * comma-join result and the RIGHT endpoint is the newly-added relation. 9261 * Multiple {@code (+)} conjuncts naming the same outer side all attach to 9262 * the one entity. A conjunct with {@code (+)} on both operands (illegal 9263 * Oracle) or conjuncts implying conflicting directions degrade the entity 9264 * back to {@link SemanticJoinType#IMPLICIT_CROSS} (its predicates stay 9265 * filters) rather than failing. 9266 * 9267 * <p>Each conjunct is assigned to the FIRST IMPLICIT_CROSS entity (in 9268 * FROM/graph order) whose right relation it references together with at 9269 * least one left-side relation, and all of whose referenced relations are 9270 * available at that boundary. A conjunct touching only one side, or no 9271 * comma-join boundary, stays a filter; an IMPLICIT_CROSS with no linking 9272 * conjunct stays a true cross product. The entity's {@code sourceSyntax} 9273 * stays {@link JoinSourceSyntax#COMMA}. 9274 */ 9275 private static ImplicitJoinPromotion promoteImplicitJoinConditions( 9276 JoinGraph graph, TExpression whereCond, NameBindingProvider provider) { 9277 List<PredicateTreeBuilder.ConjunctPredicate> conjuncts = 9278 PredicateTreeBuilder.classifyConjuncts(whereCond, provider); 9279 List<JoinEntity> joins = graph.getJoins(); 9280 int n = conjuncts.size(); 9281 // Pass 1: assign each conjunct to the comma-join boundary it links, 9282 // and accumulate the Oracle (+) outer-join direction signal per entity. 9283 int[] assigned = new int[n]; 9284 java.util.Arrays.fill(assigned, -1); 9285 boolean[] plusPresent = new boolean[n]; 9286 java.util.Map<Integer, java.util.Set<SemanticJoinType>> dirByEntity = 9287 new java.util.HashMap<>(); 9288 // Entities to degrade: a (+) that is on both sides (illegal Oracle), or 9289 // a (+) we can detect but cannot classify to exactly one endpoint. 9290 java.util.Set<Integer> degradePlusEntities = new java.util.HashSet<>(); 9291 for (int i = 0; i < n; i++) { 9292 PredicateTreeBuilder.ConjunctPredicate cp = conjuncts.get(i); 9293 java.util.Set<String> aliases = collectConjunctRelationAliases(cp.conjunct, provider); 9294 for (int ix = 0; ix < joins.size(); ix++) { 9295 JoinEntity e = joins.get(ix); 9296 if (e.getJoinType() != SemanticJoinType.IMPLICIT_CROSS) continue; 9297 if (conjunctLinksImplicitJoin(e, aliases)) { 9298 assigned[i] = ix; 9299 break; 9300 } 9301 } 9302 int target = assigned[i]; 9303 if (target < 0) continue; 9304 // Oracle (+) outer-join direction for this conjunct on its entity. 9305 // Deep-scan so a (+) anywhere in the conjunct is noticed even when 9306 // we cannot classify it (then we degrade rather than silently 9307 // treating it as an inner join). 9308 plusPresent[i] = expressionHasOraclePlus(cp.conjunct); 9309 if (!plusPresent[i]) continue; 9310 String outer = oraclePlusOuterAlias(cp.conjunct, provider); 9311 if (outer == ORACLE_PLUS_INVALID) { 9312 degradePlusEntities.add(target); 9313 } else { 9314 SemanticJoinType dir = (outer == null) 9315 ? null : oraclePlusJoinDirection(joins.get(target), outer); 9316 if (dir != null) { 9317 dirByEntity.computeIfAbsent(target, 9318 k -> new java.util.HashSet<SemanticJoinType>()).add(dir); 9319 } else { 9320 // (+) is present but unclassifiable (not a simple 9321 // comparison, ambiguous operand, or outer column not an 9322 // endpoint of this boundary) — degrade rather than guess. 9323 degradePlusEntities.add(target); 9324 } 9325 } 9326 } 9327 // Pass 2: decide each linked entity's final join type. INNER when no 9328 // (+) is present; LEFT/RIGHT when all (+) conjuncts agree on direction; 9329 // DEGRADE (entity stays IMPLICIT_CROSS, its conjuncts fall back to 9330 // filters) when a (+) is invalid/unclassifiable or different conjuncts 9331 // imply conflicting directions. 9332 java.util.Map<Integer, SemanticJoinType> finalType = new java.util.HashMap<>(); 9333 java.util.Set<Integer> degraded = new java.util.HashSet<>(); 9334 for (int ix = 0; ix < joins.size(); ix++) { 9335 if (joins.get(ix).getJoinType() != SemanticJoinType.IMPLICIT_CROSS) continue; 9336 if (degradePlusEntities.contains(ix)) { 9337 degraded.add(ix); 9338 continue; 9339 } 9340 java.util.Set<SemanticJoinType> dirs = dirByEntity.get(ix); 9341 if (dirs == null || dirs.isEmpty()) { 9342 finalType.put(ix, SemanticJoinType.INNER); 9343 } else if (dirs.size() == 1) { 9344 finalType.put(ix, dirs.iterator().next()); 9345 } else { 9346 degraded.add(ix); 9347 } 9348 } 9349 // Pass 3: in conjunct order, route each predicate to its entity's 9350 // condition list or to the residual filter list. For an OUTER (LEFT/ 9351 // RIGHT) join only the (+)-marked conjuncts are join conditions: an 9352 // unmarked two-sided predicate is a post-join WHERE filter in Oracle 9353 // (it eliminates null-extended rows), so it must stay residual. 9354 java.util.Map<Integer, List<Predicate>> condsByEntity = 9355 new java.util.LinkedHashMap<>(); 9356 List<Predicate> residualFilters = new ArrayList<>(); 9357 for (int i = 0; i < n; i++) { 9358 int t = assigned[i]; 9359 boolean asCondition = t >= 0 && !degraded.contains(t); 9360 if (asCondition) { 9361 SemanticJoinType ft = finalType.get(t); 9362 if (ft != SemanticJoinType.INNER && !plusPresent[i]) { 9363 asCondition = false; 9364 } 9365 } 9366 if (asCondition) { 9367 condsByEntity.computeIfAbsent(t, k -> new ArrayList<Predicate>()) 9368 .add(conjuncts.get(i).predicate); 9369 } else { 9370 residualFilters.add(conjuncts.get(i).predicate); 9371 } 9372 } 9373 if (condsByEntity.isEmpty()) { 9374 return new ImplicitJoinPromotion(graph, residualFilters); 9375 } 9376 List<JoinEntity> upgraded = new ArrayList<>(joins.size()); 9377 for (int ix = 0; ix < joins.size(); ix++) { 9378 JoinEntity e = joins.get(ix); 9379 List<Predicate> conds = condsByEntity.get(ix); 9380 if (conds == null || conds.isEmpty()) { 9381 upgraded.add(e); 9382 } else { 9383 upgraded.add(new JoinEntity(finalType.get(ix), 9384 e.getLeftEndpoint(), e.getRightEndpoint(), e.getOrder(), 9385 e.getSourceSyntax(), e.isNatural(), e.getUsingColumns(), 9386 conds, e.getSourceSpan(), e.getConditionText(), e.isLateral())); 9387 } 9388 } 9389 return new ImplicitJoinPromotion(new JoinGraph(upgraded), residualFilters); 9390 } 9391 9392 /** Sentinel returned by {@link #oraclePlusOuterAlias} for an invalid (+)-on-both-sides predicate. */ 9393 private static final String ORACLE_PLUS_INVALID = " +both+ "; 9394 9395 /** 9396 * Inspect a WHERE conjunct for an Oracle {@code (+)} outer-join marker and 9397 * return the lower-cased relation alias of the null-producing (outer) side. 9398 * 9399 * <p>Returns {@code null} when there is no {@code (+)} (or it is not on a 9400 * simple comparison — the only shape Oracle's {@code (+)} attaches to), or 9401 * when the marked column's relation cannot be resolved (caller degrades 9402 * when a marker was nonetheless detected). Returns 9403 * {@link #ORACLE_PLUS_INVALID} when both operands carry {@code (+)} (illegal 9404 * Oracle syntax), so the caller can degrade rather than guess a direction. 9405 * 9406 * <p>Operand subtrees are deep-scanned for the marker so a parenthesised 9407 * column ({@code (b.y(+))}) is recognised as well as a bare one. 9408 */ 9409 private static String oraclePlusOuterAlias(TExpression conjunct, 9410 NameBindingProvider provider) { 9411 TExpression e = unwrapParenExpression(conjunct); 9412 if (e == null || e.getExpressionType() != EExpressionType.simple_comparison_t) { 9413 return null; 9414 } 9415 TExpression lo = e.getLeftOperand(); 9416 TExpression ro = e.getRightOperand(); 9417 boolean leftPlus = expressionHasOraclePlus(lo); 9418 boolean rightPlus = expressionHasOraclePlus(ro); 9419 if (leftPlus && rightPlus) return ORACLE_PLUS_INVALID; 9420 if (leftPlus) return soleOperandRelationAlias(lo, provider); 9421 if (rightPlus) return soleOperandRelationAlias(ro, provider); 9422 return null; 9423 } 9424 9425 /** 9426 * True iff {@code e} or any descendant (outside a nested subquery) carries 9427 * the Oracle {@code (+)} outer-join marker. Used both to notice a marker we 9428 * cannot otherwise classify and to find it under a {@code parenthesis_t} 9429 * operand wrapper. 9430 */ 9431 private static boolean expressionHasOraclePlus(TExpression e) { 9432 if (e == null) return false; 9433 if (e.isOracleOuterJoin()) return true; 9434 final boolean[] found = {false}; 9435 e.acceptChildren(new TParseTreeVisitor() { 9436 int nestedSelectDepth = 0; 9437 9438 @Override 9439 public void preVisit(TSelectSqlStatement nested) { 9440 nestedSelectDepth++; 9441 } 9442 9443 @Override 9444 public void postVisit(TSelectSqlStatement nested) { 9445 nestedSelectDepth--; 9446 } 9447 9448 @Override 9449 public void preVisit(TExpression x) { 9450 if (nestedSelectDepth == 0 && x.isOracleOuterJoin()) { 9451 found[0] = true; 9452 } 9453 } 9454 }); 9455 return found[0]; 9456 } 9457 9458 /** 9459 * The single relation alias an operand expression references, lower-cased, 9460 * or {@code null} when it references zero or more than one relation (an 9461 * Oracle {@code (+)} marks exactly one column). 9462 */ 9463 private static String soleOperandRelationAlias(TExpression operand, 9464 NameBindingProvider provider) { 9465 java.util.Set<String> aliases = collectConjunctRelationAliases(operand, provider); 9466 return aliases.size() == 1 ? aliases.iterator().next() : null; 9467 } 9468 9469 /** 9470 * Map an Oracle {@code (+)} outer (null-producing) relation alias to the 9471 * {@link SemanticJoinType} for {@code entity}, relative to its endpoints: 9472 * a null-producing RIGHT endpoint yields {@link SemanticJoinType#LEFT} 9473 * (the left side is preserved); a null-producing LEFT endpoint yields 9474 * {@link SemanticJoinType#RIGHT} (the right side is preserved). Returns 9475 * {@code null} when the outer alias is not one of this entity's endpoints. 9476 */ 9477 private static SemanticJoinType oraclePlusJoinDirection(JoinEntity entity, 9478 String outerAliasLc) { 9479 String right = entity.getRightEndpoint().getAlias(); 9480 if (right != null && right.toLowerCase(Locale.ROOT).equals(outerAliasLc)) { 9481 return SemanticJoinType.LEFT; 9482 } 9483 for (String left : endpointAliasesLowerCase(entity.getLeftEndpoint())) { 9484 if (left.equals(outerAliasLc)) { 9485 return SemanticJoinType.RIGHT; 9486 } 9487 } 9488 return null; 9489 } 9490 9491 /** Unwrap nested parenthesis expressions iteratively; returns the inner non-paren expression. */ 9492 private static TExpression unwrapParenExpression(TExpression e) { 9493 while (e != null && e.getExpressionType() == EExpressionType.parenthesis_t 9494 && e.getLeftOperand() != null) { 9495 e = e.getLeftOperand(); 9496 } 9497 return e; 9498 } 9499 9500 /** 9501 * True iff the comma-join {@code entity} is the boundary at which a WHERE 9502 * conjunct referencing {@code aliases} should be evaluated. Three 9503 * conditions must hold: 9504 * 9505 * <ul> 9506 * <li>the conjunct references the entity's right relation (so the 9507 * newly-added relation actually participates — otherwise the 9508 * predicate belongs to an earlier boundary);</li> 9509 * <li>it references at least one left-side relation (so it is genuinely 9510 * two-sided, not a single-table filter);</li> 9511 * <li>EVERY relation it references is available at this boundary — i.e. 9512 * contained in the left aliases plus the right alias. This prevents 9513 * a predicate that also touches a not-yet-joined relation (e.g. 9514 * {@code a.x + c.z = b.y} at the {@code a×b} boundary) from being 9515 * promoted too early; it is instead picked up at the later boundary 9516 * where its last relation is added.</li> 9517 * </ul> 9518 */ 9519 private static boolean conjunctLinksImplicitJoin(JoinEntity entity, 9520 java.util.Set<String> aliases) { 9521 String right = entity.getRightEndpoint().getAlias(); 9522 if (right == null || right.isEmpty()) return false; 9523 String rightLc = right.toLowerCase(Locale.ROOT); 9524 if (!aliases.contains(rightLc)) return false; 9525 java.util.Set<String> available = new java.util.HashSet<>(); 9526 available.add(rightLc); 9527 boolean linksLeft = false; 9528 for (String left : endpointAliasesLowerCase(entity.getLeftEndpoint())) { 9529 available.add(left); 9530 if (!left.equals(rightLc) && aliases.contains(left)) { 9531 linksLeft = true; 9532 } 9533 } 9534 if (!linksLeft) return false; 9535 // All referenced relations must be available at this boundary. 9536 return available.containsAll(aliases); 9537 } 9538 9539 /** Lower-cased relation aliases an endpoint represents (RELATION → one; JOIN_RESULT → all contributors). */ 9540 private static java.util.List<String> endpointAliasesLowerCase(JoinEndpoint ep) { 9541 java.util.List<String> out = new ArrayList<>(); 9542 if (ep == null) return out; 9543 if (ep.getKind() == JoinEndpointKind.RELATION) { 9544 if (ep.getAlias() != null && !ep.getAlias().isEmpty()) { 9545 out.add(ep.getAlias().toLowerCase(Locale.ROOT)); 9546 } 9547 } else if (ep.getKind() == JoinEndpointKind.JOIN_RESULT) { 9548 for (String a : ep.getContributingAliases()) { 9549 if (a != null && !a.isEmpty()) { 9550 out.add(a.toLowerCase(Locale.ROOT)); 9551 } 9552 } 9553 } 9554 return out; 9555 } 9556 9557 /** 9558 * Collect the lower-cased relation aliases a WHERE conjunct references, by 9559 * walking its column {@link TObjectName}s. Each column contributes exactly 9560 * ONE canonical alias: the resolver-bound relation alias (which matches the 9561 * join endpoints' {@code effectiveAliasOf}, even when the column is written 9562 * with the table name rather than the alias); when the column does not 9563 * bind, the SQL-written qualifier is the fallback. A column that neither 9564 * binds nor carries a qualifier (a bare unqualified name that the resolver 9565 * cannot place) contributes nothing — it cannot pick a side deterministically. 9566 * 9567 * <p>One canonical alias per column keeps the set comparable to the 9568 * endpoint alias sets, which the containment check in 9569 * {@link #conjunctLinksImplicitJoin} relies on. Nested subqueries are not 9570 * descended into. 9571 */ 9572 private static java.util.Set<String> collectConjunctRelationAliases( 9573 TExpression conjunct, final NameBindingProvider provider) { 9574 final java.util.Set<String> aliases = new java.util.HashSet<>(); 9575 conjunct.acceptChildren(new TParseTreeVisitor() { 9576 int nestedSelectDepth = 0; 9577 9578 @Override 9579 public void preVisit(TSelectSqlStatement nested) { 9580 nestedSelectDepth++; 9581 } 9582 9583 @Override 9584 public void postVisit(TSelectSqlStatement nested) { 9585 nestedSelectDepth--; 9586 } 9587 9588 @Override 9589 public void preVisit(TObjectName node) { 9590 if (nestedSelectDepth > 0) return; 9591 if (node.getDbObjectType() != EDbObjectType.column) return; 9592 ColumnBinding binding = provider.bindColumn(node); 9593 if (binding != null && binding.getRelationAlias() != null 9594 && !binding.getRelationAlias().isEmpty()) { 9595 aliases.add(binding.getRelationAlias().toLowerCase(Locale.ROOT)); 9596 return; 9597 } 9598 String qualifier = node.getTableString(); 9599 if (qualifier != null && !qualifier.isEmpty()) { 9600 aliases.add(qualifier.toLowerCase(Locale.ROOT)); 9601 } 9602 } 9603 }); 9604 return aliases; 9605 } 9606 9607 // ----------------------------------------------------------------- 9608 // Slice 129 — PIVOT sub-slice (a): source binding + consumed-refs slot. 9609 // 9610 // A PIVOT FROM source is a TTable of type pivoted_table carrying a 9611 // TPivotedTable: getRelations().get(0) is the underlying source relation 9612 // and getPivotClause() carries the aggregation function(s), the FOR / 9613 // pivot column(s), and the IN-list. Before slice 129 this rejected with 9614 // a misleading generic TABLE_BINDING_UNRESOLVED ("could not bind table 9615 // null(...)") because bindRelation returns null for a pivoted_table. 9616 // 9617 // Sub-slice (a) admits only the narrow base-table-source / explicit- 9618 // projection / no-other-clauses shape; it binds the source relation and 9619 // captures the consumed FOR + aggregation-arg columns into the new 9620 // StatementGraph.pivotColumnRefs slot. Output-column lineage and 9621 // SELECT * expansion are sub-slice (b); UNPIVOT is (c); subquery source, 9622 // chained PIVOT, extra clauses and nested PIVOT are later sub-slices and 9623 // reject here with structured PIVOT_* diagnostics. 9624 // ----------------------------------------------------------------- 9625 9626 /** 9627 * True when {@code select}'s sole FROM source is a PIVOT/UNPIVOT operator 9628 * with no trailing JOINs. A trailing JOIN on the pivot result 9629 * (e.g. {@code FROM t PIVOT(...) p JOIN x ON ...}) is out of scope for 9630 * slice 129 and stays on the normal path. 9631 */ 9632 private static boolean isPivotSelect(TSelectSqlStatement select) { 9633 if (select.joins == null || select.joins.size() != 1) { 9634 return false; 9635 } 9636 TJoin join = select.joins.getJoin(0); 9637 if (join == null) { 9638 return false; 9639 } 9640 TJoinItemList items = join.getJoinItems(); 9641 if (items != null && items.size() > 0) { 9642 return false; 9643 } 9644 TTable t = join.getTable(); 9645 return t != null 9646 && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 9647 && t.getPivotedTable() != null; 9648 } 9649 9650 /** 9651 * Slice 143 — mirror of {@link #isPivotSelect} but for the PIVOT-then-JOIN 9652 * shape: a single explicit JOIN ({@code joinItems} PRESENT) whose driver 9653 * table is a {@code pivoted_table}. Routes to a precise deferral reject 9654 * (reusing {@code PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED}) instead of the 9655 * generic {@code TABLE_BINDING_UNRESOLVED} the normal path would emit. 9656 */ 9657 private static boolean isPivotWithJoinPartner(TSelectSqlStatement select) { 9658 if (select.joins == null || select.joins.size() != 1) { 9659 return false; 9660 } 9661 TJoin join = select.joins.getJoin(0); 9662 if (join == null) { 9663 return false; 9664 } 9665 TJoinItemList items = join.getJoinItems(); 9666 if (items == null || items.size() == 0) { 9667 return false; 9668 } 9669 TTable t = join.getTable(); 9670 return t != null 9671 && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 9672 && t.getPivotedTable() != null; 9673 } 9674 9675 /** 9676 * Slice 144 — find a PIVOT/UNPIVOT operator that appears as a JOIN 9677 * PARTNER rather than the sole FROM driver. Two positions are covered: 9678 * <ul> 9679 * <li>the right-hand side of an explicit JOIN 9680 * ({@code FROM dim d JOIN sales PIVOT(...) AS p ON ...}), and</li> 9681 * <li>a comma-join sibling ({@code FROM sales PIVOT(...) AS p, dim d}), 9682 * which the parser represents as {@code select.joins.size() > 1}.</li> 9683 * </ul> 9684 * 9685 * <p>The admitted single-PIVOT shape ({@link #isPivotSelect}) and the 9686 * slice-143 pivot-as-driver-with-a-join shape 9687 * ({@link #isPivotWithJoinPartner}) are both routed earlier and never reach 9688 * here, so this helper only upgrades shapes that would otherwise fall to the 9689 * generic {@code TABLE_BINDING_UNRESOLVED}. The driver of the SOLE 9690 * {@code TJoin} is the slice-129/-143 driver and is skipped; for comma-join 9691 * siblings ({@code size > 1}) every {@code TJoin} is an independent FROM 9692 * item whose driver may itself be the pivot. 9693 * 9694 * @return the pivoted {@link TTable} to anchor the diagnostic on, or null. 9695 */ 9696 private static TTable findPivotJoinPartner(TSelectSqlStatement select) { 9697 if (select.joins == null) { 9698 return null; 9699 } 9700 boolean singleJoin = select.joins.size() == 1; 9701 for (int j = 0; j < select.joins.size(); j++) { 9702 TJoin join = select.joins.getJoin(j); 9703 if (join == null) { 9704 continue; 9705 } 9706 if (!singleJoin && isPivotedTable(join.getTable())) { 9707 return join.getTable(); 9708 } 9709 TJoinItemList items = join.getJoinItems(); 9710 if (items != null) { 9711 for (int i = 0; i < items.size(); i++) { 9712 if (items.getJoinItem(i) != null 9713 && isPivotedTable(items.getJoinItem(i).getTable())) { 9714 return items.getJoinItem(i).getTable(); 9715 } 9716 } 9717 } 9718 } 9719 return null; 9720 } 9721 9722 /** 9723 * Slice 146 — the SQL keyword ("PIVOT" or "UNPIVOT") naming the row-shaping 9724 * operator carried by {@code pivotedTable}, used to make the JOIN-deferral 9725 * message name the actual operator instead of always saying "PIVOT". 9726 * Defaults to "PIVOT" when the clause is absent (e.g. a chained-clause-list 9727 * shape where {@link TPivotClause#getType()} is not reachable via 9728 * {@code getPivotClause()}), which is harmless for the deferral message. 9729 */ 9730 private static String pivotOperatorKeyword(TTable pivotedTable) { 9731 if (pivotedTable != null && pivotedTable.getPivotedTable() != null) { 9732 TPivotedTable pivoted = pivotedTable.getPivotedTable(); 9733 // Slice 149 — a CHAINED pivoted_table (clause list > 1) has no single 9734 // getPivotClause(): that accessor surfaces only ONE of the chained 9735 // clauses, so a MIXED chain (PIVOT(...) UNPIVOT(...)) as the JOIN 9736 // driver/partner would name just that one operator. Delegate to the 9737 // slice-148 list-walking helper so a mixed chain names the generic 9738 // "PIVOT / UNPIVOT" (an all-PIVOT chain still names "PIVOT", an 9739 // all-UNPIVOT chain still names "UNPIVOT" — byte-identical to before). 9740 if (pivoted.getPivotClauseList() != null 9741 && pivoted.getPivotClauseList().size() > 1) { 9742 return chainedPivotOperatorKeyword(pivoted); 9743 } 9744 TPivotClause pc = pivoted.getPivotClause(); 9745 if (pc != null && pc.getType() != TPivotClause.pivot) { 9746 return "UNPIVOT"; 9747 } 9748 } 9749 return "PIVOT"; 9750 } 9751 9752 /** 9753 * Slice 148 — the SQL keyword(s) naming the operator(s) carried by a 9754 * CHAINED {@code pivoted_table} ({@code pivotClauseList.size() > 1}), used to 9755 * make the {@code PIVOT_MULTIPLE_CLAUSES_NOT_SUPPORTED} deferral message name 9756 * the actual operator(s) instead of always saying "PIVOT / UNPIVOT". 9757 * Inspects every clause's {@link TPivotClause#getType()}: all PIVOT → 9758 * "PIVOT"; all UNPIVOT → "UNPIVOT"; a genuine mix (or an empty/unknown 9759 * list) → the generic "PIVOT / UNPIVOT". Unlike 9760 * {@link #pivotOperatorKeyword}, this reads the whole clause LIST because the 9761 * single {@code getPivotClause()} accessor is null for a chained shape. 9762 */ 9763 private static String chainedPivotOperatorKeyword(TPivotedTable pivoted) { 9764 boolean sawPivot = false; 9765 boolean sawUnpivot = false; 9766 if (pivoted != null && pivoted.getPivotClauseList() != null) { 9767 for (int i = 0; i < pivoted.getPivotClauseList().size(); i++) { 9768 TPivotClause pc = pivoted.getPivotClauseList().getElement(i); 9769 if (pc == null) { 9770 continue; 9771 } 9772 if (pc.getType() == TPivotClause.pivot) { 9773 sawPivot = true; 9774 } else { 9775 sawUnpivot = true; 9776 } 9777 } 9778 } 9779 if (sawPivot && !sawUnpivot) { 9780 return "PIVOT"; 9781 } 9782 if (sawUnpivot && !sawPivot) { 9783 return "UNPIVOT"; 9784 } 9785 return "PIVOT / UNPIVOT"; 9786 } 9787 9788 /** 9789 * Slice 150 — true when {@code pc} is Oracle's PIVOT XML variant 9790 * ({@code PIVOT XML (...)}). PIVOT XML aggregates all pivoted values into a 9791 * SINGLE XMLType output column rather than producing one column per IN-list 9792 * value, so its output-column lineage differs from a regular PIVOT and 9793 * {@link #buildPivotSelect} defers it. 9794 * 9795 * <p>The parser exposes no XML flag on {@link TPivotClause}; the only signal 9796 * is the clause's start token, which is the {@code XML} keyword when the XML 9797 * modifier is present (it is the opening {@code (} otherwise). UNPIVOT has no 9798 * XML variant, so this only matches a {@code pivot}-type clause. 9799 */ 9800 private static boolean isPivotXml(TPivotClause pc) { 9801 if (pc == null || pc.getType() != TPivotClause.pivot) { 9802 return false; 9803 } 9804 TSourceToken start = pc.getStartToken(); 9805 return start != null && "XML".equalsIgnoreCase(start.astext); 9806 } 9807 9808 /** 9809 * Slice 144 — true when {@code t} is a PIVOT/UNPIVOT operator 9810 * ({@code pivoted_table} carrying a {@code TPivotedTable}). 9811 */ 9812 private static boolean isPivotedTable(TTable t) { 9813 return t != null 9814 && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 9815 && t.getPivotedTable() != null; 9816 } 9817 9818 /** 9819 * Slice 129 — build the StatementGraph for a PIVOT-source SELECT 9820 * (see {@link #isPivotSelect}). Applies the admit-set rejects in a fixed 9821 * order, binds the underlying source relation, captures the consumed 9822 * FOR + aggregation-arg columns, and emits output-column NAMES with empty 9823 * sources (lineage deferred to a later sub-slice). 9824 */ 9825 private static StatementGraph buildPivotSelect(TSelectSqlStatement select, 9826 NameBindingProvider provider, 9827 String name) { 9828 TTable pivotTable = select.joins.getJoin(0).getTable(); 9829 TPivotedTable pivoted = pivotTable.getPivotedTable(); 9830 9831 // 1. single pivot clause only (chained PIVOTs deferred). 9832 if (pivoted.getPivotClauseList() != null 9833 && pivoted.getPivotClauseList().size() > 1) { 9834 // Slice 148 — name the ACTUAL chained operator(s) (all PIVOT, 9835 // all UNPIVOT, or a genuine mix), completing the message-operator 9836 // consistency begun on the JOIN-deferral (slice 146) and 9837 // extra-clause-deferral (slice 147) paths. 9838 throw new SemanticIRBuildException( 9839 Diagnostic.error(DiagnosticCode.PIVOT_MULTIPLE_CLAUSES_NOT_SUPPORTED, 9840 "chained " + chainedPivotOperatorKeyword(pivoted) 9841 + " clauses are not supported yet", pivotTable)); 9842 } 9843 TPivotClause pc = pivoted.getPivotClause(); 9844 if (pc == null) { 9845 throw new SemanticIRBuildException( 9846 Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED, 9847 "could not bind PIVOT source (no pivot clause)", pivotTable)); 9848 } 9849 // 1b. Slice 150 — Oracle PIVOT XML is a fundamentally different shape: 9850 // it produces a SINGLE XMLType output column (an XML aggregate of 9851 // all pivoted values), NOT one column per IN-list value. Treating 9852 // it as a regular PIVOT (the only path below) would emit incorrect 9853 // output-column lineage, so defer it with the generic 9854 // PIVOT/UNPIVOT-unsupported code. Checked here — before the 9855 // UNPIVOT-shape, extra-clause, and bare-* handling — so the XML 9856 // limitation is the reason reported regardless of the other clauses. 9857 if (isPivotXml(pc)) { 9858 throw new SemanticIRBuildException( 9859 Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED, 9860 "PIVOT XML is not supported yet (the single XMLType output " 9861 + "column has different lineage than a regular PIVOT)", 9862 pivotTable)); 9863 } 9864 // 2. UNPIVOT sub-slice (c): the deterministic single-value-column / 9865 // simple-IN-list shape is now supported (see buildUnpivotOutputColumns). 9866 // A multi-value-column or column-group ((a,b) ...) UNPIVOT stays 9867 // deferred — checked here so PIVOT_UNPIVOT_NOT_SUPPORTED stays reached 9868 // and the harder positional mapping is not mis-attributed. 9869 boolean isUnpivot = pc.getType() != TPivotClause.pivot; 9870 if (isUnpivot) { 9871 rejectUnsupportedUnpivotShape(pc, pivotTable); 9872 } 9873 // 3. extra query clauses deferred (WHERE / GROUP BY / HAVING / 9874 // ORDER BY / QUALIFY / DISTINCT / row-limit / set-op). Processing 9875 // them would hit the same resolver NOT_FOUND quirk that forces the 9876 // direct consumed-ref construction below. 9877 rejectPivotExtraClauses(select, pivotTable); 9878 // 4. source must be a base table (subquery source deferred). 9879 if (pivoted.getRelations().isEmpty() || pivoted.getRelations().get(0) == null) { 9880 throw new SemanticIRBuildException( 9881 Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED, 9882 "could not bind PIVOT source (no source relation)", pivotTable)); 9883 } 9884 TTable sourceTable = pivoted.getRelations().get(0); 9885 // Slice 136: a FROM-subquery source is admitted for EXPLICIT projections. 9886 // The inner SELECT was already extracted as its own statement by the 9887 // processDirectSubqueryTable pre-pass (which unwraps the pivoted_table); 9888 // binding here with allowFromSubqueries=true yields a SUBQUERY-kind 9889 // relation whose alias matches the registered subquery-statement alias, so 9890 // emitLineageForStatement wires the pivot output → subquery output edges. 9891 // Slice 137: a bare SELECT * over a subquery source is now ALSO admitted — 9892 // the slice-137 addRelationToInScopeMap unwrap registers the subquery's 9893 // published columns under the source alias, so expandPivot/UnpivotBareStar 9894 // can synthesise the passthrough schema without an external catalog. 9895 RelationSource sourceRel = buildRelation(sourceTable, provider, /*allowFromSubqueries=*/ true); 9896 9897 // 4b. Slice 156 (PIVOT) / slice 157 (UNPIVOT) — a WHERE clause over a 9898 // PIVOT or UNPIVOT is now admitted when every ref is a PROVABLE 9899 // passthrough source column (resolved directly to the underlying 9900 // source column); anything else (a pivot-output / UNPIVOT value or 9901 // FOR-name / consumed / non-provable ref, or a subquery predicate) 9902 // keeps the slice-129 deferral. Resolved BEFORE output building so the 9903 // WHERE deferral wins over a (deferred) output shape, mirroring the 9904 // previous rejectPivotExtraClauses precedence. 9905 List<ColumnRef> filterRefs = buildPivotWhereColumnRefs(select, pc, isUnpivot, 9906 sourceRel.getAlias(), sourceTable, provider, pivotTable); 9907 9908 // 4c. Slice 158 — a GROUP BY clause over a PIVOT or UNPIVOT is now 9909 // admitted (into groupByColumnRefs) when every grouped column is a 9910 // PROVABLE passthrough source column, the sibling of the slice-156/157 9911 // WHERE admission. Resolved AFTER the WHERE so a WHERE deferral wins 9912 // over a GROUP BY deferral (preserving the previous 9913 // rejectPivotExtraClauses precedence, which checked WHERE before 9914 // GROUP BY). A GROUP BY paired with a HAVING already deferred earlier 9915 // in rejectPivotExtraClauses (HAVING branch), so it never reaches here. 9916 List<ColumnRef> groupByRefs = buildPivotGroupByColumnRefs(select, pc, isUnpivot, 9917 sourceRel.getAlias(), sourceTable, provider, pivotTable); 9918 9919 // 4d. Slice 160 — a HAVING clause over a PIVOT or UNPIVOT is now admitted 9920 // (into havingColumnRefs) when every column the HAVING touches is a 9921 // PROVABLE passthrough source column, the sibling of the slice-156/157 9922 // WHERE, slice-158 GROUP BY, and slice-159 QUALIFY admissions. Resolved 9923 // AFTER the WHERE and GROUP BY and BEFORE the QUALIFY — the natural SQL 9924 // clause order (WHERE, GROUP BY, HAVING, QUALIFY) — so a deferred WHERE 9925 // / GROUP BY wins over a deferred HAVING, and a deferred HAVING wins 9926 // over a deferred QUALIFY. 9927 List<ColumnRef> havingRefs = buildPivotHavingColumnRefs(select, pc, isUnpivot, 9928 sourceRel.getAlias(), sourceTable, provider, pivotTable); 9929 9930 // 4e. Slice 159 — a QUALIFY clause over a PIVOT or UNPIVOT is now admitted 9931 // (into qualifyColumnRefs) when every column the QUALIFY touches is a 9932 // PROVABLE passthrough source column, the sibling of the slice-156/157 9933 // WHERE, slice-158 GROUP BY, and slice-160 HAVING admissions. Resolved 9934 // AFTER the WHERE, GROUP BY, and HAVING so any earlier-clause deferral 9935 // wins — the natural SQL clause order (WHERE, GROUP BY, HAVING, 9936 // QUALIFY). 9937 List<ColumnRef> qualifyRefs = buildPivotQualifyColumnRefs(select, pc, isUnpivot, 9938 sourceRel.getAlias(), sourceTable, provider, pivotTable); 9939 9940 // 4f. Slice 161 - an ORDER BY clause over a PIVOT or UNPIVOT is already 9941 // admitted (slice 142, lineage-neutral); this records its 9942 // orderByColumnRefs when every sort-key column is a PROVABLE passthrough 9943 // source column, the sibling of the slice-156/157 WHERE, slice-158 GROUP 9944 // BY, slice-159 QUALIFY, and slice-160 HAVING admissions. UNLIKE those, 9945 // it NEVER defers: a non-passthrough / unprovable / subquery sort key 9946 // simply leaves the slot empty (the lineage-neutral slice-142 behaviour). 9947 List<ColumnRef> orderByRefs = buildPivotOrderByColumnRefs(select, pc, isUnpivot, 9948 sourceRel.getAlias(), sourceTable, provider); 9949 9950 // 5. + 6. consumed refs and output-column lineage, branched on PIVOT 9951 // vs UNPIVOT. PIVOT consumes the FOR + aggregation-arg columns and 9952 // its outputs are the passthrough + pivot-output columns; UNPIVOT 9953 // consumes the IN-list source columns and its outputs are the 9954 // passthrough + value + FOR columns (slice 131 sub-slice (c)). 9955 List<ColumnRef> consumedRefs; 9956 List<OutputColumn> outputs; 9957 if (isUnpivot) { 9958 consumedRefs = collectUnpivotInListRefs(pc, sourceRel.getAlias()); 9959 outputs = buildUnpivotOutputColumns(select, pivotTable, pc, 9960 sourceRel.getAlias(), sourceTable, provider); 9961 } else { 9962 consumedRefs = collectPivotConsumedRefs(pc, sourceRel.getAlias()); 9963 outputs = buildPivotOutputColumns(select, pivotTable, pc, 9964 sourceRel.getAlias(), sourceTable, provider); 9965 } 9966 9967 // Slice 180 (R5): PIVOT/UNPIVOT SELECT blocks are built here, not at 9968 // the main SELECT site, so set their block span too. 9969 return new StatementGraph(name, "SELECT", 9970 Collections.singletonList(sourceRel), outputs, filterRefs, groupByRefs, 9971 havingRefs, qualifyRefs, orderByRefs, consumedRefs) 9972 .withSourceSpan(SourceSpan.of(select)); 9973 } 9974 9975 /** 9976 * Slice 129 — reject a PIVOT SELECT that carries a COLUMN-TOUCHING query 9977 * clause beyond the projection and the FROM-PIVOT source. One 9978 * {@code DiagnosticCode} with clause-discriminated message text (the 9979 * deferral is the same semantic boundary: "PIVOT combined with an 9980 * additional query clause is not supported yet"). Deferred because 9981 * resolving a {@code WHERE} / {@code GROUP BY} / {@code HAVING} / 9982 * {@code QUALIFY} column reference needs to disambiguate a passthrough 9983 * SOURCE column from a PIVOT-OUTPUT column. 9984 * 9985 * <p>Slice 142 — the ROW-SHAPING clauses ({@code ORDER BY}, {@code DISTINCT}, 9986 * and the row-limit clauses {@code TOP} / {@code LIMIT} / {@code OFFSET} / 9987 * {@code FETCH FIRST}) are NO LONGER rejected: they only re-shape the row 9988 * SET (order / dedup / truncate) and do not change the output COLUMN set or 9989 * the column lineage, which {@link #buildPivotSelect} reads only from the 9990 * pivot clause, the projection, and the source. Admitting them is therefore 9991 * lineage-neutral — the resulting {@code StatementGraph} is byte-identical 9992 * to the same query without the clause. The (defensive) set-operator branch 9993 * stays: a pivot SELECT node never carries a set operator in practice 9994 * ({@link #isPivotSelect} requires a FROM-PIVOT, which a union node has not), 9995 * but the guard is kept for robustness. 9996 */ 9997 private static void rejectPivotExtraClauses(TSelectSqlStatement select, TTable pivotTable) { 9998 String clause = null; 9999 // Slice 156 — the WHERE clause is no longer rejected here; it is handled 10000 // by buildPivotWhereColumnRefs (called after the source is bound), which 10001 // ADMITS a WHERE whose every ref is a provable passthrough source column 10002 // and otherwise throws the same PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED 10003 // deferral. Slice 158 — likewise the GROUP BY clause is now handled by 10004 // buildPivotGroupByColumnRefs (passthrough-only admission). Slice 159 — 10005 // likewise the QUALIFY clause is now handled by buildPivotQualifyColumnRefs 10006 // (passthrough-only admission). Slice 160 — likewise the HAVING clause is 10007 // now handled by buildPivotHavingColumnRefs (passthrough-only admission). 10008 // The only remaining column-touching clause that still defers 10009 // unconditionally here is a set operator (a pivot SELECT node never carries 10010 // one in practice, but the guard is kept defensively). The WHERE / GROUP BY 10011 // / HAVING / QUALIFY admission helpers run after this guard, in natural SQL 10012 // clause order, so a deferred earlier clause wins over a deferred later one. 10013 if (select.getSetOperatorType() != ESetOperatorType.none) { 10014 clause = "a set operator"; 10015 } 10016 if (clause != null) { 10017 // Slice 147 — name the ACTUAL operator (PIVOT vs UNPIVOT) via the 10018 // shared slice-146 helper, completing the message-operator 10019 // consistency begun on the JOIN-deferral path. 10020 throw new SemanticIRBuildException( 10021 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10022 pivotOperatorKeyword(pivotTable) + " combined with " + clause 10023 + " is not supported yet", pivotTable)); 10024 } 10025 } 10026 10027 /** 10028 * Slice 156 (PIVOT) / slice 157 (UNPIVOT) — resolve the column refs of a 10029 * {@code WHERE} clause over a PIVOT or UNPIVOT, admitting the clause only when 10030 * EVERY referenced column is a PROVABLE passthrough source column. Returns the 10031 * resolved refs (document order, deduped) for the {@code StatementGraph} 10032 * filterColumnRefs slot, or an empty list when there is no WHERE. 10033 * 10034 * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the 10035 * slice-129 deferral, code kept reached) when the WHERE cannot be admitted: 10036 * the WHERE contains a subquery predicate; or any ref is NOT a provable 10037 * passthrough column. For a PIVOT, a non-passthrough ref is a pivot-output 10038 * column, a consumed FOR / aggregation-arg column, or a column whose source 10039 * membership cannot be proven without a catalog. For an UNPIVOT (slice 157), 10040 * it is the synthesised VALUE or FOR/name output column, a consumed IN-list 10041 * source column (narrowed into rows), or — again — a column with no provable 10042 * source membership. A passthrough column passes through the operator 10043 * unchanged, so its WHERE ref resolves directly to the underlying source 10044 * column ({@code sourceAlias.col}) regardless of the aggregation count or 10045 * IN-list shape — the same provable-membership doctrine as slice 153's 10046 * passthrough output columns. The {@code dbObjectType == column} filter 10047 * excludes function names and other non-column tokens. 10048 */ 10049 private static List<ColumnRef> buildPivotWhereColumnRefs(TSelectSqlStatement select, 10050 TPivotClause pc, 10051 boolean isUnpivot, 10052 String sourceAlias, 10053 TTable sourceTable, 10054 NameBindingProvider provider, 10055 TTable pivotTable) { 10056 TWhereClause where = select.getWhereClause(); 10057 if (where == null || where.getCondition() == null) { 10058 return Collections.emptyList(); 10059 } 10060 // Slice 156 (PIVOT) / slice 157 (UNPIVOT) — the PROVABLE passthrough source 10061 // columns of the operator. A passthrough column survives the PIVOT/UNPIVOT 10062 // unchanged, so a WHERE touching ONLY those columns resolves directly to 10063 // the underlying source column. The set is empty when membership cannot be 10064 // proven (a base table with no catalog) — keeping the WHERE deferred. 10065 Set<String> passthroughLC = isUnpivot 10066 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10067 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10068 if (!passthroughLC.isEmpty()) { 10069 final List<TObjectName> whereCols = new ArrayList<>(); 10070 final boolean[] hasSubquery = {false}; 10071 TParseTreeVisitor v = new TParseTreeVisitor() { 10072 @Override 10073 public void preVisit(TObjectName n) { 10074 whereCols.add(n); 10075 } 10076 10077 @Override 10078 public void preVisit(TSelectSqlStatement s) { 10079 hasSubquery[0] = true; 10080 } 10081 }; 10082 where.getCondition().acceptChildren(v); 10083 if (!hasSubquery[0]) { 10084 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10085 boolean allPassthrough = true; 10086 for (TObjectName n : whereCols) { 10087 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10088 continue; 10089 } 10090 String col = n.getColumnNameOnly(); 10091 if (col == null || col.isEmpty() || "*".equals(col)) { 10092 continue; 10093 } 10094 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10095 allPassthrough = false; 10096 break; 10097 } 10098 refs.add(new ColumnRef(sourceAlias, col)); 10099 } 10100 if (allPassthrough) { 10101 return new ArrayList<>(refs); 10102 } 10103 } 10104 } 10105 throw new SemanticIRBuildException( 10106 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10107 pivotOperatorKeyword(pivotTable) + " combined with a WHERE clause " 10108 + "is not supported yet", pivotTable)); 10109 } 10110 10111 /** 10112 * Slice 158 — resolve the column refs of a {@code GROUP BY} clause over a 10113 * PIVOT or UNPIVOT, admitting the clause only when EVERY grouped column is a 10114 * PROVABLE passthrough source column. Returns the resolved refs (document 10115 * order, deduped) for the {@code StatementGraph} groupByColumnRefs slot, or an 10116 * empty list when there is no GROUP BY. This is the GROUP BY sibling of the 10117 * slice-156/157 WHERE admission ({@link #buildPivotWhereColumnRefs}) and uses 10118 * the same provable-passthrough doctrine: a passthrough column survives the 10119 * PIVOT/UNPIVOT unchanged, so grouping on it resolves directly to the 10120 * underlying source column ({@code sourceAlias.col}). 10121 * 10122 * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the 10123 * slice-129 deferral, code kept reached) when the GROUP BY cannot be admitted: 10124 * it contains a subquery; or any grouped ref is NOT a provable passthrough 10125 * column (a pivot-output column, a consumed FOR / aggregation-arg or IN-list 10126 * column, or a column whose source membership cannot be proven without a 10127 * catalog). A GROUP BY paired with a HAVING already deferred earlier in 10128 * {@link #rejectPivotExtraClauses}, so it never reaches here. The 10129 * {@code dbObjectType == column} filter excludes function names and other 10130 * non-column tokens. 10131 */ 10132 private static List<ColumnRef> buildPivotGroupByColumnRefs(TSelectSqlStatement select, 10133 TPivotClause pc, 10134 boolean isUnpivot, 10135 String sourceAlias, 10136 TTable sourceTable, 10137 NameBindingProvider provider, 10138 TTable pivotTable) { 10139 TGroupBy groupBy = select.getGroupByClause(); 10140 if (groupBy == null || groupBy.getItems() == null 10141 || groupBy.getItems().size() == 0) { 10142 return Collections.emptyList(); 10143 } 10144 // The PROVABLE passthrough source columns of the operator — the same set 10145 // the WHERE admission uses. Empty when membership cannot be proven (a base 10146 // table with no catalog), keeping the GROUP BY deferred. 10147 Set<String> passthroughLC = isUnpivot 10148 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10149 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10150 if (!passthroughLC.isEmpty()) { 10151 final List<TObjectName> groupCols = new ArrayList<>(); 10152 final boolean[] hasSubquery = {false}; 10153 TParseTreeVisitor v = new TParseTreeVisitor() { 10154 @Override 10155 public void preVisit(TObjectName n) { 10156 groupCols.add(n); 10157 } 10158 10159 @Override 10160 public void preVisit(TSelectSqlStatement s) { 10161 hasSubquery[0] = true; 10162 } 10163 }; 10164 TGroupByItemList items = groupBy.getItems(); 10165 for (int i = 0; i < items.size(); i++) { 10166 TGroupByItem item = items.getGroupByItem(i); 10167 if (item != null && item.getExpr() != null) { 10168 item.getExpr().acceptChildren(v); 10169 } 10170 } 10171 if (!hasSubquery[0]) { 10172 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10173 boolean allPassthrough = true; 10174 for (TObjectName n : groupCols) { 10175 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10176 continue; 10177 } 10178 String col = n.getColumnNameOnly(); 10179 if (col == null || col.isEmpty() || "*".equals(col)) { 10180 continue; 10181 } 10182 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10183 allPassthrough = false; 10184 break; 10185 } 10186 refs.add(new ColumnRef(sourceAlias, col)); 10187 } 10188 if (allPassthrough) { 10189 return new ArrayList<>(refs); 10190 } 10191 } 10192 } 10193 throw new SemanticIRBuildException( 10194 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10195 pivotOperatorKeyword(pivotTable) + " combined with a GROUP BY clause " 10196 + "is not supported yet", pivotTable)); 10197 } 10198 10199 /** 10200 * Slice 159 — resolve the column refs of a {@code QUALIFY} clause over a PIVOT 10201 * or UNPIVOT, admitting the clause only when EVERY column the QUALIFY touches 10202 * (its window-function partition / order columns and any other column refs) is 10203 * a PROVABLE passthrough source column. Returns the resolved refs (document 10204 * order, deduped) for the {@code StatementGraph} qualifyColumnRefs slot, or an 10205 * empty list when there is no QUALIFY. This is the QUALIFY sibling of the 10206 * slice-156/157 WHERE admission ({@link #buildPivotWhereColumnRefs}) and the 10207 * slice-158 GROUP BY admission ({@link #buildPivotGroupByColumnRefs}), using 10208 * the same provable-passthrough doctrine: a passthrough column survives the 10209 * PIVOT/UNPIVOT unchanged, so a QUALIFY touching only those columns resolves 10210 * directly to the underlying source column ({@code sourceAlias.col}). 10211 * 10212 * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the 10213 * slice-129 deferral, code kept reached) when the QUALIFY cannot be admitted: 10214 * it contains a subquery; or any ref is NOT a provable passthrough column (a 10215 * pivot-output column, an UNPIVOT VALUE / FOR column, a consumed FOR / agg-arg 10216 * or IN-list column, or a column whose source membership cannot be proven 10217 * without a catalog). A QUALIFY paired with a HAVING already deferred earlier 10218 * in {@link #rejectPivotExtraClauses}, so it never reaches here. The 10219 * {@code dbObjectType == column} filter excludes the window-function name and 10220 * other non-column tokens. 10221 */ 10222 private static List<ColumnRef> buildPivotQualifyColumnRefs(TSelectSqlStatement select, 10223 TPivotClause pc, 10224 boolean isUnpivot, 10225 String sourceAlias, 10226 TTable sourceTable, 10227 NameBindingProvider provider, 10228 TTable pivotTable) { 10229 if (select.getQualifyClause() == null 10230 || select.getQualifyClause().getSearchConditoin() == null) { 10231 return Collections.emptyList(); 10232 } 10233 TExpression qualify = select.getQualifyClause().getSearchConditoin(); 10234 // The PROVABLE passthrough source columns of the operator — the same set 10235 // the WHERE and GROUP BY admissions use. Empty when membership cannot be 10236 // proven (a base table with no catalog), keeping the QUALIFY deferred. 10237 Set<String> passthroughLC = isUnpivot 10238 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10239 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10240 if (!passthroughLC.isEmpty()) { 10241 final List<TObjectName> qualifyCols = new ArrayList<>(); 10242 final boolean[] hasSubquery = {false}; 10243 TParseTreeVisitor v = new TParseTreeVisitor() { 10244 @Override 10245 public void preVisit(TObjectName n) { 10246 qualifyCols.add(n); 10247 } 10248 10249 @Override 10250 public void preVisit(TSelectSqlStatement s) { 10251 hasSubquery[0] = true; 10252 } 10253 }; 10254 qualify.acceptChildren(v); 10255 if (!hasSubquery[0]) { 10256 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10257 boolean allPassthrough = true; 10258 for (TObjectName n : qualifyCols) { 10259 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10260 continue; 10261 } 10262 String col = n.getColumnNameOnly(); 10263 if (col == null || col.isEmpty() || "*".equals(col)) { 10264 continue; 10265 } 10266 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10267 allPassthrough = false; 10268 break; 10269 } 10270 refs.add(new ColumnRef(sourceAlias, col)); 10271 } 10272 if (allPassthrough) { 10273 return new ArrayList<>(refs); 10274 } 10275 } 10276 } 10277 throw new SemanticIRBuildException( 10278 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10279 pivotOperatorKeyword(pivotTable) + " combined with a QUALIFY clause " 10280 + "is not supported yet", pivotTable)); 10281 } 10282 10283 /** 10284 * Slice 161 — resolve the column refs of an {@code ORDER BY} clause over a 10285 * PIVOT or UNPIVOT, recording them in the {@code orderByColumnRefs} slot only 10286 * when EVERY sort-key column is a PROVABLE passthrough source column. Returns 10287 * the resolved refs (document order, deduped) or an empty list. 10288 * 10289 * <p>UNLIKE the slice-156/157 WHERE, slice-158 GROUP BY, slice-159 QUALIFY, and 10290 * slice-160 HAVING admissions (which THROW 10291 * {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} when the clause 10292 * cannot be admitted), an ORDER BY over a pivot is ALREADY admitted (slice 142, 10293 * lineage-neutral — it only re-shapes the row order, not the output column set). 10294 * This helper therefore NEVER throws: it merely refines the lineage by recording 10295 * the passthrough refs when they can be proven, and otherwise returns an empty 10296 * list — exactly the lineage-neutral slice-142 behaviour. The slot stays empty 10297 * when: there is no ORDER BY; the source has no provable schema (a base table 10298 * with no catalog); any sort key contains a subquery; or any sort-key column is 10299 * NOT a provable passthrough column (a pivot-output / UNPIVOT-value / FOR / 10300 * consumed column, an ordinal, or a projection-alias reference). The 10301 * all-or-nothing gate mirrors the WHERE doctrine: a mixed ORDER BY (one 10302 * passthrough + one non-passthrough key) records nothing rather than partial 10303 * lineage. The {@code dbObjectType == column} filter excludes function names and 10304 * other non-column tokens; ordinals/aliases that are not source columns fail the 10305 * passthrough membership test and keep the slot empty. 10306 */ 10307 private static List<ColumnRef> buildPivotOrderByColumnRefs(TSelectSqlStatement select, 10308 TPivotClause pc, 10309 boolean isUnpivot, 10310 String sourceAlias, 10311 TTable sourceTable, 10312 NameBindingProvider provider) { 10313 TOrderBy orderBy = select.getOrderbyClause(); 10314 if (orderBy == null || orderBy.getItems() == null) { 10315 return Collections.emptyList(); 10316 } 10317 // The PROVABLE passthrough source columns of the operator — the same set the 10318 // WHERE, GROUP BY, HAVING, and QUALIFY admissions use. Empty when membership 10319 // cannot be proven (a base table with no catalog), keeping ORDER BY 10320 // lineage-neutral. 10321 Set<String> passthroughLC = isUnpivot 10322 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10323 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10324 if (passthroughLC.isEmpty()) { 10325 return Collections.emptyList(); 10326 } 10327 final List<TObjectName> orderByCols = new ArrayList<>(); 10328 final boolean[] hasSubquery = {false}; 10329 TParseTreeVisitor v = new TParseTreeVisitor() { 10330 @Override 10331 public void preVisit(TObjectName n) { 10332 orderByCols.add(n); 10333 } 10334 10335 @Override 10336 public void preVisit(TSelectSqlStatement s) { 10337 hasSubquery[0] = true; 10338 } 10339 }; 10340 TOrderByItemList items = orderBy.getItems(); 10341 for (int i = 0; i < items.size(); i++) { 10342 TOrderByItem item = items.getOrderByItem(i); 10343 if (item == null || item.getSortKey() == null) { 10344 continue; 10345 } 10346 item.getSortKey().acceptChildren(v); 10347 } 10348 if (hasSubquery[0]) { 10349 return Collections.emptyList(); 10350 } 10351 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10352 for (TObjectName n : orderByCols) { 10353 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10354 continue; 10355 } 10356 String col = n.getColumnNameOnly(); 10357 if (col == null || col.isEmpty() || "*".equals(col)) { 10358 continue; 10359 } 10360 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10361 // Not all passthrough (a pivot-output / consumed column, or an 10362 // alias) — stay lineage-neutral (slice 142), do NOT defer. 10363 return Collections.emptyList(); 10364 } 10365 refs.add(new ColumnRef(sourceAlias, col)); 10366 } 10367 return new ArrayList<>(refs); 10368 } 10369 10370 /** 10371 * Slice 160 — resolve the column refs of a {@code HAVING} clause over a PIVOT 10372 * or UNPIVOT, admitting the clause only when EVERY column the HAVING touches 10373 * (its aggregate-function arguments and any other column refs) is a PROVABLE 10374 * passthrough source column. Returns the resolved refs (document order, 10375 * deduped) for the {@code StatementGraph} havingColumnRefs slot, or an empty 10376 * list when there is no HAVING. This is the HAVING sibling of the slice-156/157 10377 * WHERE admission ({@link #buildPivotWhereColumnRefs}), the slice-158 GROUP BY 10378 * admission ({@link #buildPivotGroupByColumnRefs}), and the slice-159 QUALIFY 10379 * admission ({@link #buildPivotQualifyColumnRefs}), using the same 10380 * provable-passthrough doctrine: a passthrough column survives the 10381 * PIVOT/UNPIVOT unchanged, so a HAVING touching only those columns resolves 10382 * directly to the underlying source column ({@code sourceAlias.col}). A 10383 * {@code HAVING COUNT(*)} touches no column at all, so it admits over a provable 10384 * source (empty refs) but still defers over a base table with no catalog (the 10385 * provable-passthrough set is empty, so membership cannot be proven). 10386 * 10387 * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the 10388 * slice-129 deferral, code kept reached) when the HAVING cannot be admitted: it 10389 * contains a subquery; or any ref is NOT a provable passthrough column (a 10390 * pivot-output column, an UNPIVOT VALUE / FOR column, a consumed FOR / agg-arg 10391 * or IN-list column, or a column whose source membership cannot be proven 10392 * without a catalog). The {@code dbObjectType == column} filter excludes the 10393 * aggregate-function names and other non-column tokens. 10394 */ 10395 private static List<ColumnRef> buildPivotHavingColumnRefs(TSelectSqlStatement select, 10396 TPivotClause pc, 10397 boolean isUnpivot, 10398 String sourceAlias, 10399 TTable sourceTable, 10400 NameBindingProvider provider, 10401 TTable pivotTable) { 10402 TGroupBy groupBy = select.getGroupByClause(); 10403 TExpression having = (groupBy == null) ? null : groupBy.getHavingClause(); 10404 if (having == null) { 10405 return Collections.emptyList(); 10406 } 10407 // The PROVABLE passthrough source columns of the operator — the same set 10408 // the WHERE, GROUP BY, and QUALIFY admissions use. Empty when membership 10409 // cannot be proven (a base table with no catalog), keeping the HAVING 10410 // deferred. 10411 Set<String> passthroughLC = isUnpivot 10412 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10413 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10414 if (!passthroughLC.isEmpty()) { 10415 final List<TObjectName> havingCols = new ArrayList<>(); 10416 final boolean[] hasSubquery = {false}; 10417 TParseTreeVisitor v = new TParseTreeVisitor() { 10418 @Override 10419 public void preVisit(TObjectName n) { 10420 havingCols.add(n); 10421 } 10422 10423 @Override 10424 public void preVisit(TSelectSqlStatement s) { 10425 hasSubquery[0] = true; 10426 } 10427 }; 10428 having.acceptChildren(v); 10429 if (!hasSubquery[0]) { 10430 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10431 boolean allPassthrough = true; 10432 for (TObjectName n : havingCols) { 10433 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10434 continue; 10435 } 10436 String col = n.getColumnNameOnly(); 10437 if (col == null || col.isEmpty() || "*".equals(col)) { 10438 continue; 10439 } 10440 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10441 allPassthrough = false; 10442 break; 10443 } 10444 refs.add(new ColumnRef(sourceAlias, col)); 10445 } 10446 if (allPassthrough) { 10447 return new ArrayList<>(refs); 10448 } 10449 } 10450 } 10451 throw new SemanticIRBuildException( 10452 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10453 pivotOperatorKeyword(pivotTable) + " combined with a HAVING clause " 10454 + "is not supported yet", pivotTable)); 10455 } 10456 10457 /** 10458 * Slice 156 — the lower-cased PROVABLE passthrough (group) source column 10459 * names of a PIVOT: the source's published columns (the catalog or a 10460 * FROM-subquery's published schema) MINUS the columns consumed by the PIVOT 10461 * (the FOR / pivot column(s) and the aggregation argument(s)). Empty when the 10462 * source has no provable schema (a base table with no catalog). This is the 10463 * slice-153 passthrough rule, factored out so the WHERE-clause admission 10464 * (slice 156) and {@link #buildPivotOutputColumns}' passthrough column 10465 * resolution share one source of truth. 10466 */ 10467 private static Set<String> provablePivotPassthroughColsLC(TPivotClause pc, 10468 String sourceAlias, 10469 TTable sourceTable, 10470 NameBindingProvider provider) { 10471 List<String> srcCols = lookupRelationColumnNames(sourceTable, provider); 10472 if (srcCols == null || srcCols.isEmpty()) { 10473 return Collections.emptySet(); 10474 } 10475 Set<String> consumedLC = new HashSet<>(); 10476 List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias)); 10477 consumed.addAll(collectPivotAggArgRefs(pc, sourceAlias)); 10478 for (ColumnRef r : consumed) { 10479 String cn = (r == null) ? null : r.getColumnName(); 10480 if (cn != null && !cn.isEmpty()) { 10481 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 10482 } 10483 } 10484 Set<String> passthrough = new HashSet<>(); 10485 for (String c : srcCols) { 10486 if (c == null || c.isEmpty()) { 10487 continue; 10488 } 10489 String lc = c.toLowerCase(Locale.ROOT); 10490 if (!consumedLC.contains(lc)) { 10491 passthrough.add(lc); 10492 } 10493 } 10494 return passthrough; 10495 } 10496 10497 /** 10498 * Slice 157 — the lower-cased PROVABLE passthrough source column names of an 10499 * UNPIVOT: the source's published columns (the catalog or a FROM-subquery's 10500 * published schema) MINUS the IN-list SOURCE columns the UNPIVOT narrows into 10501 * rows ({@link #collectUnpivotInListRefs}). Empty when the source has no 10502 * provable schema (a base table with no catalog). These are exactly the 10503 * passthrough OUTPUT columns {@link #expandUnpivotBareStar} keeps unchanged; 10504 * the synthesised VALUE / FOR-name output columns are NOT source columns (by 10505 * UNPIVOT semantics they cannot name an existing input column), so they are 10506 * absent from the published set and a WHERE touching them stays deferred. This 10507 * is the UNPIVOT mirror of {@link #provablePivotPassthroughColsLC}, shared by 10508 * the WHERE-clause admission ({@link #buildPivotWhereColumnRefs}). 10509 */ 10510 private static Set<String> provableUnpivotPassthroughColsLC(TPivotClause pc, 10511 String sourceAlias, 10512 TTable sourceTable, 10513 NameBindingProvider provider) { 10514 List<String> srcCols = lookupRelationColumnNames(sourceTable, provider); 10515 if (srcCols == null || srcCols.isEmpty()) { 10516 return Collections.emptySet(); 10517 } 10518 Set<String> consumedLC = new HashSet<>(); 10519 for (ColumnRef r : collectUnpivotInListRefs(pc, sourceAlias)) { 10520 String cn = (r == null) ? null : r.getColumnName(); 10521 if (cn != null && !cn.isEmpty()) { 10522 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 10523 } 10524 } 10525 Set<String> passthrough = new HashSet<>(); 10526 for (String c : srcCols) { 10527 if (c == null || c.isEmpty()) { 10528 continue; 10529 } 10530 String lc = c.toLowerCase(Locale.ROOT); 10531 if (!consumedLC.contains(lc)) { 10532 passthrough.add(lc); 10533 } 10534 } 10535 return passthrough; 10536 } 10537 10538 /** 10539 * Slice 129 — capture the columns CONSUMED by a PIVOT: the FOR / pivot 10540 * column(s) first, then the aggregation-function argument column(s), in 10541 * document order, deduplicated by (alias, columnName). Each is built 10542 * DIRECTLY as a {@link ColumnRef} against the bound source relation — 10543 * resolver2 marks these refs NOT_FOUND in PIVOT context even though 10544 * Phase-1 {@code linkColumnToTable} sets their {@code sourceTable}, so the 10545 * strict {@code collectColumnRefs} path would reject them. 10546 */ 10547 private static List<ColumnRef> collectPivotConsumedRefs(TPivotClause pc, 10548 String sourceAlias) { 10549 // Consumed = FOR / pivot column(s) FIRST, then aggregation-arg 10550 // column(s), document order, deduped (LinkedHashSet). Slice 130 split 10551 // the two halves into reusable helpers: the agg-arg refs are also the 10552 // sources of a pivot-output column (see buildPivotOutputColumns). 10553 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10554 refs.addAll(collectPivotForRefs(pc, sourceAlias)); 10555 refs.addAll(collectPivotAggArgRefs(pc, sourceAlias)); 10556 return new ArrayList<>(refs); 10557 } 10558 10559 /** 10560 * Slice 130 — the FOR / pivot column ref(s) consumed by a PIVOT, built 10561 * directly against {@code sourceAlias} (deduped, document order). Split out 10562 * of {@link #collectPivotConsumedRefs} so the consumed-refs slot and the 10563 * output-column lineage share one source of truth. 10564 */ 10565 private static List<ColumnRef> collectPivotForRefs(TPivotClause pc, String sourceAlias) { 10566 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10567 TObjectNameList forCols = pc.getPivotColumnList(); 10568 if (forCols != null) { 10569 for (int i = 0; i < forCols.size(); i++) { 10570 addPivotConsumedRef(refs, forCols.getObjectName(i), sourceAlias); 10571 } 10572 } else if (pc.getPivotColumn() != null) { 10573 addPivotConsumedRef(refs, pc.getPivotColumn(), sourceAlias); 10574 } 10575 return new ArrayList<>(refs); 10576 } 10577 10578 /** 10579 * Slice 130 — the aggregation-function argument column ref(s) (the value 10580 * being aggregated), built directly against {@code sourceAlias} (deduped, 10581 * document order). SQL Server / Snowflake carry a single 10582 * {@code TFunctionCall}; Oracle a {@code TResultColumnList}. These are the 10583 * sources of a pivot-output column. Function names and column-alias nodes 10584 * are skipped by the {@code dbObjectType} filter in addPivotConsumedRef. 10585 */ 10586 private static List<ColumnRef> collectPivotAggArgRefs(TPivotClause pc, String sourceAlias) { 10587 final List<TObjectName> argCols = new ArrayList<>(); 10588 TParseTreeVisitor argVisitor = new TParseTreeVisitor() { 10589 @Override 10590 public void preVisit(TObjectName n) { 10591 argCols.add(n); 10592 } 10593 }; 10594 if (pc.getAggregation_function() != null 10595 && pc.getAggregation_function().getArgs() != null) { 10596 pc.getAggregation_function().getArgs().acceptChildren(argVisitor); 10597 } 10598 if (pc.getAggregation_function_list() != null) { 10599 pc.getAggregation_function_list().acceptChildren(argVisitor); 10600 } 10601 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10602 for (TObjectName n : argCols) { 10603 addPivotConsumedRef(refs, n, sourceAlias); 10604 } 10605 return new ArrayList<>(refs); 10606 } 10607 10608 /** 10609 * Slice 154 — the aggregation-argument column ref(s) for a SINGLE aggregation 10610 * in a multi-aggregation PIVOT's {@code aggregation_function_list} (the value 10611 * being aggregated by THIS one function), built directly against 10612 * {@code sourceAlias} (deduped, document order). This is the per-aggregation 10613 * counterpart of {@link #collectPivotAggArgRefs} (which unions ALL 10614 * aggregations' args): a multi-agg cross-product output column 10615 * {@code <in_value>_<agg_alias>} draws from ONLY the named aggregation's 10616 * argument, not every aggregation's. Function names / alias nodes are excluded 10617 * by the {@code dbObjectType == column} filter in {@link #addPivotConsumedRef}. 10618 */ 10619 private static List<ColumnRef> collectSingleAggArgRefs(TResultColumn aggRc, 10620 String sourceAlias) { 10621 final List<TObjectName> argCols = new ArrayList<>(); 10622 TParseTreeVisitor argVisitor = new TParseTreeVisitor() { 10623 @Override 10624 public void preVisit(TObjectName n) { 10625 argCols.add(n); 10626 } 10627 }; 10628 if (aggRc != null && aggRc.getExpr() != null 10629 && aggRc.getExpr().getFunctionCall() != null 10630 && aggRc.getExpr().getFunctionCall().getArgs() != null) { 10631 aggRc.getExpr().getFunctionCall().getArgs().acceptChildren(argVisitor); 10632 } 10633 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10634 for (TObjectName n : argCols) { 10635 addPivotConsumedRef(refs, n, sourceAlias); 10636 } 10637 return new ArrayList<>(refs); 10638 } 10639 10640 /** Slice 130 — number of aggregation functions in the PIVOT clause. */ 10641 private static int pivotAggregationCount(TPivotClause pc) { 10642 if (pc.getAggregation_function() != null) { 10643 return 1; 10644 } 10645 if (pc.getAggregation_function_list() != null) { 10646 return pc.getAggregation_function_list().size(); 10647 } 10648 return 0; 10649 } 10650 10651 /** 10652 * Slice 130 — true when the PIVOT IN-list is a STATIC value list, so the 10653 * pivot-output column names are known at build time. False for a subquery 10654 * or {@code ANY} dynamic IN-list (the parser leaves {@code getItems()} 10655 * null or empty), where output lineage stays deferred. 10656 */ 10657 private static boolean hasStaticInList(TPivotClause pc) { 10658 return pc.getPivotInClause() != null 10659 && pc.getPivotInClause().getItems() != null 10660 && pc.getPivotInClause().getItems().size() > 0; 10661 } 10662 10663 /** 10664 * Slice 130 — the set of pivot-output column names: the IN-list items' 10665 * display names ({@code [2006]} for SQL Server bracket values, the IN-list 10666 * alias such as {@code pa} / {@code jan} for Oracle / Snowflake). A 10667 * projected column whose underlying column name 10668 * ({@link TResultColumn#getColumnNameOnly()}) is in this set is a pivot 10669 * output (sourced from the aggregation argument); any other simple-column 10670 * projection is a passthrough (group) column. Caller must have confirmed a 10671 * static IN-list via {@link #hasStaticInList}. 10672 * 10673 * <p>Returns a {@link LinkedHashSet} so the names are in IN-list DOCUMENT 10674 * ORDER. Slice 130 only used this for {@code contains()} membership, but 10675 * slice 133's {@code SELECT *} expansion ({@link #expandPivotBareStar}) 10676 * relies on the ordered iteration to emit pivot-output columns in IN-list 10677 * order — so the ordered return type is part of the contract. 10678 */ 10679 private static LinkedHashSet<String> collectPivotOutputNames(TPivotClause pc) { 10680 LinkedHashSet<String> names = new LinkedHashSet<>(); 10681 TPivotInClause in = pc.getPivotInClause(); 10682 if (in != null && in.getItems() != null) { 10683 for (int i = 0; i < in.getItems().size(); i++) { 10684 TResultColumn item = in.getItems().getResultColumn(i); 10685 if (item == null) { 10686 continue; 10687 } 10688 String dn = item.getDisplayName(); 10689 if (dn != null && !dn.isEmpty()) { 10690 names.add(dn); 10691 } 10692 } 10693 } 10694 return names; 10695 } 10696 10697 /** 10698 * Slice 129 helper — add one consumed column ref to {@code refs} iff it is 10699 * an actual column (not a function name, column alias, or {@code *}). The 10700 * ref is attributed DIRECTLY to {@code sourceAlias}. 10701 * 10702 * <p>No source-table match guard: sub-slice (a) admits only a single 10703 * base-table PIVOT source, so every column consumed by the FOR clause or 10704 * an aggregation argument necessarily belongs to that one source relation 10705 * — there is no other relation to mis-attribute to, and resolver2 leaves 10706 * these refs NOT_FOUND in PIVOT context anyway. Guarding on 10707 * {@code sourceTable} identity / name would SILENTLY DROP a valid consumed 10708 * column when Phase-1 left {@code sourceTable} null or shaped its name 10709 * differently (alias-normalized / schema-qualified) — codex diff-review 10710 * round 1 BLOCKING. The {@code dbObjectType == column} filter already 10711 * excludes function names and column-alias nodes. 10712 */ 10713 private static void addPivotConsumedRef(LinkedHashSet<ColumnRef> refs, 10714 TObjectName ref, 10715 String sourceAlias) { 10716 if (ref == null || ref.getDbObjectType() != EDbObjectType.column) { 10717 return; 10718 } 10719 String col = ref.getColumnNameOnly(); 10720 if (col == null || col.isEmpty() || "*".equals(col)) { 10721 return; 10722 } 10723 refs.add(new ColumnRef(sourceAlias, col)); 10724 } 10725 10726 /** 10727 * Slice 129 / 130 — build output columns for a PIVOT SELECT. Each projected 10728 * result column yields one OutputColumn whose name is the projection's 10729 * effective name. A {@code SELECT *} / {@code t.*} over a PIVOT is deferred 10730 * (it needs the full synthesised output schema / a catalog). 10731 * 10732 * <p>Slice 130 sub-slice (b1) attaches output-column LINEAGE when the 10733 * output schema maps deterministically to source columns — i.e. a SINGLE 10734 * aggregation over a STATIC IN-list ({@code lineageEnabled}): 10735 * <ul> 10736 * <li><b>pivot-output column</b> — the projected column's underlying name 10737 * ({@link TResultColumn#getColumnNameOnly()}) is one of the IN-list 10738 * display names. It is the aggregate sliced by the FOR value, so 10739 * {@code derived=true}, {@code aggregate=true}, and sources are the 10740 * aggregation-argument column(s).</li> 10741 * <li><b>passthrough (group) column</b> — any other simple-column 10742 * projection. It is a direct reference to the source column, so 10743 * {@code derived=false}, {@code aggregate=false}, and the single 10744 * source is {@code sourceAlias.getColumnNameOnly()}. The output NAME 10745 * may be aliased ({@code vendorid AS v}) but the SOURCE column is the 10746 * underlying name.</li> 10747 * </ul> 10748 * Multi-aggregation (vendor-specific cross-product output names like 10749 * {@code pa_s}), a dynamic / subquery IN-list, and expression projections 10750 * keep the slice-129 NAME-only skeleton ({@code derived=true}, 10751 * {@code aggregate=false}, EMPTY sources) — no false lineage. 10752 * 10753 * <p>All refs are built DIRECTLY against {@code sourceAlias}: sub-slice (a) 10754 * admits only a single base-table source, so every passthrough / agg-arg 10755 * column belongs to that one relation, and resolver2 marks them NOT_FOUND 10756 * in PIVOT context anyway (slice 129). 10757 */ 10758 private static List<OutputColumn> buildPivotOutputColumns(TSelectSqlStatement select, 10759 TTable pivotTable, 10760 TPivotClause pc, 10761 String sourceAlias, 10762 TTable sourceTable, 10763 NameBindingProvider provider) { 10764 TResultColumnList rcl = select.getResultColumnList(); 10765 if (rcl == null || rcl.size() == 0) { 10766 throw new SemanticIRBuildException( 10767 Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS, 10768 "SELECT has no projected columns", select)); 10769 } 10770 boolean lineageEnabled = pivotAggregationCount(pc) == 1 && hasStaticInList(pc); 10771 Set<String> pivotOutputNames = lineageEnabled 10772 ? collectPivotOutputNames(pc) : Collections.<String>emptySet(); 10773 List<ColumnRef> aggArgRefs = lineageEnabled 10774 ? collectPivotAggArgRefs(pc, sourceAlias) : Collections.<ColumnRef>emptyList(); 10775 10776 // Slice 153 — a NON-lineage-enabled PIVOT (a multi-aggregation PIVOT or a 10777 // dynamic / subquery IN-list) still has determinable PASSTHROUGH (group) 10778 // column lineage: a passthrough column passes through UNCHANGED from the 10779 // source regardless of the aggregation count or IN-list shape. Slice 130 10780 // emitted every projected column of such a PIVOT as a NAME-only skeleton 10781 // (empty sources); this attaches the source lineage to the passthrough 10782 // columns only. The set below is the lower-cased SOURCE column names that 10783 // are NOT consumed by the PIVOT (the FOR / pivot column(s) and the 10784 // aggregation-argument column(s)); a projected simple-column whose name is 10785 // in it is a passthrough. This is keyed off PROVABLE source membership 10786 // (the source catalog or a FROM-subquery's published columns) so it never 10787 // invents a false source for a vendor-specific cross-product output column 10788 // (e.g. `y6_s`) — those names are not source columns. Without a source 10789 // catalog (a base table and no Catalog) the set is empty, so the behaviour 10790 // is unchanged. (The lineage-enabled single-agg path below keys off the 10791 // KNOWN IN-list output-name set instead — for multi-agg those names are 10792 // vendor-specific cross-products we do not enumerate, hence the 10793 // catalog-confirmation rule here.) 10794 // Slice 156 — factored into provablePivotPassthroughColsLC (shared with 10795 // the WHERE-clause admission). Computed only for a non-lineage-enabled 10796 // PIVOT: the lineage-enabled single-agg path below keys off the KNOWN 10797 // IN-list output-name set instead. 10798 Set<String> passthroughSourceColsLC = lineageEnabled 10799 ? Collections.<String>emptySet() 10800 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10801 10802 // Slice 154 — a MULTI-aggregation PIVOT over a STATIC IN-list produces one 10803 // CROSS-PRODUCT output column per (IN-value × aggregation), named 10804 // `<in_value_display_name>_<agg_alias>` (`y6_s`, `y6_a`). Slice 130 / 153 10805 // left these as NAME-only skeletons because the names are vendor-specific 10806 // and were not enumerated. Build the map `<in_value>_<agg> -> that one 10807 // aggregation's argument column ref(s)` so a projected cross-product column 10808 // carries the SPECIFIC aggregation's lineage (NOT the union of all agg 10809 // args). Unlike the slice-153 passthrough rule, this needs NO source 10810 // catalog: the IN-value names and each aggregation's argument are fully 10811 // determined by the PIVOT clause itself (so a base table without a catalog 10812 // resolves the same as a subquery source — mirroring the lineage-enabled 10813 // single-agg path, which also reads the agg arg straight from the clause). 10814 // Keys are lower-cased (Locale.ROOT) to match the project's identifier 10815 // folding. A key produced by two (IN-value, agg) pairs with DIFFERENT 10816 // sources is AMBIGUOUS (e.g. degenerate names whose `_` concatenation 10817 // collides) and is dropped so it never attaches a wrong source. 10818 Map<String, List<ColumnRef>> crossProductSourcesLC = Collections.emptyMap(); 10819 Set<String> ambiguousCrossProductLC = Collections.emptySet(); 10820 if (!lineageEnabled && hasStaticInList(pc) 10821 && pc.getAggregation_function_list() != null 10822 && pc.getAggregation_function_list().size() > 1) { 10823 Map<String, List<ColumnRef>> xpMap = new HashMap<>(); 10824 Set<String> xpAmbiguous = new HashSet<>(); 10825 TResultColumnList aggList = pc.getAggregation_function_list(); 10826 for (String inVal : collectPivotOutputNames(pc)) { 10827 if (inVal == null || inVal.isEmpty()) { 10828 continue; 10829 } 10830 for (int ai = 0; ai < aggList.size(); ai++) { 10831 TResultColumn aggRc = aggList.getResultColumn(ai); 10832 if (aggRc == null) { 10833 continue; 10834 } 10835 String aggAlias = aggRc.getDisplayName(); 10836 if (aggAlias == null || aggAlias.isEmpty()) { 10837 continue; 10838 } 10839 String key = (inVal + "_" + aggAlias).toLowerCase(Locale.ROOT); 10840 List<ColumnRef> argRefs = collectSingleAggArgRefs(aggRc, sourceAlias); 10841 if (xpMap.containsKey(key)) { 10842 if (!xpMap.get(key).equals(argRefs)) { 10843 xpAmbiguous.add(key); 10844 } 10845 } else { 10846 xpMap.put(key, argRefs); 10847 } 10848 } 10849 } 10850 crossProductSourcesLC = xpMap; 10851 ambiguousCrossProductLC = xpAmbiguous; 10852 } 10853 10854 // Slice 133 (sub-slice (b2-pivot)) — a SOLE `SELECT *` over a 10855 // lineage-enabled PIVOT is expanded from the source table's catalog 10856 // column list into the synthesised output schema (group / passthrough 10857 // columns then pivot-output columns). A `*` mixed with explicit columns 10858 // and the non-lineage-enabled (multi-agg / dynamic-IN) `*` stay deferred 10859 // via the per-column reject below. 10860 // 10861 // Slice 151 — the SOLE star is now admitted whether it is BARE (`*`) or 10862 // QUALIFIED (`p.*` / `src.*`). An admitted PIVOT SELECT has exactly ONE 10863 // relation in scope — the pivot output (isPivotSelect requires a 10864 // FROM-PIVOT with no JOIN) — so a qualified star can only mean that one 10865 // relation and is therefore equivalent to a bare `*`; expandPivotBareStar 10866 // synthesises the identical schema (it never reads the star's qualifier, 10867 // only the source columns and the pivot clause). Slice 152 dropped the 10868 // mirror gate on the UNPIVOT bare-star path below. 10869 if (lineageEnabled && rcl.size() == 1) { 10870 TResultColumn only = rcl.getResultColumn(0); 10871 if ("*".equals(only.getColumnNameOnly())) { 10872 // Slice 137 lifted the slice-136 subquery-source deferral. A bare 10873 // SELECT * over a FROM-subquery PIVOT now expands the same way as a 10874 // base-table PIVOT: expandPivotBareStar reads the source's published 10875 // columns via lookupRelationColumnNames, which the slice-137 10876 // addRelationToInScopeMap unwrap registers under the subquery alias. 10877 // No catalog is required for a subquery source — the subquery's 10878 // explicit projection IS the published schema. 10879 return expandPivotBareStar(pc, sourceTable, sourceAlias, provider, 10880 aggArgRefs, only); 10881 } 10882 } 10883 10884 // Slice 155 — admit a SOLE bare/qualified `*` over a MULTI-aggregation 10885 // static-IN PIVOT. Slice 130 / 133 deferred this (PIVOT_STAR_NOT_SUPPORTED) 10886 // because the cross-product output names were not enumerated; slice 154 10887 // enumerated them (`<in_value>_<agg_alias>` from the IN-list display names 10888 // and each aggregation's alias), so the full output schema is now 10889 // synthesisable: the passthrough (group) columns followed by the 10890 // cross-product output columns. (A dynamic / subquery IN-list multi-agg 10891 // bare `*` keeps the deferral via the per-column reject below — its 10892 // cross-product names are unknown. A single-agg static-IN bare `*` is 10893 // handled by the lineage-enabled block above.) 10894 if (!lineageEnabled && hasStaticInList(pc) 10895 && pc.getAggregation_function_list() != null 10896 && pc.getAggregation_function_list().size() > 1 10897 && rcl.size() == 1) { 10898 TResultColumn only = rcl.getResultColumn(0); 10899 if ("*".equals(only.getColumnNameOnly())) { 10900 return expandPivotMultiAggBareStar(pc, sourceTable, sourceAlias, 10901 provider, only); 10902 } 10903 } 10904 10905 List<OutputColumn> outs = new ArrayList<>(); 10906 for (int i = 0; i < rcl.size(); i++) { 10907 TResultColumn rc = rcl.getResultColumn(i); 10908 if ("*".equals(rc.getColumnNameOnly())) { 10909 throw new SemanticIRBuildException( 10910 Diagnostic.error(DiagnosticCode.PIVOT_STAR_NOT_SUPPORTED, 10911 "SELECT * over a PIVOT is not supported yet " 10912 + "(the synthesised output schema is deferred); " 10913 + "project explicit columns", rc)); 10914 } 10915 // effectiveOutputName throws RESULT_COLUMN_NO_NAME for an 10916 // unaliased non-name expression — acceptable deferral. 10917 String outName = effectiveOutputName(rc); 10918 if (!lineageEnabled) { 10919 // A non-lineage-enabled PIVOT (multi-agg, or dynamic / subquery 10920 // IN-list). A simple-column projection can be one of two resolvable 10921 // shapes; everything else (expressions) keeps the skeleton. 10922 String colName = rc.getColumnNameOnly(); 10923 boolean simpleCol = colName != null && !colName.isEmpty() 10924 && rc.getExpr() != null 10925 && rc.getExpr().getExpressionType() 10926 == EExpressionType.simple_object_name_t; 10927 String colNameLC = simpleCol ? colName.toLowerCase(Locale.ROOT) : null; 10928 if (simpleCol && passthroughSourceColsLC.contains(colNameLC)) { 10929 // Slice 153 — passthrough (group) column: a simple source 10930 // column proven NOT consumed by the PIVOT, passes through 10931 // unchanged. (Disjoint from a cross-product name: PIVOT 10932 // semantics forbid an IN-value naming an existing source 10933 // column, so `<in>_<agg>` is never a source column.) 10934 outs.add(new OutputColumn(outName, /*derived=*/ false, 10935 /*aggregate=*/ false, 10936 Collections.singletonList(new ColumnRef(sourceAlias, colName)), 10937 /*windowSpec=*/ null)); 10938 } else if (simpleCol && crossProductSourcesLC.containsKey(colNameLC) 10939 && !ambiguousCrossProductLC.contains(colNameLC)) { 10940 // Slice 154 — multi-agg cross-product pivot-output column: 10941 // the aggregate of its SPECIFIC aggregation sliced by the FOR 10942 // value (derived + aggregate, sourced from that one 10943 // aggregation's argument column(s)). 10944 outs.add(new OutputColumn(outName, /*derived=*/ true, 10945 /*aggregate=*/ true, 10946 crossProductSourcesLC.get(colNameLC), 10947 /*windowSpec=*/ null)); 10948 } else { 10949 outs.add(pivotSkeletonOutput(outName)); 10950 } 10951 continue; 10952 } 10953 String colOnly = rc.getColumnNameOnly(); 10954 // Name-collision is a non-issue here: PIVOT semantics forbid an 10955 // IN-list value from naming a column that already exists in the 10956 // input source (see TPivotClause), so an IN-list display name can 10957 // never coincide with a real passthrough source column. 10958 if (colOnly != null && !colOnly.isEmpty() && pivotOutputNames.contains(colOnly)) { 10959 // Pivot-output column: aggregate over the value column, sliced 10960 // by the FOR value. Sources = aggregation-arg column(s). 10961 outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ true, 10962 aggArgRefs, /*windowSpec=*/ null)); 10963 } else if (colOnly != null && !colOnly.isEmpty() 10964 && rc.getExpr() != null 10965 && rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t) { 10966 // Passthrough (group) column: direct reference to the source 10967 // column (the underlying name, not the output alias). 10968 outs.add(new OutputColumn(outName, /*derived=*/ false, /*aggregate=*/ false, 10969 Collections.singletonList(new ColumnRef(sourceAlias, colOnly)), 10970 /*windowSpec=*/ null)); 10971 } else { 10972 // Expression projection (or no underlying column) — keep skeleton. 10973 outs.add(pivotSkeletonOutput(outName)); 10974 } 10975 } 10976 return outs; 10977 } 10978 10979 /** 10980 * Slice 133 (sub-slice (b2-pivot)) — synthesise the full output schema for a 10981 * bare {@code SELECT *} over a lineage-enabled (single-agg / static-IN) 10982 * PIVOT, using the source table's catalog column list: 10983 * <ol> 10984 * <li><b>group / passthrough columns</b> — every catalog column that is 10985 * NOT consumed by the PIVOT (i.e. not the FOR / pivot column(s) and not 10986 * an aggregation-argument column), in CATALOG declaration order. Each is 10987 * a direct source reference ({@code derived=false}, 10988 * {@code aggregate=false}, single source {@code sourceAlias.col}).</li> 10989 * <li><b>pivot-output columns</b> — one per IN-list value 10990 * ({@link #collectPivotOutputNames}, IN-list order). Each is the 10991 * aggregate sliced by the FOR value ({@code derived=true}, 10992 * {@code aggregate=true}, sources = the aggregation-argument column(s)).</li> 10993 * </ol> 10994 * This is the SQL-standard {@code SELECT *} PIVOT shape (SQL Server / Oracle / 10995 * Snowflake): the implicit GROUP BY columns first, then the generated pivot 10996 * columns. (There is no dlineage {@code SELECT *} golden to match: dlineage 10997 * cannot expand {@code *} without a catalog either.) 10998 * 10999 * <p>The consumed-column subtraction folds names with 11000 * {@code toLowerCase(Locale.ROOT)} — the SAME identifier canonicalisation the 11001 * other semantic-IR catalog matchers use ({@link #expandBareStarOverUsing}, 11002 * {@link #lookupRelationColumnNames}); so it is aligned with the project's 11003 * catalog name folding. (A degenerate catalog holding two columns that differ 11004 * only by case would have both excluded by a single consumed name — an 11005 * inherently ambiguous catalog, not a realistic PIVOT source.) 11006 * 11007 * <p>Throws {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED} when no catalog 11008 * column list is available (mirrors slice-90 11009 * {@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED}). 11010 */ 11011 private static List<OutputColumn> expandPivotBareStar(TPivotClause pc, 11012 TTable sourceTable, 11013 String sourceAlias, 11014 NameBindingProvider provider, 11015 List<ColumnRef> aggArgRefs, 11016 TResultColumn star) { 11017 List<String> cols = lookupRelationColumnNames(sourceTable, provider); 11018 if (cols == null || cols.isEmpty()) { 11019 throw new SemanticIRBuildException( 11020 Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED, 11021 "SELECT * over a PIVOT requires catalog metadata for source '" 11022 + sourceAlias + "' to expand the implicit group columns; " 11023 + "supply a Catalog via " 11024 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star)); 11025 } 11026 // Columns consumed by the PIVOT (FOR / pivot column(s) + aggregation-arg 11027 // column(s)); these are NOT passthrough group columns. Folded to lower 11028 // case (Locale.ROOT) to match the catalog identifier canonicalisation 11029 // used by the other semantic-IR catalog matchers (expandBareStarOverUsing, 11030 // lookupRelationColumnNames). addPivotConsumedRef already filters out 11031 // null / empty column names, so getColumnName() is non-null here; the 11032 // guard below is defensive. 11033 Set<String> consumedLC = new HashSet<>(); 11034 List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias)); 11035 consumed.addAll(aggArgRefs); 11036 for (ColumnRef r : consumed) { 11037 String cn = (r == null) ? null : r.getColumnName(); 11038 if (cn != null && !cn.isEmpty()) { 11039 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 11040 } 11041 } 11042 List<OutputColumn> outs = new ArrayList<>(); 11043 // 1. group / passthrough columns (catalog order, minus consumed). 11044 for (String c : cols) { 11045 if (c == null || c.isEmpty()) { 11046 continue; 11047 } 11048 if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) { 11049 continue; 11050 } 11051 outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false, 11052 Collections.singletonList(new ColumnRef(sourceAlias, c)), 11053 /*windowSpec=*/ null)); 11054 } 11055 // 2. pivot-output columns (IN-list document order — collectPivotOutputNames 11056 // returns a LinkedHashSet whose order is the IN-list order). 11057 for (String name : collectPivotOutputNames(pc)) { 11058 outs.add(new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ true, 11059 aggArgRefs, /*windowSpec=*/ null)); 11060 } 11061 return outs; 11062 } 11063 11064 /** 11065 * Slice 155 — synthesise the full output schema for a bare {@code SELECT *} 11066 * over a MULTI-aggregation static-IN PIVOT: 11067 * <ol> 11068 * <li><b>passthrough / group columns</b> — every published source column 11069 * NOT consumed by the PIVOT (the FOR / pivot column(s) and EVERY 11070 * aggregation-argument column), in catalog / published declaration 11071 * order. Each is a direct source reference ({@code derived=false}, 11072 * {@code aggregate=false}).</li> 11073 * <li><b>cross-product output columns</b> — one per (IN-value × aggregation) 11074 * named {@code <in_value_display_name>_<agg_alias>}, in 11075 * IN-value-outer / aggregation-inner order (the SQL-standard / Oracle 11076 * column order). Each is {@code derived=true}, {@code aggregate=true}, 11077 * sourced from that ONE aggregation's argument column(s).</li> 11078 * </ol> 11079 * Like {@link #expandPivotBareStar}, the passthrough expansion needs a source 11080 * catalog (or a FROM-subquery's published columns) — otherwise 11081 * {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED}. The cross-product part, 11082 * by contrast, is fully clause-determined (mirroring slice 154's explicit 11083 * cross-product lineage), so it needs no catalog. Caller has confirmed a 11084 * static IN-list and a multi-aggregation PIVOT. 11085 */ 11086 private static List<OutputColumn> expandPivotMultiAggBareStar(TPivotClause pc, 11087 TTable sourceTable, 11088 String sourceAlias, 11089 NameBindingProvider provider, 11090 TResultColumn star) { 11091 List<String> cols = lookupRelationColumnNames(sourceTable, provider); 11092 if (cols == null || cols.isEmpty()) { 11093 throw new SemanticIRBuildException( 11094 Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED, 11095 "SELECT * over a PIVOT requires catalog metadata for source '" 11096 + sourceAlias + "' to expand the implicit group columns; " 11097 + "supply a Catalog via " 11098 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star)); 11099 } 11100 // Columns consumed by the PIVOT (FOR / pivot column(s) + EVERY 11101 // aggregation-argument column); these are NOT passthrough group columns. 11102 // Folded to lower case (Locale.ROOT) to match the catalog identifier 11103 // canonicalisation used by the other semantic-IR catalog matchers. 11104 Set<String> consumedLC = new HashSet<>(); 11105 List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias)); 11106 consumed.addAll(collectPivotAggArgRefs(pc, sourceAlias)); 11107 for (ColumnRef r : consumed) { 11108 String cn = (r == null) ? null : r.getColumnName(); 11109 if (cn != null && !cn.isEmpty()) { 11110 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 11111 } 11112 } 11113 List<OutputColumn> outs = new ArrayList<>(); 11114 // 1. passthrough / group columns (published order, minus consumed). 11115 for (String c : cols) { 11116 if (c == null || c.isEmpty()) { 11117 continue; 11118 } 11119 if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) { 11120 continue; 11121 } 11122 outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false, 11123 Collections.singletonList(new ColumnRef(sourceAlias, c)), 11124 /*windowSpec=*/ null)); 11125 } 11126 // 2. cross-product output columns — IN-value outer (IN-list document 11127 // order), aggregation inner (declaration order). Each traces to its 11128 // OWN aggregation's argument column(s), not the union of all aggs. 11129 TResultColumnList aggList = pc.getAggregation_function_list(); 11130 for (String inVal : collectPivotOutputNames(pc)) { 11131 if (inVal == null || inVal.isEmpty()) { 11132 continue; 11133 } 11134 for (int ai = 0; ai < aggList.size(); ai++) { 11135 TResultColumn aggRc = aggList.getResultColumn(ai); 11136 if (aggRc == null) { 11137 continue; 11138 } 11139 String aggAlias = aggRc.getDisplayName(); 11140 if (aggAlias == null || aggAlias.isEmpty()) { 11141 continue; 11142 } 11143 outs.add(new OutputColumn(inVal + "_" + aggAlias, 11144 /*derived=*/ true, /*aggregate=*/ true, 11145 collectSingleAggArgRefs(aggRc, sourceAlias), 11146 /*windowSpec=*/ null)); 11147 } 11148 } 11149 return outs; 11150 } 11151 11152 /** 11153 * Slice 129 / 130 — a NAME-only deferred PIVOT output column ({@code 11154 * derived=true}, {@code aggregate=false}, EMPTY sources). Used for the 11155 * multi-aggregation / dynamic-IN / expression cases where output lineage 11156 * cannot be attributed without risking a false source. 11157 */ 11158 private static OutputColumn pivotSkeletonOutput(String name) { 11159 return new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ false, 11160 Collections.<ColumnRef>emptyList(), /*windowSpec=*/ null); 11161 } 11162 11163 // ----------------------------------------------------------------- 11164 // Slice 131 — PIVOT sub-slice (c): UNPIVOT output-column lineage. 11165 // 11166 // UNPIVOT narrows several source columns into rows. The clause carries: 11167 // getValueColumnList() — the NEW value column(s) holding the unpivoted 11168 // values (e.g. `orders`); 11169 // getPivotColumnList() — the NEW FOR/name column holding the literal 11170 // NAMES of the unpivoted columns (e.g. `employee`); 11171 // getUnpivotInClause().getItems() — the narrowed SOURCE columns 11172 // (`emp1`, `emp2`, ...), each `getColumn()` a 11173 // base-table column with a Phase-1 sourceTable. 11174 // The value and FOR column names cannot name an existing input column 11175 // (PIVOT semantics, TPivotClause javadoc) — so they never collide with a 11176 // real passthrough source column. 11177 // 11178 // Sub-slice (c) admits the deterministic shape: a SINGLE value column over a 11179 // simple (non-column-group) IN-list. Output-column lineage mirrors the 11180 // authoritative dlineage UNPIVOT handler — each IN-list source column feeds 11181 // BOTH the value column and the FOR column. A multi-value-column or 11182 // column-group UNPIVOT stays deferred (PIVOT_UNPIVOT_NOT_SUPPORTED). 11183 // ----------------------------------------------------------------- 11184 11185 /** 11186 * Slice 131 / 132 — validate the UNPIVOT shape, deferring (reject with 11187 * {@code PIVOT_UNPIVOT_NOT_SUPPORTED}, code kept reached) the shapes whose 11188 * value↔source mapping is not deterministic. 11189 * 11190 * <p>Slice 131 admitted ONLY the single-value-column / simple-IN shape. 11191 * Slice 132 (sub-slice (c2)) generalises this to the well-formed 11192 * multi-value-column / column-group shape (Oracle / Redshift) — e.g. 11193 * {@code UNPIVOT ((s1, s2) FOR yr IN ((a1, a2) AS 'Y1', (b1, b2) AS 'Y2'))}. 11194 * The admit rule is a single width invariant: every IN-list item must have 11195 * EXACTLY one source column per value column ({@code itemWidth == valueCount}), 11196 * so each value column maps positionally to one group position. 11197 * 11198 * <p>The malformed / mixed shapes that stay deferred (and keep the code 11199 * reached): no value column; a group whose width differs from the value 11200 * count (e.g. {@code ((s1, s2) FOR yr IN ((a1, a2, a3) ...))}); a single 11201 * column item under a multi-value UNPIVOT; or a column-group item under a 11202 * single-value UNPIVOT. The harder positional mapping for those is left for 11203 * a later sub-slice. 11204 */ 11205 private static void rejectUnsupportedUnpivotShape(TPivotClause pc, TTable pivotTable) { 11206 TObjectNameList valueCols = pc.getValueColumnList(); 11207 if (valueCols == null || valueCols.size() < 1) { 11208 throw new SemanticIRBuildException( 11209 Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED, 11210 "UNPIVOT without a value column is not supported yet", pivotTable)); 11211 } 11212 int valueCount = valueCols.size(); 11213 TUnpivotInClause in = pc.getUnpivotInClause(); 11214 if (in != null && in.getItems() != null) { 11215 for (int i = 0; i < in.getItems().size(); i++) { 11216 TUnpivotInClauseItem item = in.getItems().getElement(i); 11217 if (item == null) { 11218 continue; 11219 } 11220 int itemWidth; 11221 if (item.getColumn() != null) { 11222 itemWidth = 1; 11223 } else if (item.getColumnList() != null) { 11224 itemWidth = item.getColumnList().size(); 11225 } else { 11226 itemWidth = 0; 11227 } 11228 if (itemWidth != valueCount) { 11229 throw new SemanticIRBuildException( 11230 Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED, 11231 "UNPIVOT IN-list item width does not match the value-column " 11232 + "count (each IN-list group must have exactly " 11233 + valueCount + " column(s) for the positional value " 11234 + "mapping); this shape is not supported yet", pivotTable)); 11235 } 11236 } 11237 } 11238 } 11239 11240 /** 11241 * Slice 131 / 132 — ALL columns CONSUMED by an UNPIVOT: the IN-list SOURCE 11242 * columns being narrowed (NOT the new value / FOR columns), in document 11243 * order, deduped. Built DIRECTLY against {@code sourceAlias} via the shared 11244 * {@link #addPivotConsumedRef} filter (resolver2 NOT_FOUND-in-pivot quirk, 11245 * slice 129). This is also the source list for the FOR output column (the 11246 * FOR values are the literal labels of the narrowed columns, attributed to 11247 * every source column for slice-131 / dlineage parity). 11248 * 11249 * <p>Slice 132 extends slice 131 to walk column-group items: a multi-value 11250 * UNPIVOT IN-list item is a column GROUP ({@code getColumn() == null}, 11251 * {@code getColumnList()} populated), so the group columns are flattened 11252 * into this list in document order. 11253 * 11254 * <p>Note: this does NOT reuse {@link #collectPivotConsumedRefs}, which reads 11255 * {@code getPivotColumnList()} — for an UNPIVOT that is the NEW FOR column, 11256 * not a source column, and would be wrongly captured as a consumed ref. 11257 */ 11258 private static List<ColumnRef> collectUnpivotInListRefs(TPivotClause pc, 11259 String sourceAlias) { 11260 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11261 TUnpivotInClause in = pc.getUnpivotInClause(); 11262 if (in != null && in.getItems() != null) { 11263 for (int i = 0; i < in.getItems().size(); i++) { 11264 TUnpivotInClauseItem item = in.getItems().getElement(i); 11265 if (item == null) { 11266 continue; 11267 } 11268 if (item.getColumn() != null) { 11269 addPivotConsumedRef(refs, item.getColumn(), sourceAlias); 11270 } else if (item.getColumnList() != null) { 11271 TObjectNameList group = item.getColumnList(); 11272 for (int k = 0; k < group.size(); k++) { 11273 addPivotConsumedRef(refs, group.getObjectName(k), sourceAlias); 11274 } 11275 } 11276 } 11277 } 11278 return new ArrayList<>(refs); 11279 } 11280 11281 /** 11282 * Slice 132 — the source column ref(s) for the value column at 11283 * {@code position} (positional mapping per roadmap §13): for each IN-list 11284 * item, the group column at {@code position} (or {@code getColumn()} for a 11285 * single-column item when {@code position == 0}). Document order, deduped, 11286 * built directly against {@code sourceAlias}. 11287 * 11288 * <p>This unifies the single-value and multi-value cases: for a single-value 11289 * UNPIVOT each item is a single column and {@code position} is always 0, so 11290 * this returns ALL IN-list source columns — byte-identical to the slice-131 11291 * value-column lineage. For a multi-value UNPIVOT, value column {@code s_k} 11292 * at position {@code k} draws only from group position {@code k} 11293 * ({@code s1 <- a1, b1}; {@code s2 <- a2, b2}), which is more precise than 11294 * the authoritative dlineage handler (it feeds every source column into 11295 * every output column). The shape gate 11296 * ({@link #rejectUnsupportedUnpivotShape}) guarantees every item width 11297 * equals the value count, so {@code position} is always in range; the bounds 11298 * check is defensive. 11299 */ 11300 private static List<ColumnRef> collectUnpivotValueColumnRefs(TPivotClause pc, 11301 String sourceAlias, 11302 int position) { 11303 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11304 TUnpivotInClause in = pc.getUnpivotInClause(); 11305 if (in != null && in.getItems() != null) { 11306 for (int i = 0; i < in.getItems().size(); i++) { 11307 TUnpivotInClauseItem item = in.getItems().getElement(i); 11308 if (item == null) { 11309 continue; 11310 } 11311 TObjectName col = null; 11312 if (item.getColumn() != null) { 11313 if (position == 0) { 11314 col = item.getColumn(); 11315 } 11316 } else if (item.getColumnList() != null 11317 && position < item.getColumnList().size()) { 11318 col = item.getColumnList().getObjectName(position); 11319 } 11320 addPivotConsumedRef(refs, col, sourceAlias); 11321 } 11322 } 11323 return new ArrayList<>(refs); 11324 } 11325 11326 /** 11327 * Slice 132 — map each UNPIVOT value-column NAME to its position in the 11328 * value-column list, so a projected column can be matched to its positional 11329 * source group. First occurrence wins on a (degenerate) duplicate name. 11330 */ 11331 private static Map<String, Integer> unpivotValueColumnPositions(TPivotClause pc) { 11332 Map<String, Integer> positions = new HashMap<>(); 11333 TObjectNameList vcl = pc.getValueColumnList(); 11334 if (vcl != null) { 11335 for (int i = 0; i < vcl.size(); i++) { 11336 TObjectName n = vcl.getObjectName(i); 11337 if (n == null) { 11338 continue; 11339 } 11340 String name = n.getColumnNameOnly(); 11341 if (name != null && !name.isEmpty() && !positions.containsKey(name)) { 11342 positions.put(name, i); 11343 } 11344 } 11345 } 11346 return positions; 11347 } 11348 11349 /** Slice 131 — the UNPIVOT FOR/name-column name, or null. */ 11350 private static String unpivotForColumnName(TPivotClause pc) { 11351 TObjectNameList pcl = pc.getPivotColumnList(); 11352 if (pcl != null && pcl.size() >= 1 && pcl.getObjectName(0) != null) { 11353 return pcl.getObjectName(0).getColumnNameOnly(); 11354 } 11355 if (pc.getPivotColumn() != null) { 11356 return pc.getPivotColumn().getColumnNameOnly(); 11357 } 11358 return null; 11359 } 11360 11361 /** 11362 * Slice 131 — build output columns for an UNPIVOT SELECT (the deterministic 11363 * single-value / simple-IN shape; multi-value / column-group already 11364 * deferred by {@link #rejectUnsupportedUnpivotShape}). Each projected result 11365 * column is matched by its underlying name ({@link TResultColumn#getColumnNameOnly()}, 11366 * NOT the output alias — mirrors slice 130): 11367 * <ul> 11368 * <li><b>value column</b> — underlying name == a value-column name. Holds 11369 * the unpivoted values, so {@code derived=true}, {@code aggregate=false}. 11370 * Sources are POSITIONAL (slice 132): value column at position {@code k} 11371 * draws from group position {@code k} across all IN-list items 11372 * ({@code s1 <- a1, b1}; {@code s2 <- a2, b2}). For a single-value 11373 * UNPIVOT this collapses to ALL IN-list source columns (slice 131).</li> 11374 * <li><b>FOR/name column</b> — underlying name == the FOR-column name. Holds 11375 * the literal NAMES / labels of the unpivoted columns; attributed to ALL 11376 * IN-list source columns (slice-131 / dlineage parity — both halves of a 11377 * single-value UNPIVOT trace to every narrowed source column). 11378 * {@code derived=true}, {@code aggregate=false}.</li> 11379 * <li><b>passthrough column</b> — any other {@code simple_object_name_t} 11380 * projection. Direct reference to the source column: 11381 * {@code derived=false}, {@code aggregate=false}, single source 11382 * {@code sourceAlias.colName} (underlying name, alias respected in the 11383 * output NAME).</li> 11384 * <li><b>expression projection</b> — kept as a NAME-only skeleton 11385 * ({@link #pivotSkeletonOutput}); no single source column.</li> 11386 * </ul> 11387 * Value / FOR matching is exact (mirrors slice 130). No name-collision risk: 11388 * PIVOT/UNPIVOT semantics forbid the value / FOR column from naming an 11389 * existing input column, so a passthrough source column can never coincide 11390 * with the value / FOR name. All refs are built DIRECTLY against 11391 * {@code sourceAlias} (single base-table source; resolver2 NOT_FOUND quirk). 11392 */ 11393 private static List<OutputColumn> buildUnpivotOutputColumns(TSelectSqlStatement select, 11394 TTable pivotTable, 11395 TPivotClause pc, 11396 String sourceAlias, 11397 TTable sourceTable, 11398 NameBindingProvider provider) { 11399 TResultColumnList rcl = select.getResultColumnList(); 11400 if (rcl == null || rcl.size() == 0) { 11401 throw new SemanticIRBuildException( 11402 Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS, 11403 "SELECT has no projected columns", select)); 11404 } 11405 11406 // Slice 134 / 135 (sub-slice (b2-unpivot)) — a SOLE bare `SELECT *` over a 11407 // well-formed UNPIVOT (single-value, slice 131; or multi-value-column / 11408 // column-group, slice 132 — both already validated by the 11409 // rejectUnsupportedUnpivotShape width invariant run earlier in 11410 // buildPivotSelect) is expanded from the source table's catalog column list 11411 // into the synthesised output schema (passthrough columns then the FOR/name 11412 // column then the value column(s)). Slice 135 lifted the slice-134 11413 // valueCount==1 gate to valueCount>=1 so the multi-value-column case is 11414 // admitted too. 11415 // 11416 // Slice 152 — the SOLE star is now admitted whether it is BARE (`*`) or 11417 // QUALIFIED (`u.*` / `src.*`), mirroring slice 151 on the PIVOT path. An 11418 // admitted UNPIVOT SELECT has exactly ONE relation in scope — the unpivot 11419 // output (isPivotSelect requires a FROM-UNPIVOT with no JOIN) — so a 11420 // qualified star can only mean that one relation and is therefore 11421 // equivalent to a bare `*`; expandUnpivotBareStar synthesises the identical 11422 // schema (it never reads the star's qualifier, only the source columns and 11423 // the unpivot clause). The isBareStar gate is dropped here. A `*` mixed 11424 // with explicit columns (rcl.size() > 1) still defers via the per-column 11425 // reject below (PIVOT_STAR_NOT_SUPPORTED kept reached). 11426 TObjectNameList valueCols = pc.getValueColumnList(); 11427 if (rcl.size() == 1 && valueCols != null && valueCols.size() >= 1) { 11428 TResultColumn only = rcl.getResultColumn(0); 11429 if ("*".equals(only.getColumnNameOnly())) { 11430 // Slice 137 lifted the slice-136 subquery-source deferral. A bare 11431 // SELECT * over a FROM-subquery UNPIVOT now expands the same way as 11432 // a base-table UNPIVOT: expandUnpivotBareStar reads the source's 11433 // published columns via lookupRelationColumnNames, which the 11434 // slice-137 addRelationToInScopeMap unwrap registers under the 11435 // subquery alias. No catalog is required for a subquery source — 11436 // the subquery's explicit projection IS the published schema. 11437 return expandUnpivotBareStar(pc, sourceTable, sourceAlias, provider, only); 11438 } 11439 } 11440 11441 Map<String, Integer> valuePositions = unpivotValueColumnPositions(pc); 11442 String forName = unpivotForColumnName(pc); 11443 List<ColumnRef> inListRefs = collectUnpivotInListRefs(pc, sourceAlias); 11444 11445 List<OutputColumn> outs = new ArrayList<>(); 11446 for (int i = 0; i < rcl.size(); i++) { 11447 TResultColumn rc = rcl.getResultColumn(i); 11448 if ("*".equals(rc.getColumnNameOnly())) { 11449 throw new SemanticIRBuildException( 11450 Diagnostic.error(DiagnosticCode.PIVOT_STAR_NOT_SUPPORTED, 11451 "SELECT * over an UNPIVOT is not supported yet " 11452 + "(the synthesised output schema is deferred); " 11453 + "project explicit columns", rc)); 11454 } 11455 String outName = effectiveOutputName(rc); 11456 String colOnly = rc.getColumnNameOnly(); 11457 Integer valuePos = (colOnly == null || colOnly.isEmpty()) 11458 ? null : valuePositions.get(colOnly); 11459 if (valuePos != null) { 11460 // Value column (the unpivoted values): POSITIONAL sources — the 11461 // group column at this value column's position across every 11462 // IN-list item. 11463 outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ false, 11464 collectUnpivotValueColumnRefs(pc, sourceAlias, valuePos), 11465 /*windowSpec=*/ null)); 11466 } else if (colOnly != null && !colOnly.isEmpty() 11467 && colOnly.equals(forName)) { 11468 // FOR/name column (the source column NAMES / labels): traces to 11469 // every narrowed IN-list source column (dlineage parity). 11470 outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ false, 11471 inListRefs, /*windowSpec=*/ null)); 11472 } else if (colOnly != null && !colOnly.isEmpty() 11473 && rc.getExpr() != null 11474 && rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t) { 11475 // Passthrough column: direct reference to the source column. 11476 outs.add(new OutputColumn(outName, /*derived=*/ false, /*aggregate=*/ false, 11477 Collections.singletonList(new ColumnRef(sourceAlias, colOnly)), 11478 /*windowSpec=*/ null)); 11479 } else { 11480 // Expression projection (or no underlying column) — keep skeleton. 11481 outs.add(pivotSkeletonOutput(outName)); 11482 } 11483 } 11484 return outs; 11485 } 11486 11487 /** 11488 * Slice 134 / 135 (sub-slice (b2-unpivot)) — synthesise the full output schema 11489 * for a bare {@code SELECT *} over a well-formed UNPIVOT (single-value, slice 11490 * 131; or multi-value-column / column-group, slice 132 — slice 135 lifted the 11491 * value-count gate), using the source table's catalog column list: 11492 * <ol> 11493 * <li><b>passthrough columns</b> — every catalog column that is NOT a 11494 * narrowed IN-list SOURCE column ({@link #collectUnpivotInListRefs}), in 11495 * CATALOG declaration order. Each is a direct source reference 11496 * ({@code derived=false}, {@code aggregate=false}, single source 11497 * {@code sourceAlias.col}).</li> 11498 * <li><b>FOR/name column</b> — {@link #unpivotForColumnName}. Holds the 11499 * literal NAMES of the unpivoted columns; traces to ALL IN-list source 11500 * columns ({@code derived=true}, {@code aggregate=false}).</li> 11501 * <li><b>value column(s)</b> — every {@code getValueColumnList()} entry, in 11502 * declaration order. Holds the unpivoted values; the value column at 11503 * position {@code k} draws POSITIONALLY from group position {@code k} 11504 * across every IN-list item ({@link #collectUnpivotValueColumnRefs}, the 11505 * slice-132 mechanism): {@code s1 <- a1, b1}; {@code s2 <- a2, b2}. For a 11506 * single-value UNPIVOT (slice 134) the sole position 0 collapses to ALL 11507 * IN-list source columns ({@code derived=true}, {@code aggregate=false}).</li> 11508 * </ol> 11509 * This is the Oracle / Redshift {@code SELECT *} UNPIVOT order: the passthrough 11510 * columns first, then the FOR/name column, then the value column(s). The 11511 * per-column lineage is byte-identical to the slice-131 / 132 explicit-projection 11512 * path ({@link #buildUnpivotOutputColumns}); only the names and order are 11513 * synthesised here from the catalog + clause. 11514 * 11515 * <p>The passthrough subtraction folds names with {@code toLowerCase(Locale.ROOT)} 11516 * — the same identifier canonicalisation the other semantic-IR catalog matchers 11517 * use ({@link #expandPivotBareStar}, {@link #lookupRelationColumnNames}). The 11518 * FOR/value column names are NEW (UNPIVOT semantics forbid them from colliding 11519 * with a source column), so they are never in the catalog and need no subtraction. 11520 * 11521 * <p>Throws {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED} (reused from 11522 * slice 133) when no catalog column list is available, with an 11523 * UNPIVOT-discriminated message (slice-80 message-text-discrimination contract). 11524 */ 11525 private static List<OutputColumn> expandUnpivotBareStar(TPivotClause pc, 11526 TTable sourceTable, 11527 String sourceAlias, 11528 NameBindingProvider provider, 11529 TResultColumn star) { 11530 List<String> cols = lookupRelationColumnNames(sourceTable, provider); 11531 if (cols == null || cols.isEmpty()) { 11532 throw new SemanticIRBuildException( 11533 Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED, 11534 "SELECT * over an UNPIVOT requires catalog metadata for source '" 11535 + sourceAlias + "' to expand the implicit passthrough columns; " 11536 + "supply a Catalog via " 11537 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star)); 11538 } 11539 // Columns NARROWED by the UNPIVOT (the IN-list SOURCE columns); these are 11540 // removed from the passthrough set. Folded to lower case (Locale.ROOT) to 11541 // match the catalog identifier canonicalisation used by the other 11542 // semantic-IR catalog matchers. addPivotConsumedRef already filters out 11543 // null / empty names, so getColumnName() is non-null; the guard is defensive. 11544 List<ColumnRef> inListRefs = collectUnpivotInListRefs(pc, sourceAlias); 11545 Set<String> consumedLC = new HashSet<>(); 11546 for (ColumnRef r : inListRefs) { 11547 String cn = (r == null) ? null : r.getColumnName(); 11548 if (cn != null && !cn.isEmpty()) { 11549 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 11550 } 11551 } 11552 List<OutputColumn> outs = new ArrayList<>(); 11553 // 1. passthrough columns (catalog order, minus the narrowed IN-list sources). 11554 for (String c : cols) { 11555 if (c == null || c.isEmpty()) { 11556 continue; 11557 } 11558 if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) { 11559 continue; 11560 } 11561 outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false, 11562 Collections.singletonList(new ColumnRef(sourceAlias, c)), 11563 /*windowSpec=*/ null)); 11564 } 11565 // 2. FOR/name column — the literal labels of the narrowed columns; traces 11566 // to every IN-list source column (slice-131 / dlineage parity). 11567 String forName = unpivotForColumnName(pc); 11568 if (forName != null && !forName.isEmpty()) { 11569 outs.add(new OutputColumn(forName, /*derived=*/ true, /*aggregate=*/ false, 11570 inListRefs, /*windowSpec=*/ null)); 11571 } 11572 // 3. value column(s) — the unpivoted values, in getValueColumnList() 11573 // declaration order. Slice 135: value column at position k draws from 11574 // group position k across every IN-list item (collectUnpivotValueColumnRefs, 11575 // the slice-132 positional mechanism): s1 <- a1, b1; s2 <- a2, b2. For a 11576 // single-value UNPIVOT (slice 134) the sole position 0 collapses to all 11577 // IN-list source columns, byte-identical to the prior behaviour. 11578 TObjectNameList valueCols = pc.getValueColumnList(); 11579 if (valueCols != null) { 11580 for (int k = 0; k < valueCols.size(); k++) { 11581 TObjectName valueCol = valueCols.getObjectName(k); 11582 String valueName = (valueCol != null) ? valueCol.getColumnNameOnly() : null; 11583 if (valueName != null && !valueName.isEmpty()) { 11584 outs.add(new OutputColumn(valueName, /*derived=*/ true, /*aggregate=*/ false, 11585 collectUnpivotValueColumnRefs(pc, sourceAlias, k), /*windowSpec=*/ null)); 11586 } 11587 } 11588 } 11589 return outs; 11590 } 11591 11592 /** 11593 * Slices 70 and 71: build per-statement row-limit metadata from 11594 * {@code TLimitClause}, {@code TTopClause}, {@code TOffsetClause}, 11595 * or {@code TFetchFirstClause}. Returns {@code null} when no 11596 * row-limit clause is present. All admit / reject decisions for 11597 * single-SELECT row-limit clauses live here; the set-op outer 11598 * row-limit path is rejected separately by 11599 * {@link #rejectSetOpRowLimit} (slice 72 lifts). 11600 * 11601 * <h4>Admitted shapes</h4> 11602 * <ul> 11603 * <li>{@link RowLimitKind#LIMIT} — {@code TLimitClause} with 11604 * non-null {@code getRow_count()}. Offset is populated when 11605 * {@code TLimitClause.getOffset() != null} (PG / MySQL / 11606 * SQLite / BigQuery / Snowflake / Redshift inline 11607 * {@code LIMIT N OFFSET M}, MySQL old-style {@code LIMIT M, N}, 11608 * Informix {@code SKIP m LIMIT n}).</li> 11609 * <li>{@link RowLimitKind#FETCH_FIRST} — {@code TLimitClause} with 11610 * non-null {@code getSelectFetchFirstValue()} (PG 11611 * {@code FETCH FIRST}, Informix {@code FIRST n}). Offset is 11612 * populated when present (PG 11613 * {@code OFFSET m FETCH FIRST n}, Informix 11614 * {@code SKIP m FIRST n}). Also fires for 11615 * {@code TFetchFirstClause} with non-null 11616 * {@code getFetchValue()} (Oracle / SQL Server 11617 * {@code FETCH FIRST/NEXT N ROWS ONLY}) when no 11618 * {@code TOffsetClause} is present.</li> 11619 * <li>{@link RowLimitKind#TOP} — {@code TTopClause} with non-null 11620 * {@code getExpr()} and neither {@code isPercent()} nor 11621 * {@code isWithties()} set. SQL Server {@code SELECT TOP N}.</li> 11622 * <li>{@link RowLimitKind#OFFSET_FETCH} — Oracle / SQL Server 11623 * {@code OFFSET m ROWS [FETCH NEXT n ROWS ONLY]} routed via 11624 * the dedicated {@code TOffsetClause} + {@code TFetchFirstClause} 11625 * pair, and PG offset-only {@code OFFSET m} routed via 11626 * {@code TLimitClause.getOffset()} when {@code row_count} and 11627 * {@code selectFetchFirstValue} are both null. 11628 * {@link RowLimit#getCount()} may be {@code null} for 11629 * offset-only forms.</li> 11630 * </ul> 11631 * 11632 * <h4>Rejects</h4> 11633 * <ul> 11634 * <li>{@link DiagnosticCode#ROW_LIMIT_TOP_PERCENT_NOT_SUPPORTED} 11635 * — {@code TOP N PERCENT}. The sampling semantics differ from 11636 * fixed-row {@code LIMIT} enough to warrant a dedicated slice.</li> 11637 * <li>{@link DiagnosticCode#ROW_LIMIT_TOP_WITH_TIES_NOT_SUPPORTED} 11638 * — {@code TOP N WITH TIES}. Requires modeling the ORDER BY 11639 * tie-handling interaction; deferred.</li> 11640 * <li>{@link DiagnosticCode#ROW_LIMIT_HIVE_LIMIT_GRAMMAR_QUIRK} — 11641 * Hive single-argument {@code LIMIT N} parser routes the 11642 * count through {@code TLimitClause.getOffset()} with 11643 * {@code row_count == null}, which is indistinguishable at 11644 * the AST level from PG offset-only {@code OFFSET m}. Pinning 11645 * this with a vendor-specific guard prevents emitting 11646 * semantically-wrong {@code OFFSET_FETCH} metadata for what 11647 * the SQL author wrote as a LIMIT. A future grammar fix 11648 * should route the count through {@code getRow_count()}; this 11649 * guard can be removed then.</li> 11650 * <li>{@link DiagnosticCode#ROW_LIMIT_LIMIT_NOT_SUPPORTED} — 11651 * Vertica TIMESERIES windowing on {@code TLimitClause} 11652 * ({@code getWindowDef() != null}). Defensive; not modeled.</li> 11653 * <li>{@link DiagnosticCode#ROW_LIMIT_COUNT_UNRESOLVED} — the 11654 * parser constructed a row-limit clause node but did not 11655 * populate any count slot: 11656 * <ul> 11657 * <li>{@code TLimitClause} with {@code row_count}, 11658 * {@code selectFetchFirstValue}, and {@code offset} all 11659 * null (defensive; not observed in probe runs).</li> 11660 * <li>{@code TFetchFirstClause} with null fetchValue — 11661 * ANSI / DB2 grammar incompleteness (the parser 11662 * constructs the clause node but does not populate the 11663 * count). Future grammar fix can lift this.</li> 11664 * <li>{@code TTopClause} with null expression (defensive).</li> 11665 * </ul></li> 11666 * </ul> 11667 */ 11668 private static RowLimit buildRowLimit(TSelectSqlStatement select) { 11669 TLimitClause limit = select.getLimitClause(); 11670 if (limit != null) { 11671 // Vertica TIMESERIES window on TLimitClause — defensive; rare. 11672 // Pre-empts the row_count / fff branches because the windowed 11673 // form is its own semantic surface. 11674 if (limit.getWindowDef() != null) { 11675 throw new SemanticIRBuildException( 11676 Diagnostic.error(DiagnosticCode.ROW_LIMIT_LIMIT_NOT_SUPPORTED, 11677 "row-limit clause LIMIT with Vertica TIMESERIES window " 11678 + "is not supported yet", limit)); 11679 } 11680 11681 TExpression rc = limit.getRow_count(); 11682 TExpression off = limit.getOffset(); 11683 TExpression fff = limit.getSelectFetchFirstValue(); 11684 11685 // Hive single-argument LIMIT parser quirk: the count ends 11686 // up on offset with row_count=null. Vendor-conditional 11687 // because the same AST shape is legitimate PG offset-only. 11688 if (select.dbvendor == EDbVendor.dbvhive 11689 && rc == null && off != null && fff == null) { 11690 throw new SemanticIRBuildException( 11691 Diagnostic.error(DiagnosticCode.ROW_LIMIT_HIVE_LIMIT_GRAMMAR_QUIRK, 11692 "Hive single-argument LIMIT N is currently mis-routed " 11693 + "by the parser (count appears on TLimitClause.getOffset() " 11694 + "with row_count=null); fix the Hive grammar to route " 11695 + "the count through getRow_count() to lift this guard", limit)); 11696 } 11697 11698 if (rc != null) { 11699 // LIMIT N with optional OFFSET M (PG/MySQL/SQLite/ 11700 // BigQuery/Snowflake/Redshift inline LIMIT-OFFSET, 11701 // MySQL old-style LIMIT M,N, Informix SKIP m LIMIT n). 11702 return new RowLimit(RowLimitKind.LIMIT, 11703 rc.toString(), 11704 off != null ? off.toString() : null); 11705 } 11706 if (fff != null) { 11707 // FETCH FIRST via the PG/Informix routing through 11708 // TLimitClause, with optional OFFSET (PG 11709 // OFFSET m FETCH FIRST n; Informix SKIP m FIRST n). 11710 return new RowLimit(RowLimitKind.FETCH_FIRST, 11711 fff.toString(), 11712 off != null ? off.toString() : null); 11713 } 11714 if (off != null) { 11715 // Offset-only via TLimitClause (PG OFFSET m [ROWS]). 11716 return new RowLimit(RowLimitKind.OFFSET_FETCH, 11717 /*count=*/ null, 11718 off.toString()); 11719 } 11720 // Defensive: TLimitClause present with all four slots null. 11721 throw new SemanticIRBuildException( 11722 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 11723 "row-limit clause LIMIT is present but no count, offset, " 11724 + "or FETCH FIRST value is populated on the parser AST", limit)); 11725 } 11726 11727 TTopClause top = select.getTopClause(); 11728 if (top != null) { 11729 if (top.isPercent()) { 11730 throw new SemanticIRBuildException( 11731 Diagnostic.error(DiagnosticCode.ROW_LIMIT_TOP_PERCENT_NOT_SUPPORTED, 11732 "row-limit clause TOP N PERCENT is not supported yet; " 11733 + "sampling semantics warrant a dedicated slice", top)); 11734 } 11735 if (top.isWithties()) { 11736 throw new SemanticIRBuildException( 11737 Diagnostic.error(DiagnosticCode.ROW_LIMIT_TOP_WITH_TIES_NOT_SUPPORTED, 11738 "row-limit clause TOP N WITH TIES is not supported yet; " 11739 + "tie-handling semantics warrant a dedicated slice", top)); 11740 } 11741 TExpression e = top.getExpr(); 11742 if (e == null) { 11743 throw new SemanticIRBuildException( 11744 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 11745 "row-limit clause TOP is present but the count expression " 11746 + "is not populated on the parser AST", top)); 11747 } 11748 return new RowLimit(RowLimitKind.TOP, e.toString(), /*offset=*/ null); 11749 } 11750 11751 TOffsetClause offClause = select.getOffsetClause(); 11752 TFetchFirstClause fetch = select.getFetchFirstClause(); 11753 if (offClause != null) { 11754 // Oracle / SQL Server OFFSET m ROWS [FETCH NEXT n ROWS ONLY]. 11755 // The optional FETCH NEXT counterpart populates 11756 // TFetchFirstClause when present. 11757 String offsetText = offClause.getSelectOffsetValue() != null 11758 ? offClause.getSelectOffsetValue().toString() 11759 : null; 11760 if (offsetText == null) { 11761 throw new SemanticIRBuildException( 11762 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 11763 "row-limit clause OFFSET is present but the offset value " 11764 + "is not populated on the parser AST", offClause)); 11765 } 11766 String countText = null; 11767 if (fetch != null && fetch.getFetchValue() != null) { 11768 countText = fetch.getFetchValue().toString(); 11769 } 11770 return new RowLimit(RowLimitKind.OFFSET_FETCH, countText, offsetText); 11771 } 11772 if (fetch != null) { 11773 if (fetch.getFetchValue() != null) { 11774 // Oracle / SQL Server FETCH FIRST/NEXT N ROWS ONLY 11775 // without OFFSET (TOffsetClause was null above). 11776 return new RowLimit(RowLimitKind.FETCH_FIRST, 11777 fetch.getFetchValue().toString(), /*offset=*/ null); 11778 } 11779 // ANSI / DB2: TFetchFirstClause is non-null but fetchValue 11780 // is null because the grammar does not pass the count into 11781 // the node initializer. Reject so the gap is visible. 11782 throw new SemanticIRBuildException( 11783 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 11784 "row-limit clause FETCH FIRST is present but the count " 11785 + "expression is not populated on the parser AST " 11786 + "(ANSI / DB2 grammar gap)", fetch)); 11787 } 11788 return null; 11789 } 11790 11791 /** 11792 * Slice 72: build the OUTER set-op statement's row-limit. Same 11793 * decision tree as {@link #buildRowLimit} for SELECT-level routing 11794 * (PG/MySQL/SQLite/BigQuery/Snowflake/Redshift via 11795 * {@code TLimitClause}, plus Hive/Vertica/ANSI-DB2 defensives), 11796 * with an additional MSSQL-only fallback that reads the OFFSET / 11797 * FETCH FIRST clauses off the outer {@code TOrderBy} node. 11798 * 11799 * <p>Empirical AST shapes (probed against the current parser): 11800 * <ul> 11801 * <li>PG / MySQL / SQLite / BigQuery / Snowflake / Redshift route 11802 * set-op outer LIMIT / OFFSET / FETCH FIRST onto 11803 * {@code setOp.getLimitClause()} — handled by the primary 11804 * {@code buildRowLimit} path.</li> 11805 * <li>MSSQL routes set-op outer {@code OFFSET m ROWS [FETCH NEXT 11806 * n ROWS ONLY]} EXCLUSIVELY onto 11807 * {@code setOp.getOrderbyClause().getOffsetClause()} / 11808 * {@code .getFetchFirstClause()} — NOT duplicated onto the 11809 * SELECT node (opposite of single-SELECT MSSQL where slice 71 11810 * saw duplication onto both). The TOrderBy fallback below 11811 * handles this.</li> 11812 * <li>Oracle drops set-op outer OFFSET / FETCH from both SELECT 11813 * and TOrderBy slots silently; nothing for slice 72 to 11814 * emit. A future Oracle grammar fix can lift this.</li> 11815 * </ul> 11816 * 11817 * <p>The TOrderBy fallback is vendor-gated to MSSQL to avoid 11818 * over-admitting on unprobed dialects (per codex round-1 B1). 11819 * Kind mapping mirrors {@link #buildRowLimit}'s single-SELECT 11820 * decision tree: 11821 * <ul> 11822 * <li>{@code TOffsetClause} + {@code TFetchFirstClause} both 11823 * populated → {@code OFFSET_FETCH/count/offset}</li> 11824 * <li>{@code TOffsetClause} only → {@code OFFSET_FETCH/null/offset}</li> 11825 * <li>{@code TFetchFirstClause} only → {@code FETCH_FIRST/count/null} 11826 * (unreachable via current MSSQL grammar which requires 11827 * OFFSET before FETCH; retained as defensive routing-shape 11828 * parity with single-SELECT)</li> 11829 * <li>Defensive null-value rejects mirror single-SELECT 11830 * {@link #buildRowLimit}: a present {@code TOffsetClause} 11831 * with a null offset value throws 11832 * {@code ROW_LIMIT_COUNT_UNRESOLVED}; a present bare 11833 * {@code TFetchFirstClause} (no companion OFFSET) with a 11834 * null fetch value throws the same code. When both clauses 11835 * are present, only the offset slot must be populated; a 11836 * null fetch value is silently treated as offset-only 11837 * (matches the single-SELECT 11838 * {@code TOffsetClause + TFetchFirstClause} branch in 11839 * {@code buildRowLimit}).</li> 11840 * </ul> 11841 */ 11842 private static RowLimit buildSetOpRowLimit(TSelectSqlStatement setOp) { 11843 // Primary path: SELECT-level routing. Covers PG/MySQL/SQLite/ 11844 // BigQuery/Snowflake/Redshift via TLimitClause, plus inherited 11845 // Hive / Vertica / ANSI-DB2 defensives from buildRowLimit. 11846 RowLimit fromSelect = buildRowLimit(setOp); 11847 if (fromSelect != null) { 11848 return fromSelect; 11849 } 11850 // MSSQL-only TOrderBy fallback (codex B1 vendor gate). 11851 if (setOp.dbvendor != EDbVendor.dbvmssql) { 11852 return null; 11853 } 11854 TOrderBy orderBy = setOp.getOrderbyClause(); 11855 if (orderBy == null) return null; 11856 TOffsetClause oc = orderBy.getOffsetClause(); 11857 TFetchFirstClause fc = orderBy.getFetchFirstClause(); 11858 if (oc == null && fc == null) return null; 11859 11860 String offsetText = (oc != null && oc.getSelectOffsetValue() != null) 11861 ? oc.getSelectOffsetValue().toString() : null; 11862 String countText = (fc != null && fc.getFetchValue() != null) 11863 ? fc.getFetchValue().toString() : null; 11864 11865 if (oc != null && fc != null) { 11866 if (offsetText == null) { 11867 // Mirrors single-SELECT buildRowLimit TOffsetClause path: 11868 // a present OFFSET clause must populate its value (the 11869 // FETCH NEXT counterpart is optional; null countText is 11870 // silently treated as offset-only). 11871 throw new SemanticIRBuildException( 11872 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 11873 "MSSQL set-op outer OFFSET clause present on TOrderBy " 11874 + "but offset value is not populated on the parser AST", orderBy)); 11875 } 11876 return new RowLimit(RowLimitKind.OFFSET_FETCH, countText, offsetText); 11877 } 11878 if (oc != null) { 11879 if (offsetText == null) { 11880 throw new SemanticIRBuildException( 11881 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 11882 "MSSQL set-op outer OFFSET clause present on TOrderBy " 11883 + "but offset value is not populated on the parser AST", orderBy)); 11884 } 11885 return new RowLimit(RowLimitKind.OFFSET_FETCH, /*count=*/ null, offsetText); 11886 } 11887 // fc only (oc == null). Defensive: not reachable via current 11888 // MSSQL grammar which requires OFFSET before FETCH NEXT. 11889 if (countText == null) { 11890 throw new SemanticIRBuildException( 11891 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 11892 "MSSQL set-op outer FETCH FIRST clause present on TOrderBy " 11893 + "but fetch value is not populated on the parser AST", orderBy)); 11894 } 11895 return new RowLimit(RowLimitKind.FETCH_FIRST, countText, /*offset=*/ null); 11896 } 11897 11898 /** 11899 * Resolve a {@link TSelectSqlStatement}'s row-filter clause to the IR's 11900 * {@code distinct} flag. Mapping: 11901 * 11902 * <ul> 11903 * <li>no clause / {@code urfNone} / {@code urfAll}: {@code false}</li> 11904 * <li>{@code urfDistinct}: {@code true}</li> 11905 * <li>{@code urfUnique}: {@code true} — Oracle treats 11906 * {@code SELECT UNIQUE} as a deprecated synonym for 11907 * {@code SELECT DISTINCT}; both produce the same row-set.</li> 11908 * <li>{@code urfDistinctOn}: admits (slice 73). Returns 11909 * {@code true} for the boolean flag; the 11910 * {@code DISTINCT ON (cols)} partition keys are collected 11911 * separately by 11912 * {@link #buildDistinctOnColumnRefs(TSelectSqlStatement, 11913 * NameBindingProvider)} so the column-ref collection runs 11914 * AFTER {@code UsingScope} is installed (matching the timing 11915 * of {@link #buildGroupByColumnRefs} and friends).</li> 11916 * <li>{@code urfDistinctRow}, {@code urfNormalize}: rejected 11917 * (vendor-specific; not yet a documented IR shape).</li> 11918 * <li>null filter on a non-null {@code TSelectDistinct}, or a 11919 * new enum value the switch hasn't seen yet: rejected, so a 11920 * future {@code EUniqueRowFilterType} addition fails loudly 11921 * rather than silently classifying as {@code distinct=false}.</li> 11922 * </ul> 11923 */ 11924 private static boolean resolveDistinctFlag(TSelectSqlStatement select) { 11925 TSelectDistinct sd = select.getSelectDistinct(); 11926 if (sd == null) return false; 11927 EUniqueRowFilterType urf = sd.getUniqueRowFilter(); 11928 if (urf == null) { 11929 throw new SemanticIRBuildException( 11930 Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_NULL, 11931 "SELECT row-filter is null; expected one of " 11932 + "{none, all, distinct, unique}", select)); 11933 } 11934 switch (urf) { 11935 case urfNone: 11936 case urfAll: 11937 return false; 11938 case urfDistinct: 11939 case urfUnique: // Oracle deprecated synonym for DISTINCT 11940 case urfDistinctOn: // slice 73: refs collected separately 11941 return true; 11942 case urfDistinctRow: 11943 case urfNormalize: 11944 throw new SemanticIRBuildException( 11945 Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_NOT_SUPPORTED, 11946 "SELECT row-filter " + urf + " is not supported yet", select)); 11947 default: 11948 throw new SemanticIRBuildException( 11949 Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_UNKNOWN, 11950 "unknown SELECT row-filter " + urf, select)); 11951 } 11952 } 11953 11954 /** 11955 * Slice 73: collect physical column references from a 11956 * {@code SELECT DISTINCT ON (cols)} expression list. Returns the 11957 * empty list for plain {@code DISTINCT}, {@code UNIQUE}, 11958 * {@code ALL}, and the no-filter case. Only PostgreSQL and 11959 * Greenplum expose {@code urfDistinctOn} with a populated 11960 * {@link TSelectDistinct#getExpressionList()}; Oracle, MySQL, and 11961 * Redshift silently drop the {@code ON (...)} clause and parse the 11962 * SELECT as plain {@code DISTINCT}, so this helper returns 11963 * {@code []} for those vendors regardless of the surface SQL. 11964 * 11965 * <p>Mirrors {@link #buildGroupByColumnRefs}: subqueries and window 11966 * functions in the expression list are rejected BEFORE 11967 * {@link #collectColumnRefs} descends, so inner-scope refs cannot 11968 * leak into {@code distinctOnColumnRefs}. Compound expressions 11969 * ({@code a + b}, {@code CASE WHEN ...}) and aggregate arguments 11970 * ({@code COUNT(x)}) are descended into so the underlying column 11971 * refs are captured. 11972 */ 11973 private static List<ColumnRef> buildDistinctOnColumnRefs( 11974 TSelectSqlStatement select, NameBindingProvider provider) { 11975 TSelectDistinct sd = select.getSelectDistinct(); 11976 if (sd == null 11977 || sd.getUniqueRowFilter() != EUniqueRowFilterType.urfDistinctOn) { 11978 return new ArrayList<>(); 11979 } 11980 TExpressionList el = sd.getExpressionList(); 11981 if (el == null || el.size() == 0) { 11982 // PG grammar requires at least one expression after 11983 // DISTINCT ON (; this branch is defensive — surface a 11984 // clear diagnostic rather than silently emit []. 11985 throw new SemanticIRBuildException( 11986 Diagnostic.error(DiagnosticCode.DISTINCT_ON_EMPTY_COLUMN_LIST, 11987 "DISTINCT ON requires at least one expression but the " 11988 + "AST exposes an empty list", sd)); 11989 } 11990 // Iterate items explicitly so each per-expression reject 11991 // diagnostic points at the offending expression. Equivalent 11992 // to running containsAnySubquery / rejectWindowFunctionInScope 11993 // / collectColumnRefs on the whole list (TExpressionList 11994 // inherits TParseTreeNodeList.acceptChildren which already 11995 // iterates element children), but the loop body gives 11996 // clearer rejection sites and lets us dedup refs across 11997 // expressions in declaration order. 11998 List<ColumnRef> refs = new ArrayList<>(); 11999 for (int i = 0; i < el.size(); i++) { 12000 TExpression expr = el.getExpression(i); 12001 if (containsAnySubqueryExpression(expr)) { 12002 throw new SemanticIRBuildException( 12003 Diagnostic.error(DiagnosticCode.DISTINCT_ON_HAS_SUBQUERY_NOT_SUPPORTED, 12004 "DISTINCT ON expression list contains a subquery; " 12005 + "subqueries in DISTINCT ON are not supported yet", sd)); 12006 } 12007 rejectWindowFunctionInScope(expr, "DISTINCT ON expression list"); 12008 for (ColumnRef ref : collectColumnRefs(expr, provider)) { 12009 if (!refs.contains(ref)) refs.add(ref); 12010 } 12011 } 12012 return refs; 12013 } 12014 12015 private static boolean hasNoFromSource(TSelectSqlStatement select) { 12016 return select.joins == null || select.joins.size() == 0; 12017 } 12018 12019 private static boolean allResultColumnsAreConstantExpressions(TSelectSqlStatement select) { 12020 TResultColumnList rcl = select.getResultColumnList(); 12021 if (rcl == null || rcl.size() == 0) return false; 12022 for (int i = 0; i < rcl.size(); i++) { 12023 TResultColumn rc = rcl.getResultColumn(i); 12024 if (rc == null || rc.getExpr() == null || !isConstantExpression(rc.getExpr())) { 12025 return false; 12026 } 12027 } 12028 return true; 12029 } 12030 12031 private static List<ColumnRef> buildGroupByColumnRefs(TSelectSqlStatement select, NameBindingProvider provider) { 12032 TGroupBy groupBy = select.getGroupByClause(); 12033 if (groupBy == null || groupBy.getItems() == null || groupBy.getItems().size() == 0) { 12034 return new ArrayList<>(); 12035 } 12036 TGroupByItemList items = groupBy.getItems(); 12037 // Slice 127: flatten plain, ROLLUP, CUBE, and GROUPING SETS items 12038 // into the set of grouping expressions before guarding/collecting. 12039 // A plain visitor over {@code items} silently drops every column 12040 // inside a compound grouping element, because the AST visitor links 12041 // are broken there: {@link TGroupByItem#acceptChildren} descends 12042 // ONLY into its plain {@code expr} (not {@code rollupCube}, 12043 // {@code groupingSet}, or {@code exprList}), and 12044 // {@link TRollupCube#acceptChildren} / 12045 // {@link TGroupingExpressionItem#acceptChildren} do not descend at 12046 // all. Pre-slice-127, `GROUP BY ROLLUP(a, c)` produced empty 12047 // groupByColumnRefs and a subquery/window function buried inside a 12048 // compound element bypassed the slice-61/slice-13 guards. We do NOT 12049 // patch the shared AST {@code acceptChildren} methods (that would 12050 // ripple into dlineage / resolver); the fix is localized here. 12051 List<TExpression> groupingExprs = flattenGroupByExpressions(items); 12052 // Slice 61: reject subqueries in GROUP BY before column collection 12053 // descends into them. Pre-slice-61, queries such as `SELECT 1 12054 // FROM employees GROUP BY (SELECT id FROM departments)` reached 12055 // the constant-only projection guard and failed there; with the 12056 // slice-61 lift the projection now builds and the GROUP BY 12057 // collection would leak `departments.id` into groupByColumnRefs 12058 // even though `departments` is not in {@code relations}, breaking 12059 // the IR invariant that column refs reference an in-scope 12060 // relation. Mirrors the WHERE / HAVING / ORDER BY subquery guards. 12061 // Slice 127 runs the guard over the flattened expressions so a 12062 // subquery inside ROLLUP/CUBE/GROUPING SETS is also rejected; the 12063 // anchor stays the {@code groupBy} node for contract stability. 12064 for (TExpression e : groupingExprs) { 12065 if (containsAnySubquery(e)) { 12066 throw new SemanticIRBuildException( 12067 Diagnostic.error(DiagnosticCode.GROUP_BY_HAS_SUBQUERY_NOT_SUPPORTED, 12068 "GROUP BY clause contains a subquery; subqueries in " 12069 + "GROUP BY are not supported yet", groupBy)); 12070 } 12071 } 12072 // Slice 13: reject window functions in GROUP BY before collection 12073 // (slice 127 runs the guard over the flattened expressions). 12074 for (TExpression e : groupingExprs) { 12075 rejectWindowFunctionInScope(e, "GROUP BY clause"); 12076 } 12077 // Visitor-based collection ensures column refs in any nested 12078 // expression (e.g. GROUP BY date_trunc('day', t)) are captured. 12079 // 12080 // Slice 127: collect with the Phase-1 source-table fallback enabled. 12081 // Resolver2's Phase-2 GROUP BY scope builder (processGroupBy) only 12082 // resolves columns inside a plain {@code item.getExpr()}; columns 12083 // inside ROLLUP / CUBE never reach processColumnReferences, so they 12084 // carry {@code resolution == null}. The slice-93 fallback promotes 12085 // such refs to EXACT_MATCH when Phase 1's linkColumnToTable has set 12086 // a source table and the SQL-written qualifier (if any) is 12087 // consistent with it — exactly the Hive multi-insert situation the 12088 // facet was built for. Plain grouping columns ARE resolved by 12089 // Resolver2 ({@code resolution != null}), so the fallback is inert 12090 // for them and the plain-GROUP-BY contract is unchanged. 12091 return collectColumnRefsFromExpressions( 12092 groupingExprs, provider.withSourceTableFallback(true)); 12093 } 12094 12095 /** 12096 * Flatten a {@link TGroupByItemList} into the ordered list of leaf 12097 * grouping {@link TExpression}s, descending into ROLLUP / CUBE / 12098 * GROUPING SETS compound elements that the AST visitor does not reach 12099 * (slice 127). Leaves are emitted in left-to-right document order. 12100 * 12101 * <p>Each {@link TGroupByItem} carries exactly one (mutually exclusive 12102 * per its {@code init}) of: a plain {@code expr}, an {@code exprList} 12103 * (parenthesized {@code GROUP BY (a, b)}), a {@link TRollupCube} 12104 * (ROLLUP / CUBE), or a {@link TGroupingSet} (GROUPING SETS). Per-item 12105 * descent into the compound forms uses an explicit stack (no recursion, 12106 * per the repo iterative-traversal rule) with reverse-push so leaves 12107 * pop in document order. 12108 */ 12109 private static List<TExpression> flattenGroupByExpressions(TGroupByItemList items) { 12110 List<TExpression> out = new ArrayList<>(); 12111 for (int i = 0; i < items.size(); i++) { 12112 TGroupByItem item = items.getGroupByItem(i); 12113 if (item == null) { 12114 continue; 12115 } 12116 flattenGroupByItem(item, out); 12117 } 12118 return out; 12119 } 12120 12121 /** 12122 * Flatten a single {@link TGroupByItem} into the ordered list of leaf 12123 * grouping {@link TExpression}s, appending to {@code out}. Extracted 12124 * from {@link #flattenGroupByExpressions} (slice 127) so slice 128's 12125 * {@link #buildGroupingElements} can collect the member columns of one 12126 * top-level grouping element independently. Descent into ROLLUP / CUBE 12127 * / GROUPING SETS uses an explicit stack (no recursion, per the repo 12128 * iterative-traversal rule) with reverse-push so leaves pop in 12129 * document order. 12130 */ 12131 private static void flattenGroupByItem(TGroupByItem item, List<TExpression> out) { 12132 if (item == null) { 12133 return; 12134 } 12135 if (item.getExpr() != null) { 12136 out.add(item.getExpr()); 12137 } 12138 addExpressionListItems(item.getExprList(), out); 12139 Deque<TParseTreeNode> stack = new ArrayDeque<>(); 12140 if (item.getRollupCube() != null) { 12141 stack.push(item.getRollupCube()); 12142 } 12143 if (item.getGroupingSet() != null) { 12144 stack.push(item.getGroupingSet()); 12145 } 12146 while (!stack.isEmpty()) { 12147 TParseTreeNode node = stack.pop(); 12148 if (node instanceof TRollupCube) { 12149 addExpressionListItems(((TRollupCube) node).getItems(), out); 12150 } else if (node instanceof TGroupingSet) { 12151 TGroupingSetItemList gsItems = ((TGroupingSet) node).getItems(); 12152 if (gsItems != null) { 12153 // reverse-push so set items pop in document order 12154 for (int j = gsItems.size() - 1; j >= 0; j--) { 12155 TGroupingSetItem gsi = gsItems.getGroupingSetItem(j); 12156 if (gsi != null) { 12157 stack.push(gsi); 12158 } 12159 } 12160 } 12161 } else if (node instanceof TGroupingSetItem) { 12162 TGroupingSetItem gsi = (TGroupingSetItem) node; 12163 // exactly one payload per item; add direct exprs, push 12164 // the compound child so its leaves emerge in order. 12165 if (gsi.getGrouping_expression() != null) { 12166 out.add(gsi.getGrouping_expression()); 12167 } 12168 if (gsi.getExpressionItem() != null) { 12169 stack.push(gsi.getExpressionItem()); 12170 } 12171 if (gsi.getRollupCubeClause() != null) { 12172 stack.push(gsi.getRollupCubeClause()); 12173 } 12174 } else if (node instanceof TGroupingExpressionItem) { 12175 TGroupingExpressionItem gei = (TGroupingExpressionItem) node; 12176 if (gei.getExpr() != null) { 12177 out.add(gei.getExpr()); 12178 } 12179 addExpressionListItems(gei.getExprList(), out); 12180 } 12181 } 12182 } 12183 12184 /** 12185 * Slice 128 — build the structured per-top-level-element view of the 12186 * {@code GROUP BY}: one {@link GroupingElement} per top-level 12187 * {@link TGroupByItem} in document order, tagged 12188 * {@code SIMPLE} / {@code ROLLUP} / {@code CUBE} / {@code GROUPING_SETS} 12189 * (via {@link #groupingElementKind}) and carrying that element's 12190 * flattened leaf member columns. 12191 * 12192 * <p>Returns an empty list when there is no {@code GROUP BY}. Runs 12193 * <i>after</i> {@link #buildGroupByColumnRefs} in the SELECT builder, so 12194 * the slice-61 subquery and slice-13 window-function guards have already 12195 * rejected illegal grouping expressions and the per-element member 12196 * collection (which reuses {@link #collectColumnRefsFromExpressions} 12197 * with the slice-93 source-table fallback, exactly as the flat path 12198 * does) cannot newly throw. Per-element collection means a column 12199 * appearing in two top-level elements (e.g. 12200 * {@code GROUP BY a, ROLLUP(a)}) is faithfully present in both — the 12201 * flat {@link #buildGroupByColumnRefs} dedups it to a single ref, which 12202 * is the intended difference between the two slots. 12203 */ 12204 private static List<GroupingElement> buildGroupingElements( 12205 TSelectSqlStatement select, NameBindingProvider provider) { 12206 TGroupBy groupBy = select.getGroupByClause(); 12207 if (groupBy == null || groupBy.getItems() == null || groupBy.getItems().size() == 0) { 12208 return new ArrayList<>(); 12209 } 12210 TGroupByItemList items = groupBy.getItems(); 12211 NameBindingProvider fallbackProvider = provider.withSourceTableFallback(true); 12212 List<GroupingElement> out = new ArrayList<>(); 12213 for (int i = 0; i < items.size(); i++) { 12214 TGroupByItem item = items.getGroupByItem(i); 12215 if (item == null) { 12216 continue; 12217 } 12218 List<TExpression> exprs = new ArrayList<>(); 12219 flattenGroupByItem(item, exprs); 12220 List<ColumnRef> members = collectColumnRefsFromExpressions(exprs, fallbackProvider); 12221 out.add(new GroupingElement(groupingElementKind(item), members)); 12222 } 12223 return out; 12224 } 12225 12226 /** 12227 * Classify the grouping operation of a single top-level 12228 * {@link TGroupByItem} (slice 128). The authoritative signals are the 12229 * AST node-presence checks: a non-null {@code groupingSet} is 12230 * {@code GROUPING SETS}; a non-null {@code rollupCube} carries its own 12231 * {@code rollup} / {@code cube} / {@code grouping_sets} operation 12232 * (BigQuery spells {@code GROUPING SETS} as a {@link TRollupCube} with 12233 * the {@code grouping_sets} operation). The 12234 * {@link EGroupingSetType} flag is a defensive fallback for any form 12235 * that sets the flag without populating the node; {@code gsExpr} / 12236 * {@code gsList} / {@code gsEmpty} and anything unrecognized fall 12237 * through to {@code SIMPLE} (the plain {@code expr} / {@code exprList} 12238 * grouping path). 12239 */ 12240 private static GroupingElement.Kind groupingElementKind(TGroupByItem item) { 12241 if (item.getGroupingSet() != null) { 12242 return GroupingElement.Kind.GROUPING_SETS; 12243 } 12244 TRollupCube rc = item.getRollupCube(); 12245 if (rc != null) { 12246 switch (rc.getOperation()) { 12247 case TRollupCube.cube: 12248 return GroupingElement.Kind.CUBE; 12249 case TRollupCube.grouping_sets: 12250 return GroupingElement.Kind.GROUPING_SETS; 12251 case TRollupCube.rollup: 12252 default: 12253 return GroupingElement.Kind.ROLLUP; 12254 } 12255 } 12256 EGroupingSetType gst = item.getGroupingSetType(); 12257 if (gst != null) { 12258 switch (gst) { 12259 case gsRollup: 12260 return GroupingElement.Kind.ROLLUP; 12261 case gsCube: 12262 return GroupingElement.Kind.CUBE; 12263 case gsSets: 12264 return GroupingElement.Kind.GROUPING_SETS; 12265 default: 12266 break; 12267 } 12268 } 12269 return GroupingElement.Kind.SIMPLE; 12270 } 12271 12272 /** Append every {@link TExpression} of {@code list} (if any) to {@code out}. */ 12273 private static void addExpressionListItems(TExpressionList list, List<TExpression> out) { 12274 if (list == null) { 12275 return; 12276 } 12277 for (int j = 0; j < list.size(); j++) { 12278 TExpression e = list.getExpression(j); 12279 if (e != null) { 12280 out.add(e); 12281 } 12282 } 12283 } 12284 12285 /** 12286 * Multi-root variant of {@link #collectColumnRefs}: collect EXACT_MATCH 12287 * physical {@link ColumnRef}s from each expression root in order, with 12288 * the same {@code nestedSelectDepth} guard, USING merged-key handling 12289 * via {@link #appendMergedOrBoundColumnRef}, {@link LinkedHashSet} 12290 * dedup, and {@code COLUMN_BINDING_NON_EXACT} rejection. Used by 12291 * {@link #buildGroupByColumnRefs} over the slice-127 flattened 12292 * ROLLUP / CUBE / GROUPING SETS grouping expressions. 12293 */ 12294 private static List<ColumnRef> collectColumnRefsFromExpressions( 12295 List<TExpression> exprs, final NameBindingProvider provider) { 12296 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 12297 final List<String> rejects = new ArrayList<>(); 12298 // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at 12299 // the wrong side). Such a miss is a genuine error and must stay fatal; 12300 // only all-unqualified rejects are eligible for the join-graph degrade. 12301 final boolean[] sawQualifiedReject = {false}; 12302 TParseTreeVisitor visitor = new TParseTreeVisitor() { 12303 int nestedSelectDepth = 0; 12304 12305 @Override 12306 public void preVisit(TSelectSqlStatement nested) { 12307 nestedSelectDepth++; 12308 } 12309 12310 @Override 12311 public void postVisit(TSelectSqlStatement nested) { 12312 nestedSelectDepth--; 12313 } 12314 12315 @Override 12316 public void preVisit(TObjectName node) { 12317 if (nestedSelectDepth > 0) return; 12318 appendMergedOrBoundColumnRef(node, provider, refs, rejects, 12319 sawQualifiedReject); 12320 } 12321 }; 12322 for (TExpression e : exprs) { 12323 e.acceptChildren(visitor); 12324 } 12325 if (!rejects.isEmpty()) { 12326 rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]); 12327 } 12328 return new ArrayList<>(refs); 12329 } 12330 12331 /** 12332 * Collect physical column references from the {@code HAVING} clause. 12333 * 12334 * <p>HAVING is supported regardless of whether {@code GROUP BY} is 12335 * present: standard SQL allows {@code HAVING} without {@code GROUP BY} 12336 * (the whole result set is treated as a single group), and the parser 12337 * still attaches a {@link TGroupBy} node with empty 12338 * {@code getItems()} in that case. Both shapes flow through the same 12339 * collection path. 12340 * 12341 * <p>Per-shape rejections fire <i>before</i> {@link #collectColumnRefs} 12342 * so subquery / OVER children never enter the visitor and can't leak 12343 * inner-scope refs into {@code havingColumnRefs} (mirrors slice-9 12344 * ORDER BY guards): 12345 * 12346 * <ul> 12347 * <li>Scalar subqueries ({@link EExpressionType#subquery_t}) and 12348 * predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)}, 12349 * {@code ANY/ALL/SOME}) — checked via both expression-type and 12350 * {@link TExpression#getSubQuery()}, deep-scanned through the 12351 * whole HAVING expression subtree.</li> 12352 * <li>Window functions ({@code OVER (...)}) — standard SQL forbids 12353 * window functions in HAVING, but defense in depth: the 12354 * deep-scan rejecter ensures PARTITION BY / OVER ORDER BY refs 12355 * can't leak.</li> 12356 * </ul> 12357 * 12358 * <p>Aggregate functions in HAVING are <i>not</i> rejected — they're 12359 * the most common HAVING shape ({@code HAVING SUM(salary) > 1000}). 12360 * The visitor walks into the aggregate's argument list and captures 12361 * the underlying column ref ({@code salary}) the same way slice 6 12362 * does for projection-side aggregate args. 12363 */ 12364 private static List<ColumnRef> buildHavingColumnRefs(TSelectSqlStatement select, 12365 NameBindingProvider provider) { 12366 TGroupBy groupBy = select.getGroupByClause(); 12367 if (groupBy == null) return new ArrayList<>(); 12368 TExpression having = groupBy.getHavingClause(); 12369 if (having == null) return new ArrayList<>(); 12370 rejectHavingScalarSubquery(having); 12371 rejectHavingWindowFunction(having); 12372 return collectColumnRefs(having, provider); 12373 } 12374 12375 /** 12376 * Reject HAVING expressions that contain a subquery anywhere in the 12377 * subtree. Catches both: 12378 * 12379 * <ul> 12380 * <li>Scalar subqueries ({@link EExpressionType#subquery_t}) — 12381 * e.g. {@code HAVING (SELECT MAX(salary) FROM employees) > 0}.</li> 12382 * <li>Predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)}, 12383 * {@code ANY/ALL/SOME (SELECT ...)}) — these don't appear as 12384 * {@code subquery_t} expression nodes but carry a non-null 12385 * {@link TExpression#getSubQuery()}, e.g. 12386 * {@code HAVING EXISTS (SELECT 1 FROM ...)} or 12387 * {@code HAVING d.id IN (SELECT id FROM ...)}.</li> 12388 * </ul> 12389 * 12390 * <p>Mirrors {@link #rejectOrderByScalarSubquery}: top-level fast 12391 * path + visitor deep-scan over {@link TExpression#acceptChildren}. 12392 * The deep scan is required for nested cases like 12393 * {@code HAVING flag = 1 AND EXISTS (SELECT ...)} or 12394 * {@code HAVING CASE WHEN d.id IN (SELECT ...) THEN 1 ELSE 0 END > 0}. 12395 */ 12396 private static void rejectHavingScalarSubquery(TExpression having) { 12397 if (having.getExpressionType() == EExpressionType.subquery_t 12398 || having.getSubQuery() != null) { 12399 throw new SemanticIRBuildException( 12400 Diagnostic.error(DiagnosticCode.HAVING_SUBQUERY_NOT_SUPPORTED, 12401 "HAVING subquery '" + having + "' is not supported yet " 12402 + "(subqueries in HAVING would leak inner column refs)", having)); 12403 } 12404 final boolean[] found = {false}; 12405 having.acceptChildren(new TParseTreeVisitor() { 12406 @Override 12407 public void preVisit(TExpression e) { 12408 if (found[0]) return; 12409 if (e.getExpressionType() == EExpressionType.subquery_t 12410 || e.getSubQuery() != null) { 12411 found[0] = true; 12412 } 12413 } 12414 }); 12415 if (found[0]) { 12416 throw new SemanticIRBuildException( 12417 Diagnostic.error(DiagnosticCode.HAVING_HAS_SUBQUERY_NOT_SUPPORTED, 12418 "HAVING expression '" + having + "' contains a subquery " 12419 + "(scalar, EXISTS, IN, or ANY/ALL/SOME); not supported yet", having)); 12420 } 12421 } 12422 12423 /** 12424 * Reject HAVING expressions that contain a window function. Standard 12425 * SQL forbids window functions in HAVING (analytic functions are 12426 * computed after HAVING), but defense in depth: the visitor would 12427 * descend into {@code OVER (PARTITION BY ... ORDER BY ...)} and the 12428 * inner-scope refs would otherwise leak into {@code havingColumnRefs}. 12429 * Mirrors the projection-side {@link #rejectWindowFunctions} and the 12430 * ORDER BY-side {@link #rejectOrderByWindowFunction}. 12431 */ 12432 private static void rejectHavingWindowFunction(TExpression having) { 12433 final boolean[] found = {false}; 12434 having.acceptChildren(new TParseTreeVisitor() { 12435 @Override 12436 public void preVisit(TFunctionCall fn) { 12437 if (found[0]) return; 12438 if (fn.getWindowDef() != null) found[0] = true; 12439 } 12440 }); 12441 if (!found[0] && having.getExpressionType() == EExpressionType.function_t) { 12442 TFunctionCall fn = having.getFunctionCall(); 12443 if (fn != null && fn.getWindowDef() != null) found[0] = true; 12444 } 12445 if (found[0]) { 12446 throw new SemanticIRBuildException( 12447 Diagnostic.error(DiagnosticCode.HAVING_WINDOW_FUNCTION_NOT_SUPPORTED, 12448 "HAVING window function '" + having + "' is not supported yet " 12449 + "(window OVER (...) refs would leak into havingColumnRefs)", having)); 12450 } 12451 } 12452 12453 /** 12454 * Slice 125: collect physical column references from a {@code QUALIFY} 12455 * clause's predicate (Snowflake / BigQuery / Teradata). QUALIFY filters 12456 * rows on window-function results; like {@code WHERE} / {@code HAVING} 12457 * it is row-influence, so its refs are captured into 12458 * {@link StatementGraph#getQualifyColumnRefs()}. 12459 * 12460 * <p>Two surface forms are admitted and reduce to the SAME influencing 12461 * base columns: 12462 * <ul> 12463 * <li>Inline window form 12464 * ({@code QUALIFY ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) = 1}) 12465 * — the window's PARTITION BY / ORDER BY / argument refs bind as 12466 * base columns and are collected directly; the function-name 12467 * node and {@code *} are skipped.</li> 12468 * <li>Projection-alias form ({@code QUALIFY rn = 1}) — the parser 12469 * leaves {@code rn} as a bare {@code column}-typed 12470 * {@link TObjectName} (it is NOT retyped to {@code column_alias} 12471 * the way ORDER BY operands are), so it does not bind to a base 12472 * column; it resolves to the matching {@link OutputColumn} and 12473 * contributes that output's {@code getSources()} unioned with its 12474 * window spec's partition/order refs.</li> 12475 * </ul> 12476 * 12477 * <p>Window functions are admitted (the whole point of QUALIFY) and are 12478 * NOT validated against the slice-13 projection window-function 12479 * allowlist — QUALIFY collects only the refs that influence the row 12480 * set; it does not build a {@link WindowSpec}. Subqueries (scalar, 12481 * EXISTS, IN-SELECT, ANY/ALL/SOME) are rejected before ref collection 12482 * (they would leak inner-scope refs), reusing the repurposed 12483 * {@link DiagnosticCode#QUALIFY_NOT_SUPPORTED} code. 12484 */ 12485 private static List<ColumnRef> buildQualifyColumnRefs(TSelectSqlStatement select, 12486 NameBindingProvider provider, 12487 List<OutputColumn> outputColumns) { 12488 if (select.getQualifyClause() == null) { 12489 return new ArrayList<>(); 12490 } 12491 TExpression qualify = select.getQualifyClause().getSearchConditoin(); 12492 if (qualify == null) { 12493 return new ArrayList<>(); 12494 } 12495 rejectQualifySubquery(qualify); 12496 rejectUnsupportedQualifyWindowFunction(qualify); 12497 return collectQualifyColumnRefs(qualify, provider, outputColumns); 12498 } 12499 12500 /** 12501 * Slice 126: validate the function NAME of any INLINE window function 12502 * inside a {@code QUALIFY} predicate against the slice-13 12503 * {@link #WINDOW_FUNCTION_NAMES} allowlist. Slice 125 admitted any 12504 * function in QUALIFY (collecting only its influencing refs); this guard 12505 * applies the same name check {@link #buildWindowOutputColumn} uses for 12506 * projection windows so an unfamiliar windowed function whose semantics 12507 * the engine does not model — {@code QUALIFY FOO(a) OVER (ORDER BY b) = 1} 12508 * — rejects with {@link DiagnosticCode#WINDOW_FUNCTION_UNSUPPORTED} 12509 * instead of silently admitting. 12510 * 12511 * <p>An INLINE window function is a {@link TFunctionCall} carrying 12512 * {@code OVER (...)} ({@code getWindowDef() != null}). This is the SAME 12513 * signal {@code buildWindowOutputColumn} keys on. Non-window functions 12514 * are deliberately left alone: 12515 * <ul> 12516 * <li>a plain UDF / scalar call with no {@code OVER} 12517 * ({@code QUALIFY my_udf(a) = 1}), and</li> 12518 * <li>a non-windowed aggregate ({@code QUALIFY SUM(a) = 1})</li> 12519 * </ul> 12520 * both have {@code getWindowDef() == null} and stay admitted — slice 126 12521 * validates window-function NAMES, it does not require QUALIFY to contain 12522 * a window function (that would be a broader semantic rule outside this 12523 * slice). The projection-alias form ({@code QUALIFY rn = 1}) carries no 12524 * {@link TFunctionCall} in the QUALIFY expression (the window function 12525 * lives in the projection, already slice-13 validated), so the guard is a 12526 * no-op there. 12527 * 12528 * <p>Nested SELECTs are skipped (a window function in an inner scope is 12529 * not this statement's QUALIFY window). In practice none survive — 12530 * {@link #rejectQualifySubquery} runs first and rejects any subquery — but 12531 * the {@code nestedSelectDepth} guard mirrors 12532 * {@link #collectQualifyColumnRefs} for defense-in-depth and parity. 12533 * 12534 * <p>Reuses the existing {@link DiagnosticCode#WINDOW_FUNCTION_UNSUPPORTED} 12535 * code (already reached by {@link #buildWindowOutputColumn}) so the 12536 * DiagnosticCode count stays unchanged. 12537 */ 12538 private static void rejectUnsupportedQualifyWindowFunction(TExpression qualify) { 12539 qualify.acceptChildren(new TParseTreeVisitor() { 12540 int nestedSelectDepth = 0; 12541 12542 @Override 12543 public void preVisit(TSelectSqlStatement nested) { 12544 nestedSelectDepth++; 12545 } 12546 12547 @Override 12548 public void postVisit(TSelectSqlStatement nested) { 12549 nestedSelectDepth--; 12550 } 12551 12552 @Override 12553 public void preVisit(TFunctionCall fn) { 12554 if (nestedSelectDepth > 0) return; 12555 if (fn.getWindowDef() == null) return; 12556 String fnName = fn.getFunctionName() == null 12557 ? null : fn.getFunctionName().toString(); 12558 if (fnName == null 12559 || !WINDOW_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) { 12560 throw new SemanticIRBuildException( 12561 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_UNSUPPORTED, 12562 "QUALIFY uses unsupported window function '" + fnName 12563 + "'; supported names are " + WINDOW_FUNCTION_NAMES, fn)); 12564 } 12565 } 12566 }); 12567 } 12568 12569 /** 12570 * Reject {@code QUALIFY} expressions that contain a subquery anywhere 12571 * in the subtree — scalar ({@link EExpressionType#subquery_t}) or 12572 * predicate ({@code EXISTS}, {@code IN (SELECT ...)}, 12573 * {@code ANY/ALL/SOME}, carried via {@link TExpression#getSubQuery()}). 12574 * Mirrors {@link #rejectHavingScalarSubquery}: top-level fast path plus 12575 * a visitor deep-scan. Reuses the (slice-125-repurposed) 12576 * {@link DiagnosticCode#QUALIFY_NOT_SUPPORTED} code so the 12577 * DiagnosticCode count stays unchanged and the code stays reached. 12578 */ 12579 private static void rejectQualifySubquery(TExpression qualify) { 12580 boolean topLevelSubquery = qualify.getExpressionType() == EExpressionType.subquery_t 12581 || qualify.getSubQuery() != null; 12582 final boolean[] found = {topLevelSubquery}; 12583 if (!found[0]) { 12584 qualify.acceptChildren(new TParseTreeVisitor() { 12585 @Override 12586 public void preVisit(TExpression e) { 12587 if (found[0]) return; 12588 if (e.getExpressionType() == EExpressionType.subquery_t 12589 || e.getSubQuery() != null) { 12590 found[0] = true; 12591 } 12592 } 12593 }); 12594 } 12595 if (found[0]) { 12596 throw new SemanticIRBuildException( 12597 Diagnostic.error(DiagnosticCode.QUALIFY_NOT_SUPPORTED, 12598 "QUALIFY subquery '" + qualify + "' is not supported yet " 12599 + "(subqueries in QUALIFY would leak inner column refs)", qualify)); 12600 } 12601 } 12602 12603 /** 12604 * Slice 125: QUALIFY-specific column-ref collector. Mirrors 12605 * {@link #collectColumnRefs} / {@link #appendMergedOrBoundColumnRef} 12606 * (nested-SELECT skip, UsingScope merged-key expansion, EXACT_MATCH 12607 * binding) but adds a projection-alias fallback: an unqualified 12608 * {@code column}-typed {@link TObjectName} that does not bind to a base 12609 * column but matches an {@link OutputColumn} name resolves to that 12610 * output's influencing base columns ({@code getSources()} unioned with 12611 * its window spec's partition/order refs). This captures the canonical 12612 * {@code QUALIFY rn = 1} form, where {@code rn} aliases a window 12613 * projection whose own {@code getSources()} is empty. 12614 */ 12615 private static List<ColumnRef> collectQualifyColumnRefs(TExpression qualify, 12616 final NameBindingProvider provider, 12617 final List<OutputColumn> outputColumns) { 12618 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 12619 final List<String> rejects = new ArrayList<>(); 12620 // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at 12621 // the wrong side). Such a miss is a genuine error and must stay fatal; 12622 // only all-unqualified rejects are eligible for the join-graph degrade. 12623 final boolean[] sawQualifiedReject = {false}; 12624 qualify.acceptChildren(new TParseTreeVisitor() { 12625 int nestedSelectDepth = 0; 12626 12627 @Override 12628 public void preVisit(TSelectSqlStatement nested) { 12629 nestedSelectDepth++; 12630 } 12631 12632 @Override 12633 public void postVisit(TSelectSqlStatement nested) { 12634 nestedSelectDepth--; 12635 } 12636 12637 @Override 12638 public void preVisit(TObjectName node) { 12639 if (nestedSelectDepth > 0) return; 12640 if (node.getDbObjectType() != EDbObjectType.column) return; 12641 String name = node.getColumnNameOnly(); 12642 if (name == null || "*".equals(name)) return; 12643 UsingScope scope = provider.getUsingScope(); 12644 String qualifier = node.getTableString(); 12645 boolean unqualified = qualifier == null || qualifier.isEmpty(); 12646 if (unqualified && scope.has(name)) { 12647 if (scope.isAmbiguous(name)) { 12648 throw new SemanticIRBuildException( 12649 Diagnostic.error(DiagnosticCode.UNQUALIFIED_COLUMN_AMBIGUOUS, 12650 "unqualified reference to '" + name + "' is ambiguous: " 12651 + scope.ambiguityReason(name) 12652 + "; qualify with a table alias", null)); 12653 } 12654 refs.addAll(scope.mergedSourcesFor(name)); 12655 return; 12656 } 12657 ColumnBinding binding = provider.bindColumn(node); 12658 if (binding != null && binding.getStatus() == ResolutionStatus.EXACT_MATCH) { 12659 refs.add(new ColumnRef(binding.getRelationAlias(), binding.getColumnName())); 12660 return; 12661 } 12662 // Base-column binding failed. Projection-alias fallback: 12663 // an unqualified name that matches an output column's name 12664 // (case-insensitive) resolves to that output's influencing 12665 // base columns. QUALIFY (post-window) can reference SELECT 12666 // aliases in Snowflake / BigQuery. 12667 if (unqualified) { 12668 List<ColumnRef> aliasSources = resolveQualifyProjectionAlias(name, outputColumns); 12669 if (aliasSources != null) { 12670 refs.addAll(aliasSources); 12671 return; 12672 } 12673 // Catalog-less NATURAL JOIN degrade (GSP R6): unqualified 12674 // merged NATURAL key resolves to the left-side relation. 12675 if (scope.hasNaturalDegrade()) { 12676 refs.add(new ColumnRef(scope.naturalDegradeFallbackAlias(), name)); 12677 return; 12678 } 12679 } 12680 rejects.add(node + (binding == null ? "[no binding]" 12681 : "[" + binding.getStatus() + "]")); 12682 if (!unqualified) { 12683 sawQualifiedReject[0] = true; 12684 } 12685 } 12686 }); 12687 if (!rejects.isEmpty()) { 12688 rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]); 12689 } 12690 return new ArrayList<>(refs); 12691 } 12692 12693 /** 12694 * Slice 125: resolve an unqualified QUALIFY reference name to the 12695 * influencing base columns of the matching projection output column. 12696 * The influencing columns are the output's 12697 * {@link OutputColumn#getSources()} unioned with its window spec's 12698 * {@link WindowSpec#getPartitionRefs()} and 12699 * {@link WindowSpec#getOrderRefs()} — a window output's own 12700 * {@code getSources()} is empty, so the partition/order refs are where 12701 * the row-influencing base columns live. 12702 * 12703 * <p>Return contract (the caller distinguishes the two non-throwing 12704 * cases by {@code null} vs empty): 12705 * <ul> 12706 * <li>{@code null} — no output column matches by name; the caller 12707 * records a {@code COLUMN_BINDING_NON_EXACT} reject.</li> 12708 * <li>non-empty list — the matched output's influencing base 12709 * columns.</li> 12710 * <li><b>empty list</b> — an output matched but it has no influencing 12711 * base columns (a constant projection such as {@code SELECT 1 AS x} 12712 * referenced by {@code QUALIFY x = 1}, or any sourceless / 12713 * window-less projection). This is an admitted, deliberate 12714 * boundary: a QUALIFY predicate over a column-free output has no 12715 * column dependency, exactly like a constant {@code WHERE 1 = 1} 12716 * contributes no filter refs. It is NOT treated as a reject — the 12717 * ref resolves successfully and simply contributes no base-column 12718 * dependency.</li> 12719 * </ul> 12720 * 12721 * <p>Match strategy: case-insensitive ({@link Locale#ROOT}), FIRST 12722 * (leftmost) match — identical to {@link #tryResolveOrderByProjectionAlias}. 12723 * Duplicate projection aliases ({@code SELECT a AS rn, b AS rn ... 12724 * QUALIFY rn = 1}) therefore resolve to the leftmost projection rather 12725 * than rejecting as ambiguous; this is the documented slice-69 ORDER BY 12726 * boundary applied to QUALIFY for cross-clause consistency. 12727 * 12728 * <p>Resolution order in the caller is base-column-first: a name that 12729 * binds {@code EXACT_MATCH} to a base column is emitted directly and 12730 * never reaches this fallback, which fires only for an UNQUALIFIED ref 12731 * whose base binding failed. A qualified ref ({@code QUALIFY e.rn}) is 12732 * never alias-resolved here (qualifying a projection alias is invalid 12733 * SQL); it binds through the provider like any other qualified ref. 12734 */ 12735 private static List<ColumnRef> resolveQualifyProjectionAlias(String name, 12736 List<OutputColumn> outputColumns) { 12737 String key = name.toLowerCase(Locale.ROOT); 12738 for (OutputColumn oc : outputColumns) { 12739 String outName = oc.getName(); 12740 if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) { 12741 LinkedHashSet<ColumnRef> influencing = new LinkedHashSet<>(oc.getSources()); 12742 WindowSpec ws = oc.getWindowSpec(); 12743 if (ws != null) { 12744 influencing.addAll(ws.getPartitionRefs()); 12745 influencing.addAll(ws.getOrderRefs()); 12746 } 12747 return new ArrayList<>(influencing); 12748 } 12749 } 12750 return null; 12751 } 12752 12753 /** 12754 * Collect physical column references from {@code ORDER BY} sort keys. 12755 * 12756 * <p>Per-item validation rejects shapes that would otherwise vanish 12757 * silently into an empty ref list, leak inner-scope refs, or 12758 * misrepresent presentation as a dependency: 12759 * 12760 * <ul> 12761 * <li>Ordinal references ({@code ORDER BY 1}) — the sort key is a 12762 * {@link EExpressionType#simple_constant_t}; its meaning is 12763 * "first projected column" which depends on the SELECT list, 12764 * not on a base column. A future slice can model output-position 12765 * references explicitly.</li> 12766 * <li>Constant sort keys other than ordinals ({@code ORDER BY 'x'}, 12767 * and the compound {@code ORDER BY (1)} / {@code ORDER BY 1+0} 12768 * caught by the generic no-physical-column-refs check).</li> 12769 * <li>Projection-alias references ({@code ORDER BY x} where 12770 * {@code x} is a SELECT alias) — {@link TOrderByItem#doParse} 12771 * retypes the operand to {@link TObjectName#ttobjColumnAlias}, 12772 * which lowers {@link TObjectName#getDbObjectType()} to 12773 * {@link EDbObjectType#column_alias}. Without explicit 12774 * rejection the visitor would skip it and the IR would lose 12775 * the dependency entirely. The deep-scan version of this 12776 * check catches alias nodes nested inside expressions.</li> 12777 * <li>Subqueries in sort keys — scalar 12778 * ({@link EExpressionType#subquery_t}) and predicate 12779 * ({@code EXISTS}, {@code IN (SELECT ...)}, {@code ANY/ALL/SOME}) 12780 * — would otherwise leak inner-scope column refs into the 12781 * outer statement's {@code orderByColumnRefs}.</li> 12782 * <li>Window functions in sort keys ({@code ORDER BY ROW_NUMBER() 12783 * OVER (...)}) — the OVER clause descends through the visitor 12784 * and would leak its PARTITION BY / ORDER BY refs.</li> 12785 * </ul> 12786 * 12787 * <p>Sub-clauses that change row-set semantics are also rejected 12788 * here: Oracle {@code ORDER SIBLINGS BY} (hierarchical, not yet 12789 * modelled), Teradata {@code RESET WHEN} (window-style restart), 12790 * and the {@link TOrderBy}-level {@code FETCH FIRST}/{@code OFFSET} 12791 * defensive guards (in fresh parses the SELECT-level row-limit 12792 * guards in {@link #rejectUnsupportedShape} fire first because 12793 * {@code TSelectSqlNode.setOrderbyClause()} copies in-clause OFFSET/ 12794 * FETCH onto the SELECT node). 12795 * 12796 * <p>For everything else (qualified column refs, expressions like 12797 * {@code UPPER(name)}), {@link #collectColumnRefs} runs over each 12798 * sort key and aggregates the physical column refs. A per-item 12799 * empty-refs check catches anything that slipped past the explicit 12800 * shape rejections (e.g. {@code ORDER BY (1)}, 12801 * {@code ORDER BY 1 + 0}). Sort direction ({@code ASC}/{@code DESC}) 12802 * and null placement ({@code NULLS FIRST}/{@code NULLS LAST}) are 12803 * presentation metadata and are not modelled. 12804 */ 12805 private static List<ColumnRef> buildOrderByColumnRefs(TSelectSqlStatement select, 12806 NameBindingProvider provider, 12807 List<OutputColumn> outputColumns) { 12808 TOrderBy orderBy = select.getOrderbyClause(); 12809 if (orderBy == null) { 12810 return new ArrayList<>(); 12811 } 12812 if (orderBy.isSiblings()) { 12813 throw new SemanticIRBuildException( 12814 Diagnostic.error(DiagnosticCode.ORDER_SIBLINGS_BY_NOT_SUPPORTED, 12815 "ORDER SIBLINGS BY is not supported yet " 12816 + "(Oracle hierarchical ordering)", orderBy)); 12817 } 12818 if (orderBy.getResetWhenCondition() != null) { 12819 throw new SemanticIRBuildException( 12820 Diagnostic.error(DiagnosticCode.ORDER_BY_RESET_WHEN_NOT_SUPPORTED, 12821 "ORDER BY ... RESET WHEN is not supported yet " 12822 + "(Teradata window-style restart)", orderBy)); 12823 } 12824 // Slice 71: the in-clause OFFSET/FETCH on TOrderBy is no longer 12825 // rejected. MSSQL parsers duplicate OFFSET/FETCH onto BOTH the 12826 // SELECT node AND the TOrderBy node; slice 71 admits at the 12827 // SELECT level via buildRowLimit, so the TOrderBy duplicates 12828 // are simply ignored here. Oracle parsers populate only the 12829 // SELECT-level fields, so the TOrderBy fields are typically 12830 // null there. 12831 TOrderByItemList items = orderBy.getItems(); 12832 if (items == null || items.size() == 0) { 12833 return new ArrayList<>(); 12834 } 12835 // Validate + collect per item so a sort key contributing zero 12836 // column refs (e.g. constant arithmetic, parenthesised constant) 12837 // is rejected with an item-specific message instead of silently 12838 // disappearing. 12839 LinkedHashSet<ColumnRef> all = new LinkedHashSet<>(); 12840 for (int i = 0; i < items.size(); i++) { 12841 TOrderByItem item = items.getOrderByItem(i); 12842 if (item == null) continue; 12843 TExpression sortKey = item.getSortKey(); 12844 if (sortKey == null) continue; 12845 // Slice 68: positive-integer ordinals admit. The helper: 12846 // - returns null when sortKey is not a positive-integer 12847 // literal (caller falls through to the existing 12848 // constant / alias / subquery / window rejecters and the 12849 // standard ref collection); 12850 // - returns the matching output column's sources list when 12851 // sortKey IS a positive-integer literal in range; 12852 // - throws ORDER_BY_ORDINAL_OUT_OF_RANGE when the ordinal 12853 // is 0 or exceeds the output column count. 12854 // The sourceless-output case (e.g. SELECT 1 FROM t ORDER BY 1 12855 // or SELECT COUNT(*) FROM t ORDER BY 1) returns an empty 12856 // list and falls through to the per-item empty-refs guard 12857 // below, mirroring the existing ORDER BY COUNT(*) / 12858 // ORDER BY 1 + 0 rejection. 12859 List<ColumnRef> ordinalSources = tryResolveOrderByOrdinal(sortKey, outputColumns); 12860 if (ordinalSources != null) { 12861 if (ordinalSources.isEmpty()) { 12862 throw new SemanticIRBuildException( 12863 Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS, 12864 "ORDER BY ordinal '" + sortKey 12865 + "' resolves to output column with no physical column references " 12866 + "(constant or sourceless aggregate output)", sortKey)); 12867 } 12868 all.addAll(ordinalSources); 12869 continue; 12870 } 12871 // Slice 69: top-level projection-alias references admit. The 12872 // helper returns null for non-alias shapes; an empty list 12873 // (alias of a constant / sourceless aggregate) falls through 12874 // to ORDER_BY_NO_PHYSICAL_COLUMN_REFS, mirroring the slice-68 12875 // sourceless-ordinal handling. Deep-scan alias references 12876 // (e.g. ORDER BY UPPER(<alias>)) are still caught by 12877 // rejectOrderByAliasReference below. 12878 List<ColumnRef> aliasSources = tryResolveOrderByProjectionAlias(sortKey, outputColumns); 12879 if (aliasSources != null) { 12880 if (aliasSources.isEmpty()) { 12881 throw new SemanticIRBuildException( 12882 Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS, 12883 "ORDER BY projection alias '" + sortKey 12884 + "' resolves to output column with no physical column references " 12885 + "(constant or sourceless aggregate output)", sortKey)); 12886 } 12887 all.addAll(aliasSources); 12888 continue; 12889 } 12890 // Slice 68: non-ordinal constants stay rejected. The original 12891 // helper is preserved for the set-op outer path (which keeps 12892 // its ordinal/constant rejection until slice 72). 12893 rejectOrderByNonOrdinalConstant(sortKey); 12894 // Slice 69: top-level bare alias references are consumed by 12895 // tryResolveOrderByProjectionAlias above; this helper now only 12896 // catches DEEP alias references nested inside compound 12897 // expressions (e.g. ORDER BY UPPER(<alias>)). 12898 rejectOrderByAliasReference(sortKey); 12899 // Reject scalar subqueries and window functions BEFORE 12900 // collecting refs. The visitor descends into both, so without 12901 // these guards `ORDER BY (SELECT MAX(salary) FROM employees)` 12902 // and `ORDER BY ROW_NUMBER() OVER (ORDER BY salary)` would 12903 // leak inner refs into orderByColumnRefs as if the outer 12904 // statement physically depended on them. 12905 rejectOrderByScalarSubquery(sortKey); 12906 rejectOrderByWindowFunction(sortKey); 12907 List<ColumnRef> itemRefs = collectColumnRefs(item, provider); 12908 if (itemRefs.isEmpty()) { 12909 // Anything else that produces no physical column refs: 12910 // ORDER BY (1), ORDER BY 1+0, ORDER BY NULL, ORDER BY 12911 // CASE WHEN 1=1 THEN 'a' END, etc. Reject so the IR 12912 // doesn't silently emit empty refs. 12913 throw new SemanticIRBuildException( 12914 Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS, 12915 "ORDER BY sort key '" + sortKey + "' has no physical column references " 12916 + "(constant or non-column expressions are not supported yet)", sortKey)); 12917 } 12918 all.addAll(itemRefs); 12919 } 12920 return new ArrayList<>(all); 12921 } 12922 12923 /** 12924 * Slice 68: resolve a positive-integer ORDER BY ordinal to the matching 12925 * output column's sources. Returns: 12926 * 12927 * <ul> 12928 * <li>{@code null} if {@code sortKey} is not a positive-integer 12929 * literal (caller continues with the constant / alias / subquery 12930 * / window rejecters and the standard ref collection);</li> 12931 * <li>a {@link List} of {@link ColumnRef}s — the source list of the 12932 * output column at position {@code v - 1} (1-based ordinals);</li> 12933 * <li>throws {@link SemanticIRBuildException} with 12934 * {@code ORDER_BY_ORDINAL_OUT_OF_RANGE} when {@code v} is 0 or 12935 * exceeds the output column count.</li> 12936 * </ul> 12937 * 12938 * <p>{@code sortKey.getExpressionType() == simple_constant_t} for bare 12939 * positive integers; negative integers parse as a {@code unary_minus_t} 12940 * over a {@code simple_constant_t} and are not handled here. Compound 12941 * constant expressions ({@code ORDER BY 1 + 0}, {@code ORDER BY (1)}) 12942 * are {@code arithmetic_*_t} / {@code parenthesis_t} respectively and 12943 * fall through to the per-item empty-refs guard. 12944 * 12945 * <p>The empty-list case (output column resolved with 12946 * {@link OutputColumn#getSources()} empty — constant projections, 12947 * {@code COUNT(*)}, sourceless aggregates) is returned to the caller 12948 * which fires {@code ORDER_BY_NO_PHYSICAL_COLUMN_REFS}. Slice 68 12949 * boundary. 12950 * 12951 * <p>Sort direction (ASC/DESC) and null placement (NULLS FIRST/LAST) 12952 * are presentation metadata on {@link TOrderByItem}, not on the sort 12953 * key expression; this helper doesn't inspect them (slice 9 decision). 12954 */ 12955 private static List<ColumnRef> tryResolveOrderByOrdinal(TExpression sortKey, 12956 List<OutputColumn> outputs) { 12957 if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) { 12958 return null; 12959 } 12960 String txt = sortKey.toString(); 12961 if (txt == null || !txt.matches("\\d+")) { 12962 return null; 12963 } 12964 long v; 12965 try { 12966 v = Long.parseLong(txt); 12967 } catch (NumberFormatException e) { 12968 // Very-long-digit text overflows long; definitely out of range. 12969 throw new SemanticIRBuildException( 12970 Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_OUT_OF_RANGE, 12971 "ORDER BY ordinal '" + sortKey + "' is out of range " 12972 + "(must be between 1 and " + outputs.size() + ")", sortKey)); 12973 } 12974 if (v < 1 || v > outputs.size()) { 12975 throw new SemanticIRBuildException( 12976 Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_OUT_OF_RANGE, 12977 "ORDER BY ordinal '" + sortKey + "' is out of range " 12978 + "(must be between 1 and " + outputs.size() + ")", sortKey)); 12979 } 12980 return outputs.get((int) v - 1).getSources(); 12981 } 12982 12983 /** 12984 * Slice 69: resolve a top-level bare projection-alias ORDER BY sort 12985 * key to the matching output column's sources. Returns: 12986 * 12987 * <ul> 12988 * <li>{@code null} if {@code sortKey} is not a top-level bare 12989 * {@code simple_object_name_t} whose object operand has 12990 * {@code dbObjectType == EDbObjectType.column_alias} (caller 12991 * continues with {@link #rejectOrderByAliasReference} for the 12992 * deep-scan case and the standard column-ref collection);</li> 12993 * <li>a {@link List} of {@link ColumnRef}s — the matching output's 12994 * source list (which may be empty when the aliased projection 12995 * is a constant or sourceless aggregate; the caller fires 12996 * {@code ORDER_BY_NO_PHYSICAL_COLUMN_REFS} in that case);</li> 12997 * <li>throws {@link SemanticIRBuildException} with 12998 * {@code ORDER_BY_PROJECTION_ALIAS_NOT_SUPPORTED} when the 12999 * parser retyped the operand to {@code column_alias} but no 13000 * matching output exists by case-insensitive name (defensive; 13001 * theoretically unreachable for parsable SQL).</li> 13002 * </ul> 13003 * 13004 * <p>Match strategy: case-insensitive ({@link Locale#ROOT}) on 13005 * {@link OutputColumn#getName()}, returning the FIRST match. This 13006 * mirrors the set-op outer alias matcher in 13007 * {@link #processSetOpOrderByObjectName} (which uses the identical 13008 * {@code toLowerCase(Locale.ROOT)} pattern and {@code break}s on 13009 * first match) and follows MySQL / PostgreSQL ORDER BY alias 13010 * resolution semantics. Duplicate aliases (e.g. {@code SELECT a AS x, 13011 * b AS x FROM t ORDER BY x}) resolve to the leftmost matching 13012 * projection; this is the documented slice-69 boundary. 13013 * 13014 * <p>The set-op outer alias path 13015 * ({@link #buildSetOpOuterOrderByColumnRefs} → 13016 * {@link #processSetOpOrderByObjectName}) was already admitted by 13017 * slice 21 and is independent of this helper. 13018 * 13019 * <p>Deep-scan alias references inside compound expressions 13020 * ({@code ORDER BY UPPER(<alias>)}) are NOT handled here — the 13021 * parser only retypes the top-level operand to {@code column_alias}; 13022 * inside nested expressions the alias may or may not be retyped by 13023 * resolver2 depending on schema heuristics. The slice-9 13024 * {@code orderByNestedAliasReferenceIsHandledSafely} contract is 13025 * preserved: deep alias refs are caught by 13026 * {@link #rejectOrderByAliasReference} with 13027 * {@code ORDER_BY_UNSUPPORTED_SORT_KEY_SHAPE} or by a binding 13028 * failure. 13029 */ 13030 private static List<ColumnRef> tryResolveOrderByProjectionAlias( 13031 TExpression sortKey, List<OutputColumn> outputs) { 13032 if (sortKey.getExpressionType() != EExpressionType.simple_object_name_t) { 13033 return null; 13034 } 13035 TObjectName op = sortKey.getObjectOperand(); 13036 if (op == null || op.getDbObjectType() != EDbObjectType.column_alias) { 13037 return null; 13038 } 13039 String name = op.toString(); 13040 if (name == null || name.isEmpty()) { 13041 return null; 13042 } 13043 String key = name.toLowerCase(Locale.ROOT); 13044 for (OutputColumn oc : outputs) { 13045 String outName = oc.getName(); 13046 if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) { 13047 return oc.getSources(); 13048 } 13049 } 13050 throw new SemanticIRBuildException( 13051 Diagnostic.error(DiagnosticCode.ORDER_BY_PROJECTION_ALIAS_NOT_SUPPORTED, 13052 "ORDER BY projection alias '" + sortKey 13053 + "' does not match any output column " 13054 + "(defensive — parser retyped to column_alias " 13055 + "but no output by that name)", sortKey)); 13056 } 13057 13058 /** 13059 * Slice 68: reject ORDER BY sort keys that are constants but NOT 13060 * positive-integer ordinals. The positive-integer ordinal case is 13061 * admitted separately by {@link #tryResolveOrderByOrdinal} which maps 13062 * the ordinal to the matching output column's sources. This helper 13063 * handles the remaining constant shapes ({@code ORDER BY 'x'}, 13064 * {@code ORDER BY 3.14}) — none of which reference an output position 13065 * and so contribute no column dependency. 13066 * 13067 * <p>The set-op outer ORDER BY path 13068 * ({@link #buildSetOpOuterOrderByColumnRefs}) keeps the original 13069 * {@link #rejectOrderByOrdinalOrConstant} helper so ordinals at that 13070 * scope stay rejected (slice 68 lifts only the single-SELECT case; 13071 * slice 72 will lift set-op outer). 13072 */ 13073 private static void rejectOrderByNonOrdinalConstant(TExpression sortKey) { 13074 if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) { 13075 return; 13076 } 13077 String txt = sortKey.toString(); 13078 boolean looksOrdinal = txt != null && txt.matches("\\d+"); 13079 if (looksOrdinal) { 13080 // Admitted by tryResolveOrderByOrdinal; this helper is a no-op 13081 // for positive-integer ordinals. 13082 return; 13083 } 13084 throw new SemanticIRBuildException( 13085 Diagnostic.error(DiagnosticCode.ORDER_BY_CONSTANT_NOT_SUPPORTED, 13086 "ORDER BY constant '" + sortKey + "' is not supported yet " 13087 + "(constant sort keys add no column dependency)", sortKey)); 13088 } 13089 13090 /** 13091 * Reject ORDER BY sort keys that contain a subquery anywhere in the 13092 * subtree. Catches both: 13093 * 13094 * <ul> 13095 * <li>Scalar subqueries ({@link EExpressionType#subquery_t}) — 13096 * e.g. {@code ORDER BY (SELECT MAX(salary) FROM employees)}.</li> 13097 * <li>Predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)}, 13098 * {@code ANY/ALL/SOME (SELECT ...)}) — these don't appear as a 13099 * {@code subquery_t} expression but carry a non-null 13100 * {@link TExpression#getSubQuery()}, e.g. 13101 * {@code ORDER BY CASE WHEN EXISTS (SELECT 1 FROM t WHERE ...) 13102 * THEN 0 ELSE 1 END}.</li> 13103 * </ul> 13104 * 13105 * <p>The visitor descends into the subquery body, so without an 13106 * explicit reject the inner-scope refs would leak into the outer 13107 * statement's {@code orderByColumnRefs}. The same restriction is 13108 * applied to scalar subqueries in projection (see 13109 * {@link #buildOutputColumns}). 13110 */ 13111 private static void rejectOrderByScalarSubquery(TExpression sortKey) { 13112 // Top-level fast path: scalar-subquery message for the common case. 13113 if (sortKey.getExpressionType() == EExpressionType.subquery_t 13114 || sortKey.getSubQuery() != null) { 13115 throw new SemanticIRBuildException( 13116 Diagnostic.error(DiagnosticCode.ORDER_BY_SUBQUERY_NOT_SUPPORTED, 13117 "ORDER BY subquery '" + sortKey + "' is not supported yet " 13118 + "(subqueries in sort keys would leak inner column refs)", sortKey)); 13119 } 13120 // Deep scan: any nested expression that owns a subquery (scalar, 13121 // EXISTS, IN (SELECT ...), ANY/ALL/SOME) makes the sort key 13122 // out of scope. 13123 final boolean[] found = {false}; 13124 sortKey.acceptChildren(new TParseTreeVisitor() { 13125 @Override 13126 public void preVisit(TExpression e) { 13127 if (found[0]) return; 13128 if (e.getExpressionType() == EExpressionType.subquery_t 13129 || e.getSubQuery() != null) { 13130 found[0] = true; 13131 } 13132 } 13133 }); 13134 if (found[0]) { 13135 throw new SemanticIRBuildException( 13136 Diagnostic.error(DiagnosticCode.ORDER_BY_HAS_SUBQUERY_NOT_SUPPORTED, 13137 "ORDER BY sort key '" + sortKey + "' contains a subquery " 13138 + "(scalar, EXISTS, IN, or ANY/ALL/SOME); not supported yet", sortKey)); 13139 } 13140 } 13141 13142 /** 13143 * Reject ORDER BY sort keys that contain a window function. Window 13144 * functions descend through {@link TFunctionCall#acceptChildren()} so 13145 * their PARTITION BY / ORDER BY column refs would otherwise leak into 13146 * the outer statement's {@code orderByColumnRefs}. Mirrors the 13147 * projection-side {@link #rejectWindowFunctions}, but wired through 13148 * the ORDER BY item-walk instead of the result-column list. 13149 */ 13150 private static void rejectOrderByWindowFunction(TExpression sortKey) { 13151 final boolean[] found = {false}; 13152 sortKey.acceptChildren(new TParseTreeVisitor() { 13153 @Override 13154 public void preVisit(TFunctionCall fn) { 13155 if (found[0]) return; 13156 if (fn.getWindowDef() != null) found[0] = true; 13157 } 13158 }); 13159 if (!found[0] && sortKey.getExpressionType() == EExpressionType.function_t) { 13160 TFunctionCall fn = sortKey.getFunctionCall(); 13161 if (fn != null && fn.getWindowDef() != null) found[0] = true; 13162 } 13163 if (found[0]) { 13164 throw new SemanticIRBuildException( 13165 Diagnostic.error(DiagnosticCode.ORDER_BY_WINDOW_FUNCTION_NOT_SUPPORTED, 13166 "ORDER BY window function '" + sortKey + "' is not supported yet " 13167 + "(window OVER (...) refs would leak into orderByColumnRefs)", sortKey)); 13168 } 13169 } 13170 13171 /** 13172 * Reject ORDER BY sort keys that are bare constants. Splits the 13173 * message between integer ordinals (which reference the SELECT 13174 * position) and other constants (which add no column dependency). The 13175 * generic no-physical-column-refs check in 13176 * {@link #buildOrderByColumnRefs} catches compound cases like 13177 * {@code ORDER BY (1)} or {@code ORDER BY 1 + 0}. 13178 * 13179 * <p><b>Slice 68:</b> the single-SELECT call site no longer uses this 13180 * helper because positive-integer ordinals now resolve to the matching 13181 * output column's sources (see {@link #tryResolveOrderByOrdinal}). 13182 * This helper remains for {@link #buildSetOpOuterOrderByColumnRefs}, 13183 * where ordinal lifting is deferred to slice 72 (set-op outer 13184 * ORDER BY needs output-position references against the set-op output 13185 * row type, not the single-SELECT output column list). 13186 */ 13187 private static void rejectOrderByOrdinalOrConstant(TExpression sortKey) { 13188 if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) { 13189 return; 13190 } 13191 String txt = sortKey.toString(); 13192 boolean looksOrdinal = txt != null && txt.matches("\\d+"); 13193 if (looksOrdinal) { 13194 throw new SemanticIRBuildException( 13195 Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_NOT_SUPPORTED, 13196 "ORDER BY ordinal '" + sortKey + "' is not supported yet " 13197 + "(reference the column or expression directly)", sortKey)); 13198 } 13199 throw new SemanticIRBuildException( 13200 Diagnostic.error(DiagnosticCode.ORDER_BY_CONSTANT_NOT_SUPPORTED, 13201 "ORDER BY constant '" + sortKey + "' is not supported yet " 13202 + "(constant sort keys add no column dependency)", sortKey)); 13203 } 13204 13205 /** 13206 * Reject ORDER BY sort keys that contain a projection-alias reference 13207 * NESTED inside a compound expression (e.g. 13208 * {@code ORDER BY UPPER(<alias>)}). The visitor in 13209 * {@link #collectColumnRefs} skips column-alias nodes, so without an 13210 * explicit reject the IR would emit no column refs for them. 13211 * 13212 * <p><b>Slice 69:</b> the top-level bare-alias case (e.g. 13213 * {@code ORDER BY <alias>}) is now consumed by 13214 * {@link #tryResolveOrderByProjectionAlias} BEFORE this helper runs. 13215 * Only the deep-scan branch remains here; the top-level fast-path 13216 * was removed because it became unreachable. 13217 * 13218 * <p>{@link TOrderByItem#doParse} only retypes the top-level operand 13219 * to {@link TObjectName#ttobjColumnAlias} → dbObjectType 13220 * {@link EDbObjectType#column_alias}; inside nested expressions the 13221 * alias may or may not be retyped by resolver2 depending on schema 13222 * heuristics. Slice 9's 13223 * {@code orderByNestedAliasReferenceIsHandledSafely} documents the 13224 * three acceptable outcomes for deep aliases (reject by binding 13225 * failure, reject by this deep scan, or accept with a real column 13226 * dependency captured). 13227 */ 13228 private static void rejectOrderByAliasReference(TExpression sortKey) { 13229 // Deep scan: an alias node nested inside an expression 13230 // (e.g. ORDER BY UPPER(x) where x is an alias) would otherwise be 13231 // silently dropped by the column-only visitor. The top-level 13232 // bare-alias case is consumed earlier by 13233 // tryResolveOrderByProjectionAlias (slice 69 lift). 13234 final boolean[] foundAlias = {false}; 13235 final String[] aliasName = {null}; 13236 sortKey.acceptChildren(new TParseTreeVisitor() { 13237 @Override 13238 public void preVisit(TObjectName node) { 13239 if (foundAlias[0]) return; 13240 if (node.getDbObjectType() == EDbObjectType.column_alias) { 13241 foundAlias[0] = true; 13242 aliasName[0] = node.toString(); 13243 } 13244 } 13245 }); 13246 if (foundAlias[0]) { 13247 throw new SemanticIRBuildException( 13248 Diagnostic.error(DiagnosticCode.ORDER_BY_UNSUPPORTED_SORT_KEY_SHAPE, 13249 "ORDER BY sort key '" + sortKey 13250 + "' contains a projection alias reference '" 13251 + aliasName[0] + "'; not supported yet " 13252 + "(reference the underlying column directly)", sortKey)); 13253 } 13254 } 13255 13256 /** 13257 * Reject SELECT shapes outside current builder scope. The 13258 * {@code skipCteListCheck} flag is true only for the outer SELECT of a 13259 * WITH-bearing query whose CTEs were already extracted by 13260 * {@link #build}; nested WITH inside a CTE body is still rejected. 13261 */ 13262 private static void rejectUnsupportedShape(TSelectSqlStatement select, boolean skipCteListCheck) { 13263 // Slice 12: top-level set-ops and CTE-body set-ops are dispatched 13264 // by build() to buildSetOpProgram BEFORE buildSelectStatement is 13265 // called. This rejection still fires when buildSelectStatement 13266 // is called from a recursive context (FROM-subquery / scalar-body 13267 // extraction) where the inner SELECT happens to be a set-op — 13268 // those nested cases remain out of scope. 13269 if (select.getSetOperatorType() != null && select.getSetOperatorType() != ESetOperatorType.none) { 13270 throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SET_OPERATION_NOT_SUPPORTED_IN_CONTEXT, "set operations (UNION/INTERSECT/MINUS) are not supported in this context yet", select)); 13271 } 13272 if (!skipCteListCheck && select.getCteList() != null && select.getCteList().size() > 0) { 13273 throw new SemanticIRBuildException( 13274 Diagnostic.error(DiagnosticCode.NESTED_WITH_NOT_SUPPORTED, 13275 "nested WITH/CTE inside a CTE body or subquery is not supported yet", select)); 13276 } 13277 // DISTINCT / UNIQUE / ALL handling is done in resolveDistinctFlag() 13278 // (called from buildSelectStatement). Only rejected row-filter shapes 13279 // bubble up as a SemanticIRBuildException; the rest become the 13280 // StatementGraph.distinct flag. 13281 // Slice 6 lifted GROUP BY; slice 10 lifted HAVING. The HAVING 13282 // expression itself (and the per-shape rejections for subqueries 13283 // and window functions inside it) are handled in 13284 // buildHavingColumnRefs so the rejection messages can mention the 13285 // specific shape. 13286 // Slices 70 and 71: all single-SELECT row-limit admit/reject 13287 // decisions live in buildRowLimit(select). rejectUnsupportedShape 13288 // no longer carries any row-limit logic. Set-op outer row-limits 13289 // remain handled by rejectSetOpRowLimit (slice 72 lifts). 13290 // Slice 125 lifted the slice-13 blanket QUALIFY reject. The 13291 // QUALIFY clause is now admitted and its filter refs collected in 13292 // buildQualifyColumnRefs (called from buildSelectStatementImpl, 13293 // which has the outputColumns needed for projection-alias 13294 // resolution). Subqueries in QUALIFY still reject there, reusing 13295 // the (repurposed) QUALIFY_NOT_SUPPORTED code. 13296 // ORDER BY itself is lifted in slice 9; see buildOrderByColumnRefs. 13297 // Vendor- and clause-level guards are checked there so the rejection 13298 // message can mention the specific sub-clause. 13299 } 13300 13301 /** 13302 * Walk the FROM clause: each top-level {@link TJoin} contributes its 13303 * base table; each chained {@link TJoinItem} contributes one more base 13304 * table plus the column refs found in its ON-condition expression. 13305 * Comma-separated FROM lists (multiple top-level TJoins) and 13306 * single-source SELECTs both reduce to the same loop. 13307 */ 13308 private static List<RelationSource> buildRelations(TSelectSqlStatement select, 13309 NameBindingProvider provider, 13310 List<ColumnRef> joinRefsOut, 13311 boolean allowFromSubqueries) { 13312 return buildRelations(select, provider, joinRefsOut, allowFromSubqueries, 13313 /*allowJoinOnPredicateSubqueries=*/ false, 13314 /*stmtsForExtraction=*/ null, 13315 /*lineageForExtraction=*/ null, 13316 /*cteMapForExtraction=*/ null); 13317 } 13318 13319 /** 13320 * Slice-23/24 overload of {@link #buildRelations}. When 13321 * {@code allowJoinOnPredicateSubqueries} is {@code true} (outer-SELECT 13322 * call site only), uncorrelated EXISTS subqueries inside JOIN ON 13323 * predicates are extracted as their own {@code <predicate_subquery_<i>>} 13324 * StatementGraphs appended to {@code stmtsForExtraction}. The extracted 13325 * subtrees are then skipped by the JOIN-ON window-function rejecter and 13326 * the JOIN-ON ref collector so their inner refs do not leak into outer 13327 * {@code joinColumnRefs}. 13328 * 13329 * <p>Slice 24: {@code cteMapForExtraction} carries outer's 13330 * CTE-name-to-statement-index map so the extracted predicate body can 13331 * emit STATEMENT_OUTPUT → STATEMENT_OUTPUT edges into outer-visible CTE 13332 * bodies via {@link #emitLineageForStatement}. Non-outer call sites 13333 * (where {@code allowJoinOnPredicateSubqueries=false}) pass {@code null}. 13334 */ 13335 private static List<RelationSource> buildRelations(TSelectSqlStatement select, 13336 NameBindingProvider provider, 13337 List<ColumnRef> joinRefsOut, 13338 boolean allowFromSubqueries, 13339 boolean allowJoinOnPredicateSubqueries, 13340 List<StatementGraph> stmtsForExtraction, 13341 List<LineageEdge> lineageForExtraction, 13342 Map<String, Integer> cteMapForExtraction) { 13343 if (select.joins == null || select.joins.size() == 0) { 13344 throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SELECT_NO_FROM_SOURCE, "SELECT must have at least one FROM source", select)); 13345 } 13346 // Slice 62: comma-separated FROM lists (e.g. `FROM a, b`) 13347 // parse as multiple top-level TJoin elements. We admit them at 13348 // outer / CTE-body / FROM-subquery-body call sites (where 13349 // {@code allowFromSubqueries=true}) and build them as an ordered 13350 // cross-product relation graph with empty {@code joinColumnRefs} 13351 // (WHERE-side predicates feed {@code filterColumnRefs} as 13352 // usual). Synthetic body contexts (scalar / set-op-branch / 13353 // set-op-CTE / predicate) call this method with 13354 // {@code allowFromSubqueries=false} and stay rejected — that 13355 // is exactly the discriminator we need. Predicate bodies also 13356 // hit an earlier shape-specific reject inside 13357 // {@link #preflightExistsInnerShape}. 13358 if (!allowFromSubqueries && select.joins.size() > 1) { 13359 throw new SemanticIRBuildException( 13360 Diagnostic.error(DiagnosticCode.COMMA_FROM_IN_BODY_NOT_SUPPORTED, 13361 "comma-separated FROM list (implicit cross join) is not supported " 13362 + "inside scalar / set-op-branch / set-op-CTE / predicate body " 13363 + "contexts yet; use explicit JOIN ... ON", select)); 13364 } 13365 // Slice 63: explicit CROSS JOIN admits at outer / CTE-body / 13366 // FROM-subquery-body call sites (allowFromSubqueries=true) but 13367 // stays rejected inside synthetic body contexts (scalar / 13368 // set-op-branch / set-op-CTE / predicate) because the body's 13369 // shape contract (single column for scalar; column-count parity 13370 // for set-op branches; constant or single column-ref for 13371 // predicates) cannot host a cross-product relation graph 13372 // safely. Predicate bodies also hit an earlier shape-specific 13373 // reject inside {@link #preflightExistsInnerShape} so the 13374 // user-visible diagnostic mentions EXISTS / IN-SELECT context. 13375 if (!allowFromSubqueries) { 13376 for (TJoin join : select.joins) { 13377 TJoinItemList items = join.getJoinItems(); 13378 if (items == null) continue; 13379 for (int i = 0; i < items.size(); i++) { 13380 TJoinItem item = items.getJoinItem(i); 13381 if (item == null) continue; 13382 if (item.getJoinType() == EJoinType.cross) { 13383 throw new SemanticIRBuildException( 13384 Diagnostic.error(DiagnosticCode.CROSS_JOIN_IN_BODY_NOT_SUPPORTED, 13385 "CROSS JOIN is not supported inside scalar / " 13386 + "set-op-branch / set-op-CTE / predicate " 13387 + "body contexts yet; rewrite as INNER " 13388 + "JOIN ... ON in the body", item)); 13389 } 13390 // Slice 64: USING admitted at outer / CTE-body / 13391 // FROM-subquery-body call sites but rejected inside 13392 // synthetic body contexts. The body's shape contract 13393 // (single column for scalar, column-count parity for 13394 // set-op branches, constant/column-ref for predicate 13395 // bodies) cannot host the merged-key semantics safely. 13396 if (item.getUsingColumns() != null 13397 && item.getUsingColumns().size() > 0) { 13398 throw new SemanticIRBuildException( 13399 Diagnostic.error(DiagnosticCode.USING_IN_BODY_NOT_SUPPORTED, 13400 "JOIN ... USING (...) is not supported inside " 13401 + "scalar / set-op-branch / set-op-CTE / " 13402 + "predicate body contexts yet; rewrite " 13403 + "as JOIN ... ON in the body", item)); 13404 } 13405 // Slice 66: NATURAL JOIN inside synthetic body 13406 // contexts is rejected with a tuned diagnostic. 13407 // Predicate bodies hit preflightExistsInnerShape 13408 // first which emits an EXISTS-tuned message; this 13409 // reject fires for scalar / set-op-branch / 13410 // set-op-CTE bodies (and as defense-in-depth for 13411 // predicate bodies if the preflight didn't catch 13412 // a vendor-specific variant). 13413 if (isNaturalJoinType(item.getJoinType())) { 13414 throw new SemanticIRBuildException( 13415 Diagnostic.error(DiagnosticCode.NATURAL_IN_BODY_NOT_SUPPORTED, 13416 "NATURAL JOIN is not supported inside " 13417 + "scalar / set-op-branch / set-op-CTE / " 13418 + "predicate body contexts yet; rewrite " 13419 + "as JOIN ... ON in the body", item)); 13420 } 13421 } 13422 } 13423 } 13424 List<RelationSource> relations = new ArrayList<>(); 13425 // Join-graph COLUMN_BINDING_NON_EXACT degrade (GSP R6) for the JOIN ON 13426 // path. An unqualified ON-predicate operand that cannot be bound to 13427 // exactly one side (e.g. {@code JOIN cities ON city = name}) is a 13428 // which-side ambiguity, not a genuine error, WHEN the FROM is 13429 // catalog-less for at least one endpoint — exactly the case the 13430 // post-buildRelations SELECT/WHERE anchor already tolerates. The ON 13431 // refs are collected DURING this method (before that anchor is 13432 // installed), so compute the catalog-less eligibility here and hand the 13433 // ON collector an anchored provider. Every {@code collectJoinOnRefs} 13434 // call runs inside the join-item loop, where ≥2 endpoints are already 13435 // bound (driver + right), so the structural {@code relations.size() >= 2} 13436 // gate is satisfied by construction; only the catalog-less check 13437 // remains. A fully-cataloged FROM stays fatal (the catalog adjudicates). 13438 // 13439 // Whole-FROM scope is intentional and mirrors the post-buildRelations 13440 // SELECT/WHERE anchor (which gates on the same 13441 // {@code !fromRelationColumnsFullyKnown(select, provider)} for the whole 13442 // statement): a partially-cataloged FROM (one endpoint catalog-less) 13443 // degrades an unqualified ON miss because that operand MIGHT live on the 13444 // unknown side — exactly as an unqualified SELECT/WHERE column does. A 13445 // genuine typo over a fully-cataloged FROM still stays fatal. 13446 boolean joinGraphAnchorEligible = !fromRelationColumnsFullyKnown(select, provider); 13447 // Slice 179 (R4): stable FROM-order instance ordinal, assigned in the 13448 // SAME driver-then-join-items traversal order as buildJoinGraph, so 13449 // RelationSource.instanceId and JoinEndpoint.instanceId align by 13450 // construction (not by alias). Held in a one-element array so the 13451 // recursive appendJoinRelations helper can advance it across a nested 13452 // parenthesized <joined_table> sub-tree. 13453 int[] relInstanceId = {0}; 13454 for (TJoin join : select.joins) { 13455 // Slice 66: per top-level TJoin LeftOutputState. Seeded with the 13456 // top-left table's catalog; updated as JoinItems walk left-to-right. 13457 // NATURAL JoinItems consume this state (catalog intersection) and 13458 // update it. Reset between top-level TJoins so comma-FROM groups 13459 // stay independent. 13460 LeftOutputState leftState = new LeftOutputState(); 13461 appendJoinRelations(join, provider, allowFromSubqueries, 13462 allowJoinOnPredicateSubqueries, stmtsForExtraction, 13463 lineageForExtraction, cteMapForExtraction, 13464 relations, joinRefsOut, relInstanceId, leftState, 13465 joinGraphAnchorEligible); 13466 } 13467 rejectDuplicateAliases(relations); 13468 return relations; 13469 } 13470 13471 /** 13472 * Append the relations of one FROM-clause {@link TJoin} (its driver operand 13473 * plus each join item) to {@code relations}, populate {@code joinRefsOut} 13474 * with the join-condition column refs, advance {@code relInstanceId[0]} in 13475 * FROM order, and update {@code leftState} (the running row type used for 13476 * NATURAL key inference). 13477 * 13478 * <p>Recurses into a parenthesized / nested {@code <joined_table>} operand — 13479 * a {@link TJoin} reached via {@link TJoin#getJoin()} (driver) or 13480 * {@link TJoinItem#getJoin()} (right operand) when the corresponding 13481 * {@code getTable()} is null. So {@code FROM a JOIN (b JOIN c ON …) ON …} 13482 * collects all three relations, emits the inner join's refs, then attaches 13483 * the outer join's ON over the accumulated endpoints. The same 13484 * {@code leftState} is threaded through the recursion — both 13485 * {@code seedLeftOutput} and {@code appendRightToLeftOutput} only ADD 13486 * columns — so a NATURAL join that follows still sees the accumulated row 13487 * type. {@code FROM_SOURCE_NO_TABLE} / {@code JOIN_ITEM_NO_TABLE} stay fatal 13488 * only for an operand that is genuinely neither a table nor a nested join. 13489 */ 13490 private static void appendJoinRelations( 13491 TJoin join, 13492 NameBindingProvider provider, 13493 boolean allowFromSubqueries, 13494 boolean allowJoinOnPredicateSubqueries, 13495 List<StatementGraph> stmtsForExtraction, 13496 List<LineageEdge> lineageForExtraction, 13497 Map<String, Integer> cteMapForExtraction, 13498 List<RelationSource> relations, 13499 List<ColumnRef> joinRefsOut, 13500 int[] relInstanceId, 13501 LeftOutputState leftState, 13502 boolean joinGraphAnchorEligible) { 13503 // ---- driver operand ---- 13504 TTable leftTable = join.getTable(); 13505 if (leftTable != null) { 13506 relations.add(buildRelation(leftTable, provider, allowFromSubqueries, relInstanceId[0]++)); 13507 seedLeftOutput(leftState, leftTable, provider); 13508 } else if (join.getJoin() != null) { 13509 // Parenthesized joined-table as the driver operand — recurse, 13510 // threading the same leftState so it accumulates the sub-tree's 13511 // columns for any NATURAL join that follows at this level. 13512 appendJoinRelations(join.getJoin(), provider, allowFromSubqueries, 13513 allowJoinOnPredicateSubqueries, stmtsForExtraction, 13514 lineageForExtraction, cteMapForExtraction, 13515 relations, joinRefsOut, relInstanceId, leftState, 13516 joinGraphAnchorEligible); 13517 } else { 13518 throw new SemanticIRBuildException(Diagnostic.error( 13519 DiagnosticCode.FROM_SOURCE_NO_TABLE, "FROM source has no table", join)); 13520 } 13521 13522 TJoinItemList items = join.getJoinItems(); 13523 if (items == null) return; 13524 for (int i = 0; i < items.size(); i++) { 13525 TJoinItem item = items.getJoinItem(i); 13526 rejectUnsupportedJoinShape(item); 13527 TTable rightTable = item.getTable(); 13528 if (rightTable == null) { 13529 if (item.getJoin() != null) { 13530 // NATURAL / USING merged-key inference needs a single right 13531 // TTable to key against; over a parenthesized joined-table 13532 // operand (a combined row type) it is not supported yet. 13533 // Fail explicitly rather than silently dropping the merged-key 13534 // refs in buildRelations while buildJoinGraph still emits a 13535 // NATURAL/USING entity (codex P2). Plain ON / CROSS over a 13536 // nested operand ARE supported. 13537 if (isNaturalJoinType(item.getJoinType()) 13538 || (item.getUsingColumns() != null 13539 && item.getUsingColumns().size() > 0)) { 13540 throw new SemanticIRBuildException(Diagnostic.error( 13541 DiagnosticCode.NESTED_JOIN_MERGED_KEY_NOT_SUPPORTED, 13542 "NATURAL / USING join over a parenthesized joined-table " 13543 + "operand is not supported yet; rewrite with an " 13544 + "explicit ON condition", item)); 13545 } 13546 // Parenthesized joined-table as the right operand — recurse to 13547 // collect its relations + inner join refs, then attach this 13548 // outer join's ON over the accumulated endpoints. Use a FRESH 13549 // row-state for the sub-tree so its inner NATURAL inference 13550 // sees only its own columns (codex P1), then merge that row 13551 // type into this level's leftState so a NATURAL join that 13552 // FOLLOWS at this level still sees the accumulated columns. 13553 LeftOutputState subState = new LeftOutputState(); 13554 appendJoinRelations(item.getJoin(), provider, allowFromSubqueries, 13555 allowJoinOnPredicateSubqueries, stmtsForExtraction, 13556 lineageForExtraction, cteMapForExtraction, 13557 relations, joinRefsOut, relInstanceId, subState, 13558 joinGraphAnchorEligible); 13559 mergeLeftOutputState(leftState, subState); 13560 collectJoinOnRefs(item.getOnCondition(), provider, 13561 allowJoinOnPredicateSubqueries, stmtsForExtraction, 13562 lineageForExtraction, cteMapForExtraction, joinRefsOut, 13563 joinGraphAnchorEligible); 13564 continue; 13565 } 13566 throw new SemanticIRBuildException(Diagnostic.error( 13567 DiagnosticCode.JOIN_ITEM_NO_TABLE, "JOIN item has no table", item)); 13568 } 13569 // Slice 17/18: subqueries on a JOIN side are extracted as their own 13570 // statements by extractFromSubqueriesAsStatements before 13571 // buildRelations runs (when allowFromSubqueries=true). Scalar / 13572 // set-op-branch / set-op-CTE-body builds pass allowFromSubqueries= 13573 // false; buildRelation rejects there. 13574 relations.add(buildRelation(rightTable, provider, allowFromSubqueries, relInstanceId[0]++)); 13575 // Slice 66: NATURAL admission via catalog inference. When catalog 13576 // metadata is missing on either side we DEGRADE: skip the merged 13577 // refs, record a NATURAL_CATALOG_REQUIRED warning, and append the 13578 // right's columns to the running state. 13579 if (isNaturalJoinType(item.getJoinType())) { 13580 NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider); 13581 if (r.kind != NaturalKeyResult.Kind.SUCCESS) { 13582 recordNaturalDegradeWarning(r, item); 13583 appendRightToLeftOutput(leftState, rightTable, provider); 13584 continue; 13585 } 13586 emitMergedJoinRefs(JoinKind.NATURAL, r.keys, join, items, i, 13587 rightTable, provider, joinRefsOut); 13588 mergeRightIntoLeftOutput(leftState, rightTable, provider, r.keys); 13589 continue; 13590 } 13591 // Slice 64: USING and ON are mutually exclusive (enforced by 13592 // rejectUnsupportedJoinShape). Populate per-key joinColumnRefs from 13593 // USING here, then skip the onCond branch. 13594 TObjectNameList usingCols = item.getUsingColumns(); 13595 if (usingCols != null && usingCols.size() > 0) { 13596 populateUsingJoinRefs(join, items, i, rightTable, 13597 usingCols, provider, joinRefsOut); 13598 // Slice 66: USING JoinItems also merge right into the 13599 // LeftOutputState so subsequent NATURAL JoinItems see the 13600 // accumulated row type (merged USING keys at their slots). 13601 List<String> usingKeyNames = new ArrayList<>(usingCols.size()); 13602 for (int k = 0; k < usingCols.size(); k++) { 13603 TObjectName usingKey = usingCols.getObjectName(k); 13604 if (usingKey == null) continue; 13605 String keyName = usingKey.getColumnNameOnly(); 13606 if (keyName != null && !keyName.isEmpty()) { 13607 usingKeyNames.add(keyName); 13608 } 13609 } 13610 mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames); 13611 continue; 13612 } 13613 // Slice 66: ON / CROSS JoinItem — append right's catalog columns to 13614 // the running LeftOutputState; NATURAL JoinItems that follow see it. 13615 appendRightToLeftOutput(leftState, rightTable, provider); 13616 collectJoinOnRefs(item.getOnCondition(), provider, 13617 allowJoinOnPredicateSubqueries, stmtsForExtraction, 13618 lineageForExtraction, cteMapForExtraction, joinRefsOut, 13619 joinGraphAnchorEligible); 13620 } 13621 } 13622 13623 /** 13624 * Collect the column refs of a JOIN item's {@code ON} expression into 13625 * {@code joinRefsOut}, applying the same predicate-subquery extraction and 13626 * window-function / subquery rejections as the inline ON path. No-op when 13627 * {@code onCond} is null (CROSS / APPLY items carry no ON). Shared by the 13628 * flat and nested ({@link #appendJoinRelations}) traversal paths. 13629 */ 13630 private static void collectJoinOnRefs(TExpression onCond, NameBindingProvider provider, 13631 boolean allowJoinOnPredicateSubqueries, 13632 List<StatementGraph> stmtsForExtraction, 13633 List<LineageEdge> lineageForExtraction, 13634 Map<String, Integer> cteMapForExtraction, 13635 List<ColumnRef> joinRefsOut, 13636 boolean joinGraphAnchorEligible) { 13637 if (onCond == null) { 13638 return; 13639 } 13640 // Join-graph COLUMN_BINDING_NON_EXACT degrade (GSP R6): the anchor wraps 13641 // ONLY the direct ON-condition ref binding below. Predicate-subquery 13642 // EXTRACTION keeps the un-anchored {@code provider} — each extracted 13643 // body is built via a nested entry that resets the anchor anyway, and 13644 // its inner binding does not enjoy the ON path's ≥2-bound-endpoint 13645 // invariant. The anchor only changes rejectNonExactBindings: an 13646 // unqualified ON operand that can't be placed on one side (a which-side 13647 // ambiguity over a catalog-less join) degrades to a warning instead of 13648 // aborting the whole analysis. 13649 NameBindingProvider refProvider = joinGraphAnchorEligible 13650 ? provider.withJoinStructureAnchor(true) : provider; 13651 if (allowJoinOnPredicateSubqueries) { 13652 // Slice 23/24/25: outer-SELECT JOIN ON path — extract uncorrelated 13653 // predicate-subquery wrappers as their own <predicate_subquery_<i>> 13654 // StatementGraphs and skip their subtrees during the window guard and 13655 // ref collection. The leak guard rejects everything that is NOT an 13656 // extracted wrapper. 13657 Set<TExpression> extractedRoots = 13658 extractUncorrelatedPredicateSubqueriesFromJoinOn(onCond, provider, 13659 stmtsForExtraction, lineageForExtraction, cteMapForExtraction); 13660 rejectAnyRemainingSubqueriesInJoinOn(onCond, extractedRoots); 13661 rejectWindowFunctionInScopeSkipping(onCond, "JOIN ON condition", extractedRoots); 13662 joinRefsOut.addAll(collectColumnRefsSkipping(onCond, refProvider, extractedRoots)); 13663 } else { 13664 // Slice 13/17: reject window functions and predicate subqueries in 13665 // JOIN ON before collectColumnRefs descends (every non-outer call 13666 // site: FROM-subquery body, CTE body, scalar body, set-op branch). 13667 rejectWindowFunctionInScope(onCond, "JOIN ON condition"); 13668 rejectSubqueriesInJoinOn(onCond); 13669 joinRefsOut.addAll(collectColumnRefs(onCond, refProvider)); 13670 } 13671 } 13672 13673 /** 13674 * Slice 17: reject predicate subqueries (EXISTS, IN-SELECT, 13675 * scalar-subquery comparisons, etc.) inside a JOIN ON expression. 13676 * Without this guard, slice-17's expanded JOIN surface (relation 13677 * subqueries on either side) would let predicate subqueries slip 13678 * past {@code collectColumnRefs} and produce incomplete IR. Applies 13679 * to every {@code buildRelations} call site; the slice-11 13680 * {@link #rejectSubqueriesInScalarBodyClauses} and slice-17 13681 * {@link #rejectSubqueriesInFromSubqueryBodyClauses} fire BEFORE 13682 * the recursive {@code buildSelectStatement}, so their context- 13683 * specific messages preempt this one. 13684 */ 13685 private static void rejectSubqueriesInJoinOn(TExpression onCond) { 13686 if (containsAnySubqueryExpression(onCond)) { 13687 throw new SemanticIRBuildException( 13688 Diagnostic.error(DiagnosticCode.JOIN_ON_TOP_LEVEL_SUBQUERY_NOT_SUPPORTED, 13689 "subquery in a top-level JOIN ON predicate is not supported yet", onCond)); 13690 } 13691 } 13692 13693 // ==================================================================== 13694 // Slice 23: uncorrelated EXISTS subqueries in top-level outer-SELECT 13695 // JOIN ON. 13696 // 13697 // Approach: walk the JOIN-ON expression looking for `exists_t` nodes 13698 // (and `not_t(exists_t(...))` for NOT EXISTS); validate each as 13699 // uncorrelated with a constant-only inner projection; build the inner 13700 // SELECT as its own `<predicate_subquery_<i>>` StatementGraph; record 13701 // the extracted `exists_t` root in a Set so the JOIN-ON window guard 13702 // and ref collector can skip its subtree. Predicate bodies are 13703 // unreachable from outer (no relation, no lineage edge) so they 13704 // contribute zero canonical edges — matching dlineage's behaviour for 13705 // EXISTS-in-JOIN-ON shapes that project a constant (the inner-shape 13706 // preflight enforces constant-only projection so this invariant 13707 // holds). 13708 // 13709 // Process: codex round 1 + round 2 plan reviews; v3 plan locked. 13710 // See roadmap §14.25 (slice-23 entry). 13711 // ==================================================================== 13712 13713 /** 13714 * True iff {@code e} is the root of an EXISTS predicate that slice 23 13715 * may extract: either an {@code exists_t} expression, or a 13716 * {@code logical_not_t} whose <b>right</b> operand is {@code exists_t}. 13717 * NOT EXISTS unwraps to its inner {@code exists_t}. 13718 * 13719 * <p>Note: the GSP parser puts the operand of {@code logical_not_t} in 13720 * {@link TExpression#getRightOperand()}, not {@code getLeftOperand()} 13721 * (verified across Oracle / PostgreSQL / MSSQL / MySQL / BigQuery). 13722 * The root fast-path for {@code NOT EXISTS} is therefore "dead" in the 13723 * sense that the descendant walker on the wrapping {@code logical_not_t} 13724 * already visits the child {@code exists_t} — we still keep it here so 13725 * the symmetry between root EXISTS and root NOT EXISTS is explicit. 13726 * 13727 * <p>Slice 25 (kept as a dedicated helper for slice-23/24 callers and 13728 * for clarity): the slice-25 generalisation lives in 13729 * {@link #unwrapToInnerExtractableSubquery(TExpression)} which 13730 * recognises four wrapper shapes — including the two EXISTS shapes 13731 * here. 13732 */ 13733 private static boolean isExistsRoot(TExpression e) { 13734 if (e == null) return false; 13735 if (e.getExpressionType() == EExpressionType.exists_t) return true; 13736 if (e.getExpressionType() == EExpressionType.logical_not_t 13737 && e.getRightOperand() != null 13738 && e.getRightOperand().getExpressionType() == EExpressionType.exists_t) { 13739 return true; 13740 } 13741 return false; 13742 } 13743 13744 /** Return the actual {@code exists_t} node — unwrap a {@code logical_not_t} parent if present. */ 13745 private static TExpression unwrapExistsRoot(TExpression e) { 13746 if (e.getExpressionType() == EExpressionType.exists_t) return e; 13747 return e.getRightOperand(); 13748 } 13749 13750 /** 13751 * Slice 25 / Slice 26: pure shape-recogniser for the predicate- 13752 * subquery wrappers admitted in TOP-LEVEL JOIN ON. Returns the inner 13753 * extractable node ({@code subquery_t} or {@code exists_t}) for the 13754 * wrapper shapes; null otherwise. Pure — performs NO validation and 13755 * throws NO exceptions. 13756 * 13757 * <p>Recognised wrappers: 13758 * <ul> 13759 * <li>{@code exists_t} (slice-23 EXISTS) — returns {@code e}.</li> 13760 * <li>{@code logical_not_t} with rightOperand {@code exists_t} 13761 * (slice-23 NOT EXISTS) — returns the inner exists_t.</li> 13762 * <li>{@code in_t} with rightOperand {@code subquery_t} 13763 * (slice 25 IN-SELECT / NOT IN-SELECT) — returns the 13764 * rightOperand. LHS-subquery {@code in_t} returns null 13765 * (slice 26 boundary: dlineage's {@code fdr clause="on"} 13766 * sources omit the outer column for IN-LHS, so admitting on 13767 * the IR side would manufacture canonical-model divergence).</li> 13768 * <li>{@code simple_comparison_t} (slice 25 + slice 26 scalar 13769 * comparison) — returns the operand on whichever side is a 13770 * {@code subquery_t}. RHS-subquery (slice 25) and LHS- 13771 * subquery (slice 26) are both admitted; both-sides subquery 13772 * returns null and falls through to {@link #findSubqueryOnLeftWrapper}'s 13773 * new "both subqueries" rejection branch.</li> 13774 * <li>{@code group_comparison_t} with rightOperand 13775 * {@code subquery_t} AND non-null {@code getQuantifier()} 13776 * (slice 25 ANY/ALL/SOME) — returns the rightOperand. 13777 * LHS-subquery {@code group_comparison_t} returns null 13778 * (slice 26 boundary: borderline grammar; not probed).</li> 13779 * </ul> 13780 * 13781 * <p>For null returns, the wrapper either is not a recognised shape 13782 * (falls through to the slice-23 generic remaining-subquery rejection 13783 * in {@link #rejectAnyRemainingSubqueriesInJoinOn}) OR has the right 13784 * outer shape but the LHS / RHS positioning is unsupported (subquery 13785 * on left side of IN/quantifier, both sides subquery for cmp, tuple 13786 * LHS / RHS, expression LHS / RHS). The walker validates the 13787 * non-subquery side via {@link #isAdmittedOuterLhsShape} or 13788 * {@link #isAdmittedOuterRhsShape} and throws a slice-25 / slice-26 13789 * tuned message before calling this helper for extraction. 13790 * 13791 * <p>The slice-23/24 EXISTS callers ({@code isExistsRoot} and 13792 * {@code unwrapExistsRoot}) remain in place — both are simple 13793 * boolean / unwrap helpers; this method consolidates the slice-25 / 13794 * slice-26 shape decision in one place. 13795 */ 13796 private static TExpression unwrapToInnerExtractableSubquery(TExpression e) { 13797 if (e == null) return null; 13798 EExpressionType t = e.getExpressionType(); 13799 if (t == EExpressionType.exists_t) return e; 13800 if (t == EExpressionType.logical_not_t 13801 && e.getRightOperand() != null 13802 && e.getRightOperand().getExpressionType() == EExpressionType.exists_t) { 13803 return e.getRightOperand(); 13804 } 13805 TExpression l = e.getLeftOperand(); 13806 TExpression r = e.getRightOperand(); 13807 boolean lhsIsSubq = l != null && l.getExpressionType() == EExpressionType.subquery_t; 13808 boolean rhsIsSubq = r != null && r.getExpressionType() == EExpressionType.subquery_t; 13809 if (t == EExpressionType.in_t) { 13810 return rhsIsSubq ? r : null; 13811 } 13812 if (t == EExpressionType.simple_comparison_t) { 13813 // Slice 26: admit subquery on either single side. Both sides 13814 // → null (rejected via findSubqueryOnLeftWrapper's new 13815 // "both subqueries" branch — see isSubqueryOnLeftOfWrapper). 13816 if (lhsIsSubq && rhsIsSubq) return null; 13817 if (rhsIsSubq) return r; 13818 if (lhsIsSubq) return l; 13819 return null; 13820 } 13821 if (t == EExpressionType.group_comparison_t 13822 && e.getQuantifier() != null) { 13823 return rhsIsSubq ? r : null; 13824 } 13825 return null; 13826 } 13827 13828 /** 13829 * Slice 25: admitted LHS shapes for non-EXISTS predicate-subquery 13830 * wrappers ({@code in_t} / {@code simple_comparison_t} / 13831 * {@code group_comparison_t}) when the subquery is on the RHS. 13832 * 13833 * <p>Admits ONLY {@link EExpressionType#simple_object_name_t} — 13834 * a single column reference, qualified or unqualified. Rejects: 13835 * tuple expressions ({@code (a, b) IN (...)}), parenthesized 13836 * wrapping ({@code (e.col) IN (...)}), arithmetic 13837 * ({@code e.col + 1 IN (...)}), function calls 13838 * ({@code UPPER(e.col) IN (...)}), scalar subqueries on LHS, and 13839 * any other non-column shape. 13840 * 13841 * <p>Slice-25 boundary; future slice may admit parenthesized 13842 * column refs (slice 26+). 13843 */ 13844 private static boolean isAdmittedOuterLhsShape(TExpression lhs) { 13845 return lhs != null 13846 && lhs.getExpressionType() == EExpressionType.simple_object_name_t; 13847 } 13848 13849 /** 13850 * Slice 26: admitted RHS shapes for {@code simple_comparison_t} 13851 * with subquery on the LHS. Mirror of 13852 * {@link #isAdmittedOuterLhsShape}: admits ONLY 13853 * {@link EExpressionType#simple_object_name_t} — a single column 13854 * reference, qualified or unqualified. Rejects tuple, parenthesized, 13855 * arithmetic, function-call, and subquery (the "both subqueries" 13856 * shape is rejected separately via 13857 * {@link #isSubqueryOnLeftOfWrapper}'s new 13858 * {@code simple_comparison_t} both-sides branch). 13859 * 13860 * <p>Slice-26 boundary: only {@code simple_comparison_t} reaches 13861 * this helper (the walker dispatches on which side is the 13862 * subquery). {@code in_t} / {@code group_comparison_t} with LHS 13863 * subquery return null from 13864 * {@link #unwrapToInnerExtractableSubquery} so they never reach 13865 * here. 13866 */ 13867 private static boolean isAdmittedOuterRhsShape(TExpression rhs) { 13868 return rhs != null 13869 && rhs.getExpressionType() == EExpressionType.simple_object_name_t; 13870 } 13871 13872 /** 13873 * Slice 25 (impl-review M1-fix): true iff {@code e} is a 13874 * {@code logical_not_t} wrapping a slice-25 IN / scalar-cmp / 13875 * ANY-ALL-SOME wrapper (i.e. NOT applied to an admitted slice-25 13876 * shape that ISN'T an EXISTS). The descendant walker would 13877 * otherwise traverse INTO this {@code logical_not_t} and find the 13878 * child wrapper, accidentally admitting 13879 * {@code NOT (e.col IN (SELECT ...))} which is NOT a slice-25 13880 * recognised shape ({@code unwrapToInnerExtractableSubquery} 13881 * matches {@code logical_not_t} only when the inner is 13882 * {@code exists_t}). 13883 * 13884 * <p>This helper is consulted by the extraction walker BEFORE it 13885 * descends into the children of a {@code logical_not_t}, so the 13886 * rejection happens at the wrapper level with a tuned message 13887 * pointing at the slice-25 boundary. 13888 */ 13889 private static boolean isLogicalNotOverNonExistsWrapper(TExpression e) { 13890 if (e == null) return false; 13891 if (e.getExpressionType() != EExpressionType.logical_not_t) return false; 13892 TExpression r = e.getRightOperand(); 13893 if (r == null) return false; 13894 // Strip parenthesis_t chain. The Oracle parser wraps 13895 // `NOT (e.col IN (SELECT...))` as 13896 // logical_not_t → parenthesis_t → in_t, so the immediate 13897 // right child is parenthesis_t. Descend through any chain of 13898 // parens to find the actual subject. Note: 13899 // {@code parenthesis_t} stores its child on 13900 // {@link TExpression#getLeftOperand()} (mirroring 13901 // {@link #isConstantExpression}'s descent). 13902 TExpression subject = r; 13903 while (subject != null 13904 && subject.getExpressionType() == EExpressionType.parenthesis_t) { 13905 subject = subject.getLeftOperand(); 13906 } 13907 if (subject == null) return false; 13908 if (subject.getExpressionType() == EExpressionType.exists_t) return false; 13909 // Either an in_t / simple_comparison_t / group_comparison_t 13910 // with subquery RHS, or any of those types directly. 13911 return unwrapToInnerExtractableSubquery(subject) != null; 13912 } 13913 13914 /** 13915 * Slice 25 / Slice 26: build a tuned outer-shape rejection message 13916 * for a non-EXISTS predicate-subquery wrapper. Called from the 13917 * extraction walker when {@link #unwrapToInnerExtractableSubquery} 13918 * returns non-null for an in_t / simple_comparison_t / 13919 * group_comparison_t but the non-subquery side is not admitted by 13920 * {@link #isAdmittedOuterLhsShape} (slice 25 — subquery on RHS) or 13921 * {@link #isAdmittedOuterRhsShape} (slice 26 — subquery on LHS). 13922 * 13923 * <p>{@code isLhsSubquery} indicates which side of the wrapper 13924 * carries the subquery: {@code true} = subquery on LHS (slice 26 13925 * path; we validate the wrapper's RHS), {@code false} = subquery 13926 * on RHS (slice 25 path; we validate the wrapper's LHS). 13927 * 13928 * <p>Uses the slice-25 outer-shape error prefix 13929 * "predicate subquery in JOIN ON:" so end users distinguish 13930 * outer-shape failures from the slice-23/24 inner-shape failures 13931 * (which keep the "EXISTS in JOIN ON:" prefix). 13932 */ 13933 private static String buildOuterShapeRejectionMessage(TExpression wrapper, 13934 boolean isLhsSubquery, 13935 PredicateClauseContext ctx) { 13936 EExpressionType t = wrapper.getExpressionType(); 13937 String shapeLabel; 13938 if (t == EExpressionType.in_t) shapeLabel = "IN"; 13939 else if (t == EExpressionType.simple_comparison_t) shapeLabel = "comparison"; 13940 else if (t == EExpressionType.group_comparison_t) shapeLabel = "ANY/ALL/SOME"; 13941 else shapeLabel = String.valueOf(t); 13942 // Validate the side that does NOT carry the subquery. 13943 TExpression nonSubquerySide = isLhsSubquery 13944 ? wrapper.getRightOperand() 13945 : wrapper.getLeftOperand(); 13946 String sideLabel = isLhsSubquery ? "RHS" : "LHS"; 13947 EExpressionType sideType = nonSubquerySide == null 13948 ? null : nonSubquerySide.getExpressionType(); 13949 String detail; 13950 if (nonSubquerySide == null) { 13951 detail = "missing " + sideLabel; 13952 } else if (sideType == EExpressionType.list_t) { 13953 detail = "tuple " + sideLabel; 13954 } else if (sideType == EExpressionType.parenthesis_t) { 13955 detail = "parenthesized " + sideLabel; 13956 } else if (sideType == EExpressionType.simple_object_name_t) { 13957 // Defensive: should not be reached when the corresponding 13958 // admitted-shape helper returns true. 13959 detail = "unexpected admitted " + sideLabel + " shape"; 13960 } else { 13961 detail = "expression " + sideLabel + " (" + sideType + ")"; 13962 } 13963 String boundary = isLhsSubquery ? "slice 26 boundary" : "slice 25 boundary"; 13964 return "predicate subquery in " + ctx.clauseLabel + ": " + shapeLabel 13965 + " wrapper has unsupported " + sideLabel + " shape (" 13966 + detail + "); only a single column reference " 13967 + "(simple_object_name_t) is admitted on the " 13968 + sideLabel 13969 + " of a comparison / IN / ANY-ALL-SOME " 13970 + "predicate subquery when the other side is a " 13971 + "subquery (" + boundary + ")"; 13972 } 13973 13974 /** 13975 * Slice 25 (rename of slice-23 13976 * {@code extractUncorrelatedExistsFromJoinOn}): walk the JOIN-ON 13977 * expression, extract every uncorrelated predicate-subquery wrapper 13978 * (EXISTS / NOT EXISTS / IN-SELECT / NOT IN-SELECT / scalar 13979 * comparison subquery / ANY-ALL-SOME) as its own 13980 * {@code <predicate_subquery_<i>>} StatementGraph, and return the 13981 * set of extracted inner nodes (the {@code exists_t} or 13982 * {@code subquery_t}, NOT the wrapping {@code in_t} / 13983 * {@code simple_comparison_t} / {@code group_comparison_t} / 13984 * {@code logical_not_t}) keyed on identity. 13985 * 13986 * <p>The set is consumed by the JOIN-ON window-function guard, the 13987 * JOIN-ON ref collector, and the slice-17 remaining-subquery 13988 * rejecter — each of those skips INTO / PAST these subtrees so 13989 * inner refs do not leak into outer joinColumnRefs. Critically, 13990 * the wrapper itself (e.g. an {@code in_t} whose RHS is the 13991 * {@code subquery_t}) is NOT in the set — this lets the LHS column 13992 * reference (e.g. {@code e.dept_id} in 13993 * {@code e.dept_id IN (SELECT ...)}) be collected normally into 13994 * outer's {@code joinColumnRefs}. 13995 * 13996 * <p>The walker handles BOTH the root-position case (the entire ON 13997 * IS one of the four wrappers, which {@code acceptChildren} would 13998 * not visit as a node) AND descendant positions (e.g. 13999 * {@code e.id = d.id AND e.dept_id IN (SELECT ...)}). Multiple 14000 * wrappers in one ON, multiple ON across multiple JOINs, and mixed 14001 * EXISTS / IN / cmp / ANY-ALL combinations are all handled. 14002 * 14003 * <p>Slice 25 / Slice 26 outer-shape validation: for non-EXISTS 14004 * wrappers, the side opposite the subquery must be a single 14005 * {@code simple_object_name_t} column ref. Slice 25 admits subquery 14006 * on RHS only and validates LHS via 14007 * {@link #isAdmittedOuterLhsShape}. Slice 26 lifts {@code 14008 * simple_comparison_t} to also admit subquery on LHS and validates 14009 * RHS via {@link #isAdmittedOuterRhsShape}. Tuple / parenthesized / 14010 * expression / function-call shapes on the validated side throw 14011 * {@link SemanticIRBuildException} with a tuned message via 14012 * {@link #buildOuterShapeRejectionMessage}. The EXISTS branch has 14013 * no outer-shape gate (slice-23 carryover). 14014 * 14015 * <p>Snapshot/rollback wrapper at the outer-SELECT call site 14016 * ({@link #build}) catches a partial extraction (e.g. third 14017 * wrapper rejected after first two extracted) and truncates 14018 * {@code stmts}/{@code lineage} back to the snapshot. 14019 */ 14020 /** 14021 * Slice 110 — context bag threading clause-specific 14022 * {@link DiagnosticCode}s and a clause-label into the slice-23+ 14023 * predicate-subquery extraction pipeline so the same walker code can 14024 * power JOIN-ON (slice 23–33+) and UPDATE WHERE (slice 110) without 14025 * code duplication. 14026 * 14027 * <p>Two static instances exist: 14028 * <ul> 14029 * <li>{@link #JOIN_ON} — preserves slice-23+ JOIN-ON behavior 14030 * byte-for-byte (same codes, same "JOIN ON" labels).</li> 14031 * <li>{@link #UPDATE_WHERE} — slice 110 UPDATE WHERE call site 14032 * (parallel {@code UPDATE_WHERE_*} codes; "UPDATE WHERE clause" 14033 * label).</li> 14034 * </ul> 14035 * 14036 * <p>Codes per clause are intentionally parallel (slice-80 14037 * granular-codes contract: each semantic reject reason gets its own 14038 * stable API code rather than an umbrella code with discriminating 14039 * message text). 14040 */ 14041 private static final class PredicateClauseContext { 14042 /** Used as the "in <label>" piece of every diagnostic message. */ 14043 final String clauseLabel; 14044 final DiagnosticCode existsBodyMissing; 14045 final DiagnosticCode existsInnerRelationUnknown; 14046 final DiagnosticCode existsCorrelatedUnknownOuterAlias; 14047 final DiagnosticCode predicateNotNot; 14048 final DiagnosticCode outerShapeRejected; 14049 final DiagnosticCode scalarComparisonBothSides; 14050 final DiagnosticCode predicateSubqueryOnLeft; 14051 final DiagnosticCode genericSubqueryNotSupported; 14052 14053 private PredicateClauseContext(String clauseLabel, 14054 DiagnosticCode existsBodyMissing, 14055 DiagnosticCode existsInnerRelationUnknown, 14056 DiagnosticCode existsCorrelatedUnknownOuterAlias, 14057 DiagnosticCode predicateNotNot, 14058 DiagnosticCode outerShapeRejected, 14059 DiagnosticCode scalarComparisonBothSides, 14060 DiagnosticCode predicateSubqueryOnLeft, 14061 DiagnosticCode genericSubqueryNotSupported) { 14062 this.clauseLabel = clauseLabel; 14063 this.existsBodyMissing = existsBodyMissing; 14064 this.existsInnerRelationUnknown = existsInnerRelationUnknown; 14065 this.existsCorrelatedUnknownOuterAlias = existsCorrelatedUnknownOuterAlias; 14066 this.predicateNotNot = predicateNotNot; 14067 this.outerShapeRejected = outerShapeRejected; 14068 this.scalarComparisonBothSides = scalarComparisonBothSides; 14069 this.predicateSubqueryOnLeft = predicateSubqueryOnLeft; 14070 this.genericSubqueryNotSupported = genericSubqueryNotSupported; 14071 } 14072 14073 static final PredicateClauseContext JOIN_ON = new PredicateClauseContext( 14074 "JOIN ON", 14075 DiagnosticCode.JOIN_ON_EXISTS_BODY_MISSING, 14076 DiagnosticCode.JOIN_ON_EXISTS_INNER_RELATION_UNKNOWN, 14077 DiagnosticCode.JOIN_ON_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14078 DiagnosticCode.JOIN_ON_PREDICATE_NOT_NOT_SUPPORTED, 14079 DiagnosticCode.JOIN_ON_OUTER_SHAPE_REJECTED, 14080 DiagnosticCode.JOIN_ON_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14081 DiagnosticCode.JOIN_ON_PREDICATE_NOT_LIFTABLE, 14082 DiagnosticCode.JOIN_ON_PREDICATE_GENERIC_NOT_SUPPORTED); 14083 14084 static final PredicateClauseContext UPDATE_WHERE = new PredicateClauseContext( 14085 "UPDATE WHERE clause", 14086 DiagnosticCode.UPDATE_WHERE_EXISTS_BODY_MISSING, 14087 DiagnosticCode.UPDATE_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14088 DiagnosticCode.UPDATE_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14089 DiagnosticCode.UPDATE_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14090 DiagnosticCode.UPDATE_WHERE_OUTER_SHAPE_REJECTED, 14091 DiagnosticCode.UPDATE_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14092 DiagnosticCode.UPDATE_WHERE_PREDICATE_NOT_LIFTABLE, 14093 DiagnosticCode.UPDATE_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14094 14095 static final PredicateClauseContext DELETE_WHERE = new PredicateClauseContext( 14096 "DELETE WHERE clause", 14097 DiagnosticCode.DELETE_WHERE_EXISTS_BODY_MISSING, 14098 DiagnosticCode.DELETE_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14099 DiagnosticCode.DELETE_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14100 DiagnosticCode.DELETE_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14101 DiagnosticCode.DELETE_WHERE_OUTER_SHAPE_REJECTED, 14102 DiagnosticCode.DELETE_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14103 DiagnosticCode.DELETE_WHERE_PREDICATE_NOT_LIFTABLE, 14104 DiagnosticCode.DELETE_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14105 14106 static final PredicateClauseContext SELECT_WHERE = new PredicateClauseContext( 14107 "SELECT WHERE clause", 14108 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14109 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14110 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14111 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14112 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14113 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14114 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14115 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14116 14117 /** 14118 * Slice 113 — uncorrelated WHERE-side predicate subqueries on 14119 * set-op branches (UNION / INTERSECT / EXCEPT / MINUS branches). 14120 * Reuses every {@link DiagnosticCode} from {@link #SELECT_WHERE} 14121 * because a branch IS a SELECT — the shape rejects are 14122 * semantically identical to top-level SELECT WHERE. Only the 14123 * {@code clauseLabel} differs so diagnostic messages distinguish 14124 * the nested context (helpful when a multi-branch query reports 14125 * a reject and the user needs to know which branch). Keeping the 14126 * codes shared frees consumers from a new code-family migration 14127 * and preserves the enum count at 279. 14128 */ 14129 static final PredicateClauseContext SET_OP_BRANCH_WHERE = new PredicateClauseContext( 14130 "set-op branch WHERE clause", 14131 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14132 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14133 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14134 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14135 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14136 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14137 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14138 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14139 14140 /** 14141 * Slice 114 — uncorrelated WHERE-side predicate subqueries 14142 * inside a non-set-op CTE body (the SELECT body of a single CTE 14143 * in a WITH list on SELECT / MERGE / UPDATE / DELETE). Reuses 14144 * every {@link DiagnosticCode} from {@link #SELECT_WHERE} 14145 * because a CTE body IS a SELECT — the shape rejects are 14146 * semantically identical to top-level SELECT WHERE. Only the 14147 * {@code clauseLabel} differs so a reject in a 14148 * {@code WITH cte AS (SELECT ... WHERE NOT (...))} shape can 14149 * identify the CTE-body host context in the diagnostic message. 14150 * Keeping the codes shared (slice 113 precedent) preserves the 14151 * enum count at 279 and frees consumers from another 14152 * code-family migration. 14153 */ 14154 static final PredicateClauseContext CTE_BODY_WHERE = new PredicateClauseContext( 14155 "CTE body WHERE clause", 14156 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14157 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14158 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14159 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14160 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14161 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14162 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14163 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14164 14165 /** 14166 * Slice 120 — uncorrelated WHERE-side predicate subqueries inside 14167 * a FROM-subquery body (the inner SELECT of a {@code FROM (...)} 14168 * derived table). Reuses every {@link DiagnosticCode} from 14169 * {@link #SELECT_WHERE} (slice 113/114/116 precedent) because a 14170 * FROM-subquery body IS a SELECT — the shape rejects are 14171 * semantically identical to top-level SELECT WHERE. Only the 14172 * {@code clauseLabel} differs so a reject inside a 14173 * {@code FROM (SELECT ... WHERE NOT (...)) sub} shape can identify 14174 * the FROM-subquery host context in the diagnostic message. The 14175 * FROM-subquery body builder {@code processDirectSubqueryTable} is 14176 * shared by the SELECT, UPDATE (slice 83), and DELETE (slice 84) 14177 * FROM-subquery extractors, so this single context lifts all three. 14178 * Keeping the codes shared preserves the enum count at 279 and 14179 * frees consumers from another code-family migration. 14180 */ 14181 static final PredicateClauseContext FROM_SUBQUERY_BODY_WHERE = new PredicateClauseContext( 14182 "FROM-subquery body WHERE clause", 14183 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14184 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14185 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14186 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14187 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14188 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14189 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14190 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14191 14192 /** 14193 * Slice 121 — uncorrelated WHERE-side predicate subqueries inside 14194 * a scalar projection subquery body (the inner SELECT of a 14195 * {@code SELECT (SELECT ...) AS m FROM ...} scalar projection). 14196 * Reuses every {@link DiagnosticCode} from {@link #SELECT_WHERE} 14197 * (slice 113/114/120 precedent) because a scalar body IS a SELECT — 14198 * the shape rejects are semantically identical to top-level SELECT 14199 * WHERE. Only the {@code clauseLabel} differs so a reject inside a 14200 * {@code (SELECT ... WHERE NOT (...)) AS m} shape can identify the 14201 * scalar-body host context in the diagnostic message. The lift is 14202 * applied at the shared projection scalar-body build in 14203 * {@code extractScalarSubqueriesAsStatementsInternal}, covering both 14204 * the recursive (outer / CTE-body) and non-recursive (set-op-branch) 14205 * scalar contexts. The UPDATE SET-RHS scalar path keeps the slice-11 14206 * WHERE reject ({@code SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE}). 14207 * Keeping the codes shared preserves the enum count at 279 and 14208 * frees consumers from another code-family migration. 14209 */ 14210 static final PredicateClauseContext SCALAR_BODY_WHERE = new PredicateClauseContext( 14211 "scalar subquery body WHERE clause", 14212 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14213 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14214 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14215 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14216 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14217 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14218 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14219 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14220 14221 /** 14222 * Slice 116 — uncorrelated WHERE-side predicate subqueries on 14223 * MERGE per-WHEN action WHEREs ({@code TMergeUpdateClause.updateWhereClause}, 14224 * {@code TMergeUpdateClause.deleteWhereClause}, 14225 * {@code TMergeInsertClause.insertWhereClause}). Reuses every 14226 * {@link DiagnosticCode} from {@link #SELECT_WHERE} (slice 113/114 14227 * precedent) because a MERGE-action WHERE predicate IS a SELECT 14228 * WHERE in shape — the shape rejects are semantically identical to 14229 * top-level SELECT WHERE. Only the {@code clauseLabel} differs so a 14230 * reject inside a MERGE WHEN can identify the host context in the 14231 * diagnostic message. Keeping the codes shared preserves the enum 14232 * count at 279 and frees consumers from another code-family 14233 * migration. 14234 */ 14235 static final PredicateClauseContext MERGE_WHEN_WHERE = new PredicateClauseContext( 14236 "MERGE WHEN action WHERE clause", 14237 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14238 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14239 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14240 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14241 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14242 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14243 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14244 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14245 } 14246 14247 /** 14248 * Slice 110 — preserved entry-point alias for the JOIN-ON walker. 14249 * Delegates to {@link #extractUncorrelatedPredicateSubqueriesFromClause} 14250 * with {@link PredicateClauseContext#JOIN_ON} so existing JOIN-ON 14251 * callers (single site in {@code buildRelations}) need no change and 14252 * the slice-23+ diagnostic byte-shape is preserved exactly. 14253 */ 14254 private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromJoinOn( 14255 TExpression onCond, 14256 final NameBindingProvider provider, 14257 final List<StatementGraph> stmts, 14258 final List<LineageEdge> lineage, 14259 final Map<String, Integer> cteMapForExtraction) { 14260 return extractUncorrelatedPredicateSubqueriesFromClause( 14261 onCond, provider, stmts, lineage, cteMapForExtraction, 14262 PredicateClauseContext.JOIN_ON, 14263 /*correlationScope=*/ null); 14264 } 14265 14266 /** 14267 * Slice 118 — overload preserved for the slice-110 / 111 / 112 / 113 / 14268 * 114 / 116 call sites that don't admit correlation. Delegates to the 14269 * 8-arg form with {@code correlationScope=null}. 14270 */ 14271 private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause( 14272 TExpression onCond, 14273 final NameBindingProvider provider, 14274 final List<StatementGraph> stmts, 14275 final List<LineageEdge> lineage, 14276 final Map<String, Integer> cteMapForExtraction, 14277 final PredicateClauseContext ctx) { 14278 return extractUncorrelatedPredicateSubqueriesFromClause(onCond, 14279 provider, stmts, lineage, cteMapForExtraction, ctx, 14280 /*correlationScope=*/ null, /*semiFactsOut=*/ null, 14281 /*outerRelationAliases=*/ Collections.<String>emptySet()); 14282 } 14283 14284 /** 14285 * R8 — delegating overload preserving the pre-R8 7-arg signature 14286 * (correlationScope, no semi-join collector). MERGE / UPDATE / DELETE 14287 * predicate-WHERE callers route here; semi-join facts are collected 14288 * only on the SELECT WHERE path (which calls the 8-arg overload with a 14289 * non-null {@code semiFactsOut}). 14290 */ 14291 private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause( 14292 TExpression onCond, 14293 final NameBindingProvider provider, 14294 final List<StatementGraph> stmts, 14295 final List<LineageEdge> lineage, 14296 final Map<String, Integer> cteMapForExtraction, 14297 final PredicateClauseContext ctx, 14298 final EnclosingScope correlationScope) { 14299 return extractUncorrelatedPredicateSubqueriesFromClause(onCond, 14300 provider, stmts, lineage, cteMapForExtraction, ctx, 14301 correlationScope, /*semiFactsOut=*/ null, 14302 /*outerRelationAliases=*/ Collections.<String>emptySet()); 14303 } 14304 14305 /** 14306 * Slice 118 — same as the 7-arg overload but threads an optional 14307 * {@code correlationScope} (target + USING source + outer CTEs) into 14308 * {@link #extractOnePredicateSubqueryBody}. When non-null, the inner 14309 * predicate-body build uses tolerant outer binding and the post-build 14310 * correlation walk PROMOTES outer-aliased refs into synthesised 14311 * OUTER_REFERENCE relations instead of rejecting them. The FILTER and 14312 * WITHIN GROUP correlation walks remain active (codex round-1 Q2 14313 * BLOCKING fix) so refs hidden inside FILTER subtrees or PG 14314 * {@code fn.withinGroup.orderBy} continue to reject. 14315 * 14316 * <p>All non-MERGE callers pass {@code correlationScope=null} and 14317 * therefore see byte-identical behaviour. Only 14318 * {@link #collectMergeActionWhere} passes a non-null scope (built once 14319 * per MERGE in {@code buildMerge} via 14320 * {@link #buildMergeEnclosingScope}). 14321 */ 14322 private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause( 14323 TExpression onCond, 14324 final NameBindingProvider provider, 14325 final List<StatementGraph> stmts, 14326 final List<LineageEdge> lineage, 14327 final Map<String, Integer> cteMapForExtraction, 14328 final PredicateClauseContext ctx, 14329 final EnclosingScope correlationScope, 14330 final List<SemiJoinFact> semiFactsOut, 14331 final Set<String> outerRelationAliases) { 14332 // Defensive null assertions — the extraction path can only be 14333 // reached from buildRelations when 14334 // allowJoinOnPredicateSubqueries=true (outer-SELECT only), which 14335 // guarantees stmts/lineage/cteMap are non-null. Failing here means 14336 // a future refactor wired a non-outer call site through the slice-25 14337 // path without supplying the required state. 14338 if (onCond == null) { 14339 return Collections.newSetFromMap(new java.util.IdentityHashMap<TExpression, Boolean>()); 14340 } 14341 if (stmts == null || lineage == null || cteMapForExtraction == null) { 14342 throw new IllegalStateException( 14343 "extractUncorrelatedPredicateSubqueriesFromClause(" 14344 + ctx.clauseLabel + ") activated without required state — " 14345 + "stmts=" + (stmts == null ? "null" : "ok") 14346 + " lineage=" + (lineage == null ? "null" : "ok") 14347 + " cteMap=" + (cteMapForExtraction == null ? "null" : "ok") 14348 + "; caller misconfiguration"); 14349 } 14350 final Set<TExpression> extractedRoots = 14351 Collections.newSetFromMap(new java.util.IdentityHashMap<TExpression, Boolean>()); 14352 // Slice 25 (impl-review M1-fix): explicit reject for 14353 // {@code logical_not_t} over a slice-25 IN / scalar-cmp / 14354 // ANY-ALL-SOME wrapper at the root. The slice-23/24 14355 // {@code logical_not_t} over {@code exists_t} (NOT EXISTS) 14356 // remains admitted by unwrapToInnerExtractableSubquery. 14357 if (isLogicalNotOverNonExistsWrapper(onCond)) { 14358 throw new SemanticIRBuildException( 14359 Diagnostic.error(ctx.predicateNotNot, 14360 "predicate subquery in " + ctx.clauseLabel + ": NOT applied to " 14361 + "a non-EXISTS predicate subquery wrapper " 14362 + "(" + onCond.getRightOperand().getExpressionType() 14363 + ") is not supported yet — the slice-25 boundary " 14364 + "admits NOT only over EXISTS; " 14365 + "rewrite e.g. NOT (a IN (SELECT ...)) as " 14366 + "a NOT IN (SELECT ...)", onCond)); 14367 } 14368 // Root fast path: acceptChildren never visits the root node, so 14369 // a clause whose entire expression IS a wrapper would be missed 14370 // by the descendant walker. 14371 TExpression rootExtractable = unwrapToInnerExtractableSubquery(onCond); 14372 if (rootExtractable != null) { 14373 // M1-fix + slice-26 dual-side: validate the non-subquery 14374 // side of non-EXISTS wrappers BEFORE extracting (so partial 14375 // extraction never lands). Slice 25 carryover: subquery on 14376 // RHS → validate LHS via isAdmittedOuterLhsShape. 14377 // Slice 26 NEW: subquery on LHS (simple_comparison_t only) 14378 // → validate RHS via isAdmittedOuterRhsShape. 14379 if (onCond.getExpressionType() != EExpressionType.exists_t 14380 && onCond.getExpressionType() != EExpressionType.logical_not_t) { 14381 boolean isLhsSubquery = (rootExtractable == onCond.getLeftOperand()); 14382 boolean nonSubquerySideOk = isLhsSubquery 14383 ? isAdmittedOuterRhsShape(onCond.getRightOperand()) 14384 : isAdmittedOuterLhsShape(onCond.getLeftOperand()); 14385 if (!nonSubquerySideOk) { 14386 throw new SemanticIRBuildException( 14387 Diagnostic.error(ctx.outerShapeRejected, 14388 buildOuterShapeRejectionMessage(onCond, isLhsSubquery, ctx), 14389 onCond)); 14390 } 14391 } 14392 extractOnePredicateSubqueryBody(onCond, rootExtractable, provider, stmts, lineage, 14393 cteMapForExtraction, ctx, correlationScope, semiFactsOut, 14394 outerRelationAliases); 14395 extractedRoots.add(rootExtractable); 14396 } 14397 // Descendant walk: find every wrapper at any depth. Skip into 14398 // already-extracted subtrees (so we don't re-enter the body 14399 // looking for nested wrappers — covered by the inner-shape 14400 // preflight's "no nested predicate subqueries in body" 14401 // rejection). 14402 onCond.acceptChildren(new TParseTreeVisitor() { 14403 // Track depth into already-extracted roots and into wrapper 14404 // subtrees we've extracted. preVisit increments on the 14405 // wrapper (the parent that contained the inner extractable); 14406 // postVisit decrements on either the inner extractable 14407 // (extractedRoots.contains) or the wrapper 14408 // (unwrapToInnerExtractableSubquery != null). The 14409 // {@code skipDepth > 0} guard prevents the second 14410 // decrement from going negative when both apply (e.g. NOT 14411 // EXISTS — both the logical_not_t wrapper and the inner 14412 // exists_t fire). 14413 int skipDepth = 0; 14414 14415 @Override 14416 public void preVisit(TExpression e) { 14417 if (skipDepth > 0) return; 14418 if (extractedRoots.contains(e)) { 14419 // Already-extracted inner being re-visited shouldn't 14420 // happen in normal traversal but defensive guard 14421 // avoids double-extraction if it ever did. 14422 skipDepth++; 14423 return; 14424 } 14425 // Slice 25 (impl-review M1-fix): explicit reject for 14426 // {@code logical_not_t} over a slice-25 wrapper at any 14427 // depth. Without this, the visitor would descend into 14428 // the wrapper child and silently extract — admitting 14429 // a shape (`NOT (a IN (SELECT ...))`) that the 14430 // slice-25 boundary does NOT admit. 14431 if (isLogicalNotOverNonExistsWrapper(e)) { 14432 throw new SemanticIRBuildException( 14433 Diagnostic.error(ctx.predicateNotNot, 14434 "predicate subquery in " + ctx.clauseLabel + ": NOT applied to " 14435 + "a non-EXISTS predicate subquery wrapper " 14436 + "(" + e.getRightOperand().getExpressionType() 14437 + ") is not supported yet — the slice-25 " 14438 + "boundary admits NOT only over EXISTS; " 14439 + "rewrite e.g. NOT (a IN (SELECT ...)) as " 14440 + "a NOT IN (SELECT ...)", e)); 14441 } 14442 TExpression toExtract = unwrapToInnerExtractableSubquery(e); 14443 if (toExtract != null) { 14444 // M1-fix + slice-26 dual-side: validate the non- 14445 // subquery side BEFORE extracting (so partial 14446 // extraction never lands). The slice-23/24 14447 // NOT-EXISTS path uses logical_not_t, which has no 14448 // outer-shape gate. 14449 if (e.getExpressionType() != EExpressionType.exists_t 14450 && e.getExpressionType() != EExpressionType.logical_not_t) { 14451 boolean isLhsSubquery = (toExtract == e.getLeftOperand()); 14452 boolean nonSubquerySideOk = isLhsSubquery 14453 ? isAdmittedOuterRhsShape(e.getRightOperand()) 14454 : isAdmittedOuterLhsShape(e.getLeftOperand()); 14455 if (!nonSubquerySideOk) { 14456 throw new SemanticIRBuildException( 14457 Diagnostic.error(ctx.outerShapeRejected, 14458 buildOuterShapeRejectionMessage(e, isLhsSubquery, ctx), 14459 e)); 14460 } 14461 } 14462 if (extractedRoots.contains(toExtract)) return; 14463 extractOnePredicateSubqueryBody(e, toExtract, provider, stmts, lineage, 14464 cteMapForExtraction, ctx, correlationScope, semiFactsOut, 14465 outerRelationAliases); 14466 extractedRoots.add(toExtract); 14467 skipDepth++; 14468 } 14469 } 14470 14471 @Override 14472 public void postVisit(TExpression e) { 14473 // M2-fix: decrement on EITHER the extracted inner 14474 // (extractedRoots.contains) OR the wrapper 14475 // (unwrapToInnerExtractableSubquery != null). The 14476 // {@code skipDepth > 0} guard prevents going negative 14477 // when both apply. 14478 if (skipDepth > 0 14479 && (extractedRoots.contains(e) 14480 || unwrapToInnerExtractableSubquery(e) != null)) { 14481 skipDepth--; 14482 } 14483 } 14484 }); 14485 return extractedRoots; 14486 } 14487 14488 /** 14489 * R8 — a predicate-derived semi-join discovered during WHERE 14490 * predicate-subquery extraction. Carries the polarity (semi vs 14491 * anti-semi), the lifted inner block's statement index + label, the 14492 * optional outer relation alias (for the left endpoint), the 14493 * correlated outer↔inner column conditions (may be empty), and the 14494 * wrapper's source span / verbatim text. Converted to a 14495 * {@link JoinEntity} at JoinGraph-assembly time (which has the outer 14496 * relation list to resolve the left endpoint's qualified name). 14497 */ 14498 private static final class SemiJoinFact { 14499 final SemanticJoinType polarity; 14500 final int innerStatementIndex; 14501 final String innerLabel; 14502 // Distinct outer relation aliases referenced by the correlation 14503 // (insertion order). Empty for an uncorrelated subquery. When a 14504 // single alias, the left endpoint is that relation; when several, 14505 // the left endpoint is the accumulated outer rowset (codex R8). 14506 final List<String> outerAliases; 14507 final List<Predicate> conditions; // never null; may be empty 14508 final SourceSpan span; // nullable 14509 final String conditionText; // nullable 14510 14511 SemiJoinFact(SemanticJoinType polarity, int innerStatementIndex, 14512 String innerLabel, List<String> outerAliases, 14513 List<Predicate> conditions, SourceSpan span, 14514 String conditionText) { 14515 this.polarity = polarity; 14516 this.innerStatementIndex = innerStatementIndex; 14517 this.innerLabel = innerLabel; 14518 this.outerAliases = outerAliases == null 14519 ? Collections.<String>emptyList() : outerAliases; 14520 this.conditions = conditions == null 14521 ? Collections.<Predicate>emptyList() : conditions; 14522 this.span = span; 14523 this.conditionText = conditionText; 14524 } 14525 } 14526 14527 /** 14528 * R8 — semi-join polarity for a predicate wrapper. {@code EXISTS} / 14529 * {@code IN} → {@link SemanticJoinType#SEMI}; {@code NOT EXISTS} 14530 * (logical_not over exists) / {@code NOT IN} ({@code in_t} with a NOT 14531 * token) → {@link SemanticJoinType#ANTI_SEMI}. Returns {@code null} 14532 * for wrappers that are not modelled as semi-joins (scalar comparison, 14533 * ANY/ALL/SOME) so the caller emits no semi-join fact for them. 14534 */ 14535 private static SemanticJoinType semiJoinPolarity(TExpression wrapper) { 14536 if (wrapper == null) return null; 14537 EExpressionType t = wrapper.getExpressionType(); 14538 if (t == EExpressionType.logical_not_t) { 14539 // NOT EXISTS — the slice-25 boundary admits NOT only over EXISTS. 14540 TExpression inner = wrapper.getRightOperand(); 14541 if (inner != null && inner.getExpressionType() == EExpressionType.exists_t) { 14542 return SemanticJoinType.ANTI_SEMI; 14543 } 14544 return null; 14545 } 14546 if (t == EExpressionType.exists_t) { 14547 return SemanticJoinType.SEMI; 14548 } 14549 if (t == EExpressionType.in_t) { 14550 return wrapper.getNotToken() != null 14551 ? SemanticJoinType.ANTI_SEMI : SemanticJoinType.SEMI; 14552 } 14553 return null; 14554 } 14555 14556 /** 14557 * R8 — build the {@link SemiJoinFact} for an extracted predicate 14558 * subquery, or {@code null} when the wrapper is not a semi-join shape 14559 * ({@link #semiJoinPolarity} null) or no collector is requested. 14560 * 14561 * <p>Correlation pairing: 14562 * <ul> 14563 * <li>{@code IN}: outer column = the non-subquery side of the 14564 * {@code in_t}; inner column = the subquery's first output 14565 * column source.</li> 14566 * <li>{@code EXISTS} / {@code NOT EXISTS}: outer↔inner pairs are 14567 * lifted from equi-conjuncts in the inner WHERE that compare an 14568 * inner-local column to an outer (non-local) column.</li> 14569 * </ul> 14570 */ 14571 private static SemiJoinFact buildSemiJoinFact(TExpression wrapper, 14572 TExpression extractableNode, 14573 TSelectSqlStatement inner, 14574 StatementGraph innerStmt, 14575 int innerIndex, String innerLabel, 14576 Set<String> innerLocalAliases, 14577 NameBindingProvider provider) { 14578 SemanticJoinType polarity = semiJoinPolarity(wrapper); 14579 if (polarity == null) return null; 14580 List<Predicate> conditions = new ArrayList<>(); 14581 // Distinct outer aliases, insertion-ordered (codex R8: drives the 14582 // single-relation-vs-joined-rowset left-endpoint choice). 14583 java.util.LinkedHashSet<String> outerAliases = new java.util.LinkedHashSet<>(); 14584 if (wrapper.getExpressionType() == EExpressionType.in_t) { 14585 // Membership equality: outer side (the non-subquery operand) = 14586 // the subquery's first output column. Both must be bare columns. 14587 boolean lhsIsSubquery = (extractableNode == wrapper.getLeftOperand()); 14588 TExpression outerSide = lhsIsSubquery 14589 ? wrapper.getRightOperand() : wrapper.getLeftOperand(); 14590 ColumnRef outerCol = bareColumnRefOf(outerSide, provider); 14591 // codex R8 [P2 round 2]: the inner membership column must ALSO 14592 // be a bare column. `a.id IN (SELECT b.id + 1 FROM b)` projects 14593 // a compound expression — emitting `a.id = b.id` would be a 14594 // fabricated equality, so omit the membership pair there. 14595 ColumnRef innerCol = isFirstProjectionBareColumn(inner) 14596 ? firstOutputColumnRefOf(innerStmt) : null; 14597 // codex R8 [P2 round 3]: the inner membership column must be a 14598 // genuine inner-local column. `a.id IN (SELECT a.id FROM t2 b)` 14599 // projects an OUTER column — emitting `a.id = a.id` would 14600 // misrepresent the predicate and not connect the right endpoint. 14601 if (outerCol != null && innerCol != null 14602 && isLocalAlias(innerCol, innerLocalAliases)) { 14603 addOuterAlias(outerAliases, outerCol); 14604 conditions.add(equiPredicate(outerCol, innerCol, 14605 SourceSpan.of(wrapper))); 14606 } 14607 // codex R8 [P1]: a correlated IN (e.g. `a.id IN (SELECT b.id 14608 // FROM b WHERE b.k = a.k)`) also carries correlation in the 14609 // inner WHERE — fold those pairs in too so they are not lost. 14610 for (ColumnRef[] pair : extractCorrelationPairs(inner, 14611 innerLocalAliases, provider)) { 14612 addOuterAlias(outerAliases, pair[0]); 14613 conditions.add(equiPredicate(pair[0], pair[1], null)); 14614 } 14615 } else { 14616 // EXISTS / NOT EXISTS — correlation lives in the inner WHERE. 14617 for (ColumnRef[] pair : extractCorrelationPairs(inner, 14618 innerLocalAliases, provider)) { 14619 addOuterAlias(outerAliases, pair[0]); 14620 conditions.add(equiPredicate(pair[0], pair[1], null)); 14621 } 14622 } 14623 return new SemiJoinFact(polarity, innerIndex, innerLabel, 14624 new ArrayList<>(outerAliases), 14625 conditions, SourceSpan.of(wrapper), verbatimText(wrapper)); 14626 } 14627 14628 private static void addOuterAlias(java.util.LinkedHashSet<String> aliases, 14629 ColumnRef outerCol) { 14630 if (outerCol != null && outerCol.getRelationAlias() != null 14631 && !outerCol.getRelationAlias().isEmpty()) { 14632 aliases.add(outerCol.getRelationAlias()); 14633 } 14634 } 14635 14636 /** 14637 * R8 — the resolved {@link ColumnRef} for an expression that is a 14638 * <em>bare, qualified column reference</em> ({@code simple_object_name_t} 14639 * with a relation alias), or {@code null} otherwise. Strict by design 14640 * (codex R8 review): a compound operand such as {@code b.k + 1} or 14641 * {@code lower(a.n)} returns {@code null} so we never synthesise a 14642 * misleading EQUI column predicate from a side that is not a plain 14643 * column (the pair is omitted instead). 14644 */ 14645 private static ColumnRef bareColumnRefOf(TExpression expr, 14646 NameBindingProvider provider) { 14647 if (expr == null 14648 || expr.getExpressionType() != EExpressionType.simple_object_name_t) { 14649 return null; 14650 } 14651 try { 14652 List<ColumnRef> refs = collectColumnRefs(expr, provider); 14653 for (ColumnRef r : refs) { 14654 if (r != null && r.getRelationAlias() != null 14655 && !r.getRelationAlias().isEmpty()) { 14656 return r; 14657 } 14658 } 14659 } catch (RuntimeException ignore) { 14660 // Tolerant: an un-extractable column ref just yields no column. 14661 } 14662 return null; 14663 } 14664 14665 /** 14666 * R8 — true iff the inner SELECT's first projection is a bare column 14667 * reference ({@code simple_object_name_t}). Guards the IN membership 14668 * equality so a compound projection ({@code SELECT b.id + 1 ...}) does 14669 * not produce a fabricated bare-column equality. 14670 */ 14671 private static boolean isFirstProjectionBareColumn(TSelectSqlStatement inner) { 14672 if (inner == null || inner.getResultColumnList() == null 14673 || inner.getResultColumnList().size() == 0) { 14674 return false; 14675 } 14676 TResultColumn rc = inner.getResultColumnList().getResultColumn(0); 14677 if (rc == null || rc.getExpr() == null) { 14678 return false; 14679 } 14680 return rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t; 14681 } 14682 14683 /** R8 — the first source {@link ColumnRef} of a block's first output column. */ 14684 private static ColumnRef firstOutputColumnRefOf(StatementGraph innerStmt) { 14685 if (innerStmt == null) return null; 14686 List<OutputColumn> outs = innerStmt.getOutputColumns(); 14687 if (outs == null || outs.isEmpty()) return null; 14688 List<ColumnRef> sources = outs.get(0).getSources(); 14689 if (sources == null || sources.isEmpty()) return null; 14690 return sources.get(0); 14691 } 14692 14693 /** R8 — synthesise an EQUI {@link Predicate} from two column refs. */ 14694 private static Predicate equiPredicate(ColumnRef left, ColumnRef right, 14695 SourceSpan span) { 14696 return new Predicate(PredicateKind.EQUI, "=", 14697 PredicateOperand.column(left, null), 14698 PredicateOperand.column(right, null), span); 14699 } 14700 14701 /** 14702 * R8 — lift outer↔inner correlation pairs from a correlated EXISTS 14703 * inner WHERE. Walks the top-level AND conjuncts iteratively (the 14704 * left-leaning binary tree of {@code logical_and_t} — never recurse, 14705 * per the project StackOverflow guard) and, for each {@code =} 14706 * comparison whose two sides are one inner-local column and one 14707 * outer (non-local) column, emits the pair {@code [outerCol, innerCol]}. 14708 * Returns an empty list for an uncorrelated EXISTS. 14709 */ 14710 private static List<ColumnRef[]> extractCorrelationPairs( 14711 TSelectSqlStatement inner, Set<String> innerLocalAliases, 14712 NameBindingProvider provider) { 14713 List<ColumnRef[]> out = new ArrayList<>(); 14714 if (inner == null || inner.getWhereClause() == null 14715 || inner.getWhereClause().getCondition() == null) { 14716 return out; 14717 } 14718 // Iterative DFS over AND-conjuncts (left-leaning tree). 14719 Deque<TExpression> stack = new ArrayDeque<>(); 14720 stack.push(inner.getWhereClause().getCondition()); 14721 int guard = 0; 14722 while (!stack.isEmpty() && guard++ < 100000) { 14723 TExpression e = stack.pop(); 14724 if (e == null) continue; 14725 if (e.getExpressionType() == EExpressionType.logical_and_t) { 14726 if (e.getRightOperand() != null) stack.push(e.getRightOperand()); 14727 if (e.getLeftOperand() != null) stack.push(e.getLeftOperand()); 14728 continue; 14729 } 14730 if (e.getExpressionType() != EExpressionType.simple_comparison_t) { 14731 continue; 14732 } 14733 String op = e.getOperatorToken() != null 14734 ? e.getOperatorToken().toString() : null; 14735 if (op == null || !"=".equals(op)) continue; 14736 ColumnRef l = bareColumnRefOf(e.getLeftOperand(), provider); 14737 ColumnRef r = bareColumnRefOf(e.getRightOperand(), provider); 14738 if (l == null || r == null) continue; 14739 boolean lLocal = isLocalAlias(l, innerLocalAliases); 14740 boolean rLocal = isLocalAlias(r, innerLocalAliases); 14741 // Exactly one side local → the other is the outer correlation. 14742 if (lLocal && !rLocal) { 14743 out.add(new ColumnRef[]{ r, l }); // [outer, inner] 14744 } else if (rLocal && !lLocal) { 14745 out.add(new ColumnRef[]{ l, r }); 14746 } 14747 } 14748 return out; 14749 } 14750 14751 private static boolean isLocalAlias(ColumnRef ref, Set<String> innerLocalAliases) { 14752 return ref.getRelationAlias() != null 14753 && innerLocalAliases.contains( 14754 ref.getRelationAlias().toLowerCase(Locale.ROOT)); 14755 } 14756 14757 /** 14758 * R8 — true iff {@code alias} names a relation in the host SELECT's FROM 14759 * list ({@code outerRelationAliases}, lower-cased). Gates the 14760 * correlated-EXISTS degrade so only a genuine outer-relation correlation 14761 * degrades; an alias that is neither inner-local nor a known outer 14762 * relation (a typo) still rejects. 14763 */ 14764 private static boolean isKnownOuterAlias(String alias, 14765 Set<String> outerRelationAliases) { 14766 return alias != null && outerRelationAliases != null 14767 && outerRelationAliases.contains(alias.toLowerCase(Locale.ROOT)); 14768 } 14769 14770 /** 14771 * Slice 25 (rename of slice-23 {@code extractOneExistsBody}): 14772 * extract a single predicate-subquery body's inner SELECT as its 14773 * own {@code <predicate_subquery_<i>>} StatementGraph. Runs the 14774 * inner-shape preflight before recursive build, then post-build 14775 * correlation check. 14776 * 14777 * <p>{@code extractableNode} is either an {@code exists_t} 14778 * (slice-23 EXISTS / slice-24 column-bearing EXISTS) or a 14779 * {@code subquery_t} (slice-25 IN-SELECT / scalar comparison / 14780 * ANY-ALL-SOME). Both expose the inner SELECT via 14781 * {@link TExpression#getSubQuery()}. 14782 * 14783 * <p>R8 — {@code wrapper} is the predicate wrapper containing 14784 * {@code extractableNode} ({@code exists_t} / {@code logical_not_t} 14785 * over exists / {@code in_t} / comparison). When {@code semiFactsOut} 14786 * is non-null and the wrapper is an EXISTS/IN shape, a 14787 * {@link SemiJoinFact} is appended for the JoinGraph. When 14788 * {@code correlationScope} is null a correlated EXISTS now DEGRADES 14789 * (records the correlation, does not throw) instead of failing with 14790 * {@code *_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS} (R8 degrade). 14791 */ 14792 private static int extractOnePredicateSubqueryBody(TExpression wrapper, 14793 TExpression extractableNode, 14794 NameBindingProvider provider, 14795 List<StatementGraph> stmts, 14796 List<LineageEdge> lineage, 14797 Map<String, Integer> cteMapForExtraction, 14798 PredicateClauseContext ctx, 14799 EnclosingScope correlationScope, 14800 List<SemiJoinFact> semiFactsOut, 14801 Set<String> outerRelationAliases) { 14802 TSelectSqlStatement inner = extractableNode.getSubQuery(); 14803 if (inner == null) { 14804 // Degenerate node with no subquery; defensive. 14805 throw new SemanticIRBuildException( 14806 Diagnostic.error(ctx.existsBodyMissing, 14807 "EXISTS in " + ctx.clauseLabel + ": subquery body is missing", null)); 14808 } 14809 // (a–g) Inner-shape preflight (slice-23 boundary; slice 24 widens 14810 // (e) to admit single column-ref projection in addition to constant). 14811 preflightExistsInnerShape(inner); 14812 14813 // Slice 118 — when an enclosing correlation scope is supplied 14814 // (MERGE per-WHEN action WHERE only), decorate `provider` with 14815 // tolerant outer binding so the inner build admits qualified refs 14816 // to outer aliases (target / USING source / outer CTEs) as 14817 // synthetic EXACT_MATCH bindings instead of rejecting them as 14818 // COLUMN_BINDING_NON_EXACT. Mirrors the slice-117 pattern for 14819 // UPDATE SET-RHS correlated scalars. Computed BEFORE 14820 // buildSelectStatementImpl so the inner build's bindColumn calls 14821 // see the tolerant fallback already populated (codex round-5 14822 // ordering fix from slice 117). Qualifiers IN the inner's local 14823 // FROM aliases still strict-reject so real typos (`o.bad_col` 14824 // where `o` IS the inner FROM alias) still surface as 14825 // COLUMN_BINDING_NON_EXACT. 14826 final NameBindingProvider effectiveProvider; 14827 if (correlationScope != null) { 14828 Set<String> innerLocalAliasesForTolerant = 14829 precomputeInnerLocalAliases(inner); 14830 effectiveProvider = innerLocalAliasesForTolerant.isEmpty() 14831 ? provider 14832 : provider.withTolerantOuterBinding( 14833 innerLocalAliasesForTolerant); 14834 } else { 14835 effectiveProvider = provider; 14836 } 14837 14838 // Build the inner SELECT as its own StatementGraph. SAME provider 14839 // as outer (codex round-1 MUST 3 — outer CTEs remain visible). 14840 // Slice 118: tolerant-decorated provider when correlationScope 14841 // != null (MERGE per-WHEN action WHERE only); same provider as 14842 // before otherwise. 14843 // hasOuterCteListAlreadyProcessed=false (codex round-2 SHOULD 1 — 14844 // generic nested-WITH guard remains active as belt-and-braces). 14845 // allowFromSubqueries=false (no FROM-subqueries in inner body for 14846 // slice 23). isPredicateBody=true: for constant-only inner emits one 14847 // synthetic OutputColumn (slice-23 path); for column-ref inner the 14848 // §4.1.2 short-circuit falls through to the normal column-ref path 14849 // (slice-24 widening). 14850 String predName = PREDICATE_BODY_PREFIX + stmts.size() + ">"; 14851 StatementGraph innerStmt; 14852 if (isPivotSelect(inner)) { 14853 // Slice 141: a nested PIVOT / UNPIVOT in a predicate subquery body 14854 // (`WHERE k IN (SELECT ... PIVOT(...))` / EXISTS / scalar-comparison 14855 // / ANY-ALL-SOME). The slice-129 pivot router in 14856 // {@link #buildSelectStatementImpl} is gated to the OUTER SELECT 14857 // context (`name == null && !isPredicateBody`); the predicate body 14858 // carries `isPredicateBody=true` and the synthetic 14859 // `<predicate_subquery_N>` name, so the gate misses and the body 14860 // would otherwise fall to the normal path which can't bind a 14861 // `pivoted_table` (rejects with the misleading 14862 // TABLE_BINDING_UNRESOLVED "null(piviot_table)"). Route it to 14863 // {@link #buildPivotSelect} with the synthetic predicate-body name 14864 // so the body becomes a proper pivot StatementGraph. 14865 // 14866 // PIVOT extra-clause (WHERE/GROUP BY/etc.) / chained / malformed- 14867 // UNPIVOT / bare-`*`-without-catalog rejects stay deferred via 14868 // {@code buildPivotSelect}'s own guards. A bare `SELECT *` over a 14869 // pivot in a predicate body is rejected EARLIER by 14870 // {@link #preflightExistsInnerShape}'s slice-23 / 24 single-column 14871 // contract — the preflight {@code "*"} check fires before this 14872 // routing site is reached. PIVOT-then-JOIN stays on the normal 14873 // path because {@code isPivotSelect} requires 14874 // `items.size() == 0`. A subquery-source pivot in a predicate body 14875 // is rejected by the slice-24 belt-and-braces relation-kind walk 14876 // BELOW (admits TABLE / CTE only) via the existing 14877 // {@code ctx.existsInnerRelationUnknown} code. 14878 // 14879 // Direct analogue of slice 138 ({@link #processDirectSubqueryTable} 14880 // FROM-subquery body), slice 139 (the four CTE walkers), and 14881 // slice 140 ({@link #buildSetOpProgramInternal} set-op branch). 14882 // This is the SIXTH (and final) SemanticIRBuilder routing site 14883 // that previously missed the pivot router. 14884 // 14885 // Slice-23 contract preserved: the predicate body remains 14886 // UNREACHABLE from outer (no outer relation points at it; no 14887 // STATEMENT_OUTPUT edge from outer to the body). The body's own 14888 // {@link #emitLineageForStatement} call BELOW emits the pivot 14889 // output -> source edges (TABLE_COLUMN for base-table source, 14890 // STATEMENT_OUTPUT for CTE source when the body sits inside an 14891 // outer-WITH). For correlation purposes (MERGE per-WHEN action 14892 // WHERE — slice 118), the pivot body's outputs / consumed-refs are 14893 // built directly against the LOCAL source alias by 14894 // {@code buildPivotSelect}; any outer-alias ref inside the inner 14895 // would surface via the post-build correlation walk below (the 14896 // walk traverses `innerStmt`'s clause-ref slots, which for a pivot 14897 // body are exactly the pivot's local TABLE / CTE relation). 14898 innerStmt = buildPivotSelect(inner, effectiveProvider, predName); 14899 } else { 14900 innerStmt = buildSelectStatementImpl(inner, effectiveProvider, predName, 14901 /*hasOuterCteListAlreadyProcessed=*/ false, 14902 /*allowFromSubqueries=*/ false, 14903 /*allowScalarProjectionSubqueries=*/ false, 14904 /*allowWindowProjection=*/ false, 14905 /*allowJoinOnPredicateSubqueries=*/ false, 14906 /*stmtsForExtraction=*/ null, 14907 /*lineageForExtraction=*/ null, 14908 /*cteMapForExtraction=*/ null, 14909 /*isPredicateBody=*/ true, 14910 /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE, 14911 /*allowWherePredicateSubqueries=*/ false); 14912 } 14913 14914 // Slice 24 (codex impl-review SHOULD 1): defensive relation-kind 14915 // walk. The preflight rejects FROM-subqueries; the post-build 14916 // correlation check below rejects OUTER_REFERENCE relations 14917 // (synthesised by promoteCorrelatedRefsToOuterReference for 14918 // outer refs we don't see). Belt-and-braces: the predicate body 14919 // must contain ONLY TABLE or CTE-bound relations. SUBQUERY / 14920 // OUTER_REFERENCE / UNION leaking through here would mean the 14921 // emitLineageForStatement call below routes through code paths 14922 // (e.g. the SUBQUERY-alias map) that we deliberately pass empty, 14923 // producing a SemanticIRBuildException about an unregistered 14924 // alias. Failing fast here surfaces the architectural violation 14925 // with a slice-24-tuned message instead. 14926 for (RelationSource r : innerStmt.getRelations()) { 14927 RelationKind kind = r.getBinding().getKind(); 14928 if (kind != RelationKind.TABLE && kind != RelationKind.CTE) { 14929 throw new SemanticIRBuildException( 14930 Diagnostic.error(ctx.existsInnerRelationUnknown, 14931 "EXISTS in " + ctx.clauseLabel + ": inner SELECT relation '" 14932 + r.getAlias() + "' has unsupported binding kind " 14933 + kind + "; only TABLE or CTE relations are admitted " 14934 + "(slice 24 boundary)", null)); 14935 } 14936 } 14937 // Post-build correlation check (codex round-1 MUST 2 + round-2 SHOULD 2). 14938 // Use the existing collectAllInnerRefs helper so clause coverage 14939 // stays in sync with promoteCorrelatedRefsToOuterReference. 14940 // Slice 24: collectAllInnerRefs includes OutputColumn.sources, so 14941 // a column-ref projection like `EXISTS (SELECT e.id FROM x)` where 14942 // `e` is the OUTER's alias trips the same correlation rejection — 14943 // no extra slice-24 code needed. 14944 // 14945 // Slice 118 — when correlationScope != null (MERGE per-WHEN action 14946 // WHERE only), instead of REJECTING outer-aliased refs we PROMOTE 14947 // them into synthesised OUTER_REFERENCE relations via 14948 // promoteCorrelatedRefsToOuterReference. Mirrors the slice-14 / 14949 // slice-117 pattern. Unknown outer aliases (not in target / USING 14950 // source / outer CTEs) still throw SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS 14951 // (the promoter's existing boundary; diagnostic message says 14952 // "scalar subquery" — acceptable cosmetic limitation, slice 117 14953 // precedent). The slice-118 lift covers refs landing in 14954 // collectAllInnerRefs clauses (output sources, filter, join, 14955 // groupBy, having, orderBy, distinctOn); the FILTER and WITHIN 14956 // GROUP walks BELOW remain active so outer-aliased refs hidden 14957 // inside FILTER subtrees or PG fn.withinGroup.orderBy still 14958 // reject with SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS 14959 // (codex round-1 Q2 BLOCKING preserved this boundary). 14960 Set<String> innerLocalAliases = new HashSet<>(); 14961 for (RelationSource r : innerStmt.getRelations()) { 14962 innerLocalAliases.add(r.getAlias().toLowerCase(Locale.ROOT)); 14963 } 14964 // R8 — DEGRADE GATE. A catalog-less correlated EXISTS / IN in a 14965 // top-level SELECT WHERE now analyzes (the correlation is surfaced 14966 // on the SEMI / ANTI_SEMI join entity) instead of failing with 14967 // *_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS. The degrade is narrowly 14968 // scoped: only SELECT_WHERE, only non-MERGE (correlationScope == 14969 // null), only genuine EXISTS/IN semi-join shapes. JOIN-ON, scalar 14970 // comparison, ANY/ALL, FROM-subquery / scalar / set-op-branch / 14971 // CTE bodies, UPDATE/DELETE WHERE, and MERGE all keep the prior 14972 // correlation reject. 14973 boolean degradeCorrelated = correlationScope == null 14974 && ctx == PredicateClauseContext.SELECT_WHERE 14975 && semiJoinPolarity(wrapper) != null; 14976 if (correlationScope != null) { 14977 // Pass a descriptive "outerAlias" so the promoter's diagnostic 14978 // messages identify the MERGE predicate-body host context if 14979 // promotion fails on an unknown alias. 14980 innerStmt = promoteCorrelatedRefsToOuterReference( 14981 innerStmt, 14982 "<merge predicate subquery " + (stmts.size()) + ">", 14983 correlationScope); 14984 } else { 14985 for (ColumnRef ref : collectAllInnerRefs(innerStmt)) { 14986 if (!innerLocalAliases.contains(ref.getRelationAlias().toLowerCase(Locale.ROOT))) { 14987 if (degradeCorrelated 14988 && isKnownOuterAlias(ref.getRelationAlias(), outerRelationAliases)) { 14989 // A genuine correlation to an outer FROM relation: 14990 // the ref stays in the inner block's filterColumnRefs 14991 // and the outer↔inner pairing is surfaced on the 14992 // semi-join entity via buildSemiJoinFact. No throw. 14993 // codex R8 [P1 round 2]: an alias that is NEITHER 14994 // inner-local NOR a known outer relation (a typo like 14995 // `z`) still rejects below. 14996 continue; 14997 } 14998 throw new SemanticIRBuildException( 14999 Diagnostic.error(ctx.existsCorrelatedUnknownOuterAlias, 15000 "EXISTS in " + ctx.clauseLabel + ": correlated reference to outer alias '" 15001 + ref.getRelationAlias() 15002 + "' is not supported yet (slice 23 accepts uncorrelated EXISTS only)", null)); 15003 } 15004 } 15005 } 15006 // Slice 28: projection-only FILTER-aware correlation walk. The 15007 // slice-28 source-skip in buildOutputColumns removes column refs 15008 // inside FILTER (WHERE ...) subtrees from OutputColumn.sources, so 15009 // a correlated FILTER ref in the inner projection (e.g. 15010 // `EXISTS (SELECT SUM(x.s) FILTER (WHERE e.region='EU') FROM x)` 15011 // where `e` is the outer alias) would slip past the loop above. 15012 // Existing collectAllInnerRefs continues to cover correlated 15013 // FILTER refs landing in inner WHERE / HAVING / GROUP BY / ORDER BY 15014 // / JOIN-ON because those clauses still collect via plain 15015 // collectColumnRefs which descends into FILTER subtrees. 15016 TResultColumnList rclForFilterWalk = inner.getResultColumnList(); 15017 if (rclForFilterWalk != null) { 15018 for (int rci = 0; rci < rclForFilterWalk.size(); rci++) { 15019 TResultColumn rc = rclForFilterWalk.getResultColumn(rci); 15020 Set<TExpression> filterClauses = collectFilterClauses(rc); 15021 for (TExpression fclause : filterClauses) { 15022 // Slice 118 — use effectiveProvider so under 15023 // correlationScope != null, outer-aliased refs come 15024 // back as synthetic EXACT_MATCH ColumnRefs (rather 15025 // than throwing on tolerant fallback being absent). 15026 // The alias-membership rejection below then fires 15027 // for outer-aliased FILTER-inner refs, preserving 15028 // the slice-118 boundary (codex round-1 Q2 BLOCKING 15029 // fix: FILTER-inner correlation still rejects). 15030 for (ColumnRef ref : collectColumnRefs(fclause, effectiveProvider)) { 15031 boolean nonLocal = !innerLocalAliases.contains( 15032 ref.getRelationAlias().toLowerCase(Locale.ROOT)); 15033 boolean degradeThisRef = degradeCorrelated 15034 && isKnownOuterAlias(ref.getRelationAlias(), 15035 outerRelationAliases); 15036 if (nonLocal && !degradeThisRef) { 15037 // R8: the SELECT_WHERE semi-join degrade skips a 15038 // genuine outer-relation correlation here; every 15039 // other context (and unknown aliases) still reject. 15040 throw new SemanticIRBuildException( 15041 Diagnostic.error(ctx.existsCorrelatedUnknownOuterAlias, 15042 "EXISTS in " + ctx.clauseLabel + ": correlated reference to outer alias '" 15043 + ref.getRelationAlias() 15044 + "' inside FILTER (WHERE ...) is not supported yet", null)); 15045 } 15046 } 15047 } 15048 } 15049 } 15050 // Slice 30: projection-only direct WITHIN GROUP ORDER BY correlation 15051 // walk. PostgreSQL attaches WITHIN GROUP to fn.withinGroup directly, 15052 // and TFunctionCall.acceptChildren does NOT descend into that field — 15053 // so collectColumnRefs (and therefore collectAllInnerRefs above) is 15054 // blind to outer references inside `fn.withinGroup.orderBy`. A 15055 // correlated reference like 15056 // `mode() WITHIN GROUP (ORDER BY e.region)` (where `e` is the 15057 // outer alias) would slip past the slice-23 correlation loop above. 15058 // Catch it explicitly with a per-result-column WG ORDER BY scan. 15059 // Mirrors the slice-28 FILTER walk pattern. Also closes the same 15060 // correlation gap retroactively for slice-29-admitted aggregates 15061 // (LISTAGG / STRING_AGG / GROUP_CONCAT / ARRAY_AGG WITHIN GROUP), 15062 // see Slice30Test.pgCorrelatedListaggWithinGroupOrderByNowAlsoRejected. 15063 // 15064 // IMPORTANT: this walk uses a qualifier-only collector 15065 // ({@link #collectQualifierAliases}) instead of 15066 // {@link #collectColumnRefs}. Resolver2 also doesn't attach 15067 // ResolutionResult to TObjectName nodes inside PG's direct 15068 // fn.withinGroup field (it shares the AST asymmetry that lets 15069 // slice 29 admit these without a source-skip). Going through 15070 // {@code collectColumnRefs} → {@code provider.bindColumn} would 15071 // throw {@code non-exact column bindings} on legitimate 15072 // non-correlated refs (status=NOT_FOUND because Resolver2 skipped 15073 // them). The qualifier-only collector reads the qualifier alias 15074 // straight off the TObjectName, which matches slice-23's 15075 // correlation invariant: only qualified refs that name an outer 15076 // alias are caught — unqualified refs remain a documented 15077 // schema-less limitation. 15078 TResultColumnList rclForWgWalk = inner.getResultColumnList(); 15079 if (rclForWgWalk != null) { 15080 for (int rci = 0; rci < rclForWgWalk.size(); rci++) { 15081 TResultColumn rc = rclForWgWalk.getResultColumn(rci); 15082 Set<TOrderBy> wgOrderBys = collectDirectWithinGroupOrderBys(rc); 15083 for (TOrderBy wgOrderBy : wgOrderBys) { 15084 for (String alias : collectQualifierAliases(wgOrderBy)) { 15085 boolean nonLocal = !innerLocalAliases.contains( 15086 alias.toLowerCase(Locale.ROOT)); 15087 boolean degradeThisRef = degradeCorrelated 15088 && isKnownOuterAlias(alias, outerRelationAliases); 15089 if (nonLocal && !degradeThisRef) { 15090 // R8: the SELECT_WHERE semi-join degrade skips a 15091 // genuine outer-relation correlation here. 15092 throw new SemanticIRBuildException( 15093 Diagnostic.error(ctx.existsCorrelatedUnknownOuterAlias, 15094 "EXISTS in " + ctx.clauseLabel + ": correlated reference to outer alias '" 15095 + alias 15096 + "' inside WITHIN GROUP (ORDER BY ...) is not supported yet", null)); 15097 } 15098 } 15099 } 15100 } 15101 } 15102 int idx = stmts.size(); 15103 stmts.add(innerStmt); 15104 // Slice 24: emit lineage edges for the predicate body. For 15105 // constant-only inner (slice-23 carryover), the synthetic 15106 // OutputColumn has empty sources and emitLineageForStatement 15107 // emits zero edges — no shape change for slice 23. For 15108 // column-ref inner (slice 24), the real OutputColumn carries 15109 // one ColumnRef source pointing at the inner's local relation; 15110 // emitLineageForStatement emits a STATEMENT_OUTPUT → TABLE_COLUMN 15111 // edge (TABLE-bound inner) or STATEMENT_OUTPUT → STATEMENT_OUTPUT 15112 // edge (CTE-bound inner) that the projector's slice-24 pass uses 15113 // to resolve the JOIN canonical edge. 15114 // 15115 // SUBQUERY map is empty: inner-shape preflight rejects FROM-subqueries. 15116 // ScalarInfo map is empty: inner-shape preflight rejects scalar 15117 // projections (and column-ref projection is single-source, not a 15118 // scalar-subquery extraction). 15119 // Slice 118 — pass the enclosing scope's flattened SUBQUERY-alias 15120 // map under correlation mode so OUTER_REFERENCE-of-SUBQUERY refs 15121 // resolve to the enclosing MERGE's USING-subquery statement index 15122 // for cross-stmt lineage emission (mirrors slice 117 UPDATE-side 15123 // emit dispatch). 15124 Map<String, Integer> subqueryAliasMap = (correlationScope != null) 15125 ? correlationScope.flattenSubqueryAliasToIndex() 15126 : Collections.<String, Integer>emptyMap(); 15127 emitLineageForStatement(innerStmt, idx, lineage, 15128 cteMapForExtraction, 15129 subqueryAliasMap, 15130 Collections.<Integer, ScalarInfo>emptyMap()); 15131 // The predicate body remains UNREACHABLE from outer: no relation 15132 // in outer points at it, and no STATEMENT_OUTPUT lineage edge has 15133 // it as its `to`. Inner WHERE / inner JOIN refs of the predicate 15134 // body therefore cannot enter outer's row-influence walker. The 15135 // slice-24 projector pass iterates predicate bodies directly via 15136 // `isPredicateSubquerySyntheticName` to emit JOIN canonical edges 15137 // from their OutputColumn sources only (slice-24 §4.2.1). 15138 // 15139 // R8 — record the semi-join fact (EXISTS/IN → SEMI, NOT EXISTS/NOT 15140 // IN → ANTI_SEMI) so JoinGraph assembly can emit a JoinEntity 15141 // linking the outer relation to this lifted subquery block. 15142 if (semiFactsOut != null) { 15143 SemiJoinFact fact = buildSemiJoinFact(wrapper, extractableNode, inner, 15144 innerStmt, idx, innerStmt.getName(), innerLocalAliases, 15145 effectiveProvider); 15146 if (fact != null) { 15147 semiFactsOut.add(fact); 15148 } 15149 } 15150 return idx; 15151 } 15152 15153 /** 15154 * Slice 23: inner-shape preflight for an extracted EXISTS body. See 15155 * roadmap §14.25 (slice-23 plan §4.4) for the full reasoning. 15156 */ 15157 private static void preflightExistsInnerShape(TSelectSqlStatement inner) { 15158 // (a) No set-op 15159 if (inner.getSetOperatorType() != null 15160 && inner.getSetOperatorType() != ESetOperatorType.none) { 15161 throw new SemanticIRBuildException( 15162 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_IS_SET_OP, 15163 "EXISTS in JOIN ON: inner SELECT may not be a set operation", inner)); 15164 } 15165 // (b) No nested CTE list 15166 if (inner.getCteList() != null && inner.getCteList().size() > 0) { 15167 throw new SemanticIRBuildException( 15168 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_WITH, 15169 "EXISTS in JOIN ON: inner SELECT may not have its own WITH clause", inner)); 15170 } 15171 // (c) No row-limit (delegated to rejectUnsupportedShape's row-limit 15172 // guards which fire during buildSelectStatement). For an early, 15173 // specific message we also fast-fail here. 15174 if (inner.getLimitClause() != null 15175 || inner.getTopClause() != null 15176 || inner.getFetchFirstClause() != null 15177 || inner.getOffsetClause() != null) { 15178 throw new SemanticIRBuildException( 15179 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_ROW_LIMIT, 15180 "EXISTS in JOIN ON: inner SELECT may not have a row-limit clause", inner)); 15181 } 15182 // (d) Inner FROM is required (codex round-2 MUST 2). 15183 if (inner.joins == null || inner.joins.size() == 0) { 15184 throw new SemanticIRBuildException( 15185 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_MISSING_FROM, 15186 "EXISTS in JOIN ON: inner SELECT must have a FROM clause " 15187 + "(degenerate EXISTS (SELECT 1) is not in scope)", inner)); 15188 } 15189 // Slice 62 (codex plan-review round 1, P2 #1): predicate bodies 15190 // are built with allowFromSubqueries=false, so the gated reject 15191 // inside buildRelations also fires; we surface a slice-23 15192 // tuned message here so callers see the predicate-body shape 15193 // diagnostic before the generic comma-FROM message. 15194 if (inner.joins.size() > 1) { 15195 throw new SemanticIRBuildException( 15196 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_COMMA_FROM, 15197 "EXISTS in JOIN ON: comma-separated FROM list " 15198 + "(implicit cross join) in inner SELECT is not supported yet", inner)); 15199 } 15200 // Slice 63: predicate body must not contain explicit CROSS JOIN 15201 // either. Surfaces a predicate-body-tuned diagnostic before the 15202 // gated reject inside buildRelations would fire with the 15203 // generic "scalar / set-op-branch / set-op-CTE / predicate" 15204 // message. The same shared preflight is used by EXISTS / 15205 // IN-SELECT / cmp-subquery / ANY-ALL-SOME wrappers. 15206 // Slice 64: same treatment for JOIN ... USING. 15207 for (TJoin j : inner.joins) { 15208 TJoinItemList items = j.getJoinItems(); 15209 if (items == null) continue; 15210 for (int i = 0; i < items.size(); i++) { 15211 TJoinItem item = items.getJoinItem(i); 15212 if (item == null) continue; 15213 if (item.getJoinType() == EJoinType.cross) { 15214 throw new SemanticIRBuildException( 15215 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_CROSS_JOIN, 15216 "EXISTS in JOIN ON: CROSS JOIN in inner SELECT " 15217 + "is not supported yet", null)); 15218 } 15219 if (item.getUsingColumns() != null 15220 && item.getUsingColumns().size() > 0) { 15221 throw new SemanticIRBuildException( 15222 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_USING, 15223 "EXISTS in JOIN ON: JOIN ... USING (...) in inner " 15224 + "SELECT is not supported yet", null)); 15225 } 15226 // Slice 66: NATURAL JOIN inside an EXISTS-style predicate 15227 // body is rejected with an EXISTS-tuned diagnostic. The 15228 // gated reject inside buildRelations would fire later 15229 // with the generic body-context message; surfacing here 15230 // gives users an EXISTS / IN-SELECT / cmp-subquery 15231 // friendly error. 15232 if (isNaturalJoinType(item.getJoinType())) { 15233 throw new SemanticIRBuildException( 15234 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_NATURAL, 15235 "EXISTS in JOIN ON: NATURAL JOIN in inner SELECT " 15236 + "is not supported yet", null)); 15237 } 15238 } 15239 } 15240 // (d') No FROM-subquery on inner FROM/JOIN list. The recursive build 15241 // passes allowFromSubqueries=false, so buildRelation would also 15242 // reject; we surface a slice-23 specific message here. 15243 for (TJoin j : inner.joins) { 15244 if (j.getTable() != null 15245 && j.getTable().getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 15246 throw new SemanticIRBuildException( 15247 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_FROM_SUBQUERY, 15248 "EXISTS in JOIN ON: FROM-clause subquery in inner SELECT is not supported yet", null)); 15249 } 15250 TJoinItemList items = j.getJoinItems(); 15251 if (items == null) continue; 15252 for (int i = 0; i < items.size(); i++) { 15253 TTable r = items.getJoinItem(i).getTable(); 15254 if (r != null && r.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 15255 throw new SemanticIRBuildException( 15256 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_FROM_SUBQUERY_ON_JOIN, 15257 "EXISTS in JOIN ON: FROM-clause subquery on JOIN side in inner SELECT is not supported yet", null)); 15258 } 15259 } 15260 } 15261 // (e) Result-column list: exactly one column projecting either a 15262 // constant expression (slice 23), a single column reference 15263 // (slice 24), an expression / function call / CASE / 15264 // aggregate over inner columns (slice 27), an aggregate with 15265 // FILTER (WHERE ...) over inner columns (slice 28), or — on 15266 // PostgreSQL only — a whitelisted WITHIN GROUP aggregate 15267 // (slice 29 admits LISTAGG / STRING_AGG / GROUP_CONCAT / 15268 // ARRAY_AGG / count / sum / avg / min / max / stddev / 15269 // variance family; slice 30 extends with `mode`). 15270 // Multi-column / star / window function / scalar subquery / 15271 // non-whitelisted WITHIN GROUP aggregate projections are 15272 // rejected with shape-specific tuned messages — see 15273 // {@link #findUnsupportedWithinGroupFunctionName} for the 15274 // vendor + name gate. 15275 TResultColumnList rcl = inner.getResultColumnList(); 15276 if (rcl == null || rcl.size() != 1) { 15277 throw new SemanticIRBuildException( 15278 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_COLUMN_COUNT, 15279 "EXISTS in JOIN ON: inner SELECT must project exactly one column, got " 15280 + (rcl == null ? 0 : rcl.size()), null)); 15281 } 15282 TResultColumn rc0 = rcl.getResultColumn(0); 15283 if ("*".equals(rc0.getColumnNameOnly())) { 15284 throw new SemanticIRBuildException( 15285 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_NON_CONSTANT_PROJECTION, 15286 "EXISTS in JOIN ON: inner SELECT must project a constant expression " 15287 + "or a single column reference, got SELECT *", null)); 15288 } 15289 TExpression projExpr = rc0.getExpr(); 15290 if (projExpr == null || !isAdmittedPredicateProjection(projExpr)) { 15291 throw new SemanticIRBuildException( 15292 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_NON_CONSTANT_PROJECTION, 15293 "EXISTS in JOIN ON: inner SELECT must project a constant expression " 15294 + "(e.g. SELECT 1), a single column reference " 15295 + "(e.g. SELECT x.id), an expression / function call / " 15296 + "CASE / aggregate over inner columns (e.g. SELECT x.id + 1, " 15297 + "UPPER(x.region), MAX(x.id), CASE WHEN ...), an aggregate " 15298 + "with FILTER (WHERE ...) over inner columns " 15299 + "(e.g. SUM(x.id) FILTER (WHERE x.region = 'EU')), or a " 15300 + "WITHIN GROUP (ORDER BY ...) aggregate over inner columns " 15301 + "(PostgreSQL admits the direct fn.withinGroup attachment; " 15302 + "Oracle and SQL Server admit the windowDef.withinGroup " 15303 + "attachment via slice 31 when no OVER clause is present " 15304 + "— see TWindowDef.isIncludingOverClause(); slice 44 also " 15305 + "admits Snowflake hypothetical-set ordered-set aggregates " 15306 + "(rank / dense_rank / percent_rank / cume_dist) via direct " 15307 + "fn.withinGroup attachment); DB2 / Snowflake LISTAGG / " 15308 + "STRING_AGG WITHIN GROUP remain rejected pending a probe " 15309 + "of their parser-specific argument storage; window " 15310 + "functions (any OVER-bearing form) and scalar subqueries " 15311 + "are not supported yet (slice 31 boundary)", null)); 15312 } 15313 // Slice 29 / Slice 31: vendor-gated WITHIN GROUP rejecter. 15314 // 15315 // Two attachment styles, gated by vendor: 15316 // * Direct attachment ({@code fn.getWithinGroup()}): PG admits 15317 // because its visitor descent (TFunctionCall.acceptChildren) 15318 // does NOT walk fn.withinGroup, leaving OutputColumn.sources 15319 // populated exactly with the function's column-bearing args. 15320 // Snowflake and DB2 use the same field but their parser- 15321 // specific arg storage (DB2's stringExpr / separatorExpr for 15322 // LISTAGG) may not be visitor-visible — silently-empty 15323 // sources while dlineage walks fdd to the base column = 15324 // manufactured IR_MISSING_DEPENDENCY divergence; rejected. 15325 // * WindowDef attachment ({@code fn.getWindowDef().getWithinGroup()} 15326 // with WITHIN-GROUP-only windowDef): Oracle / MSSQL admit via 15327 // slice 31. The visitor DOES descend through 15328 // {@code windowDef.withinGroup.orderBy}, so the slice-31 15329 // source-skip in 15330 // {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} 15331 // keeps OutputColumn.sources from leaking the WITHIN GROUP 15332 // ORDER BY column refs (probe Q1 / Q3 / Q4 / Q5 in 15333 // {@code /tmp/probe31}). 15334 // 15335 // Probed: PG (Q1, Q5, Q6, Q9, Q10), Oracle (Q1-Q5 in 15336 // {@code /tmp/probe31}), MSSQL (Q11-Q12), SparkSQL (parser drops 15337 // WITHIN GROUP attachment, so containsAggregateWithWithinGroup 15338 // returns false and the lift applies). 15339 if (containsAggregateWithWithinGroup(projExpr)) { 15340 EDbVendor v = inner.dbvendor; 15341 // Slice 44 / 45: Snowflake admitted at this gate ONLY when 15342 // every WITHIN GROUP-bearing call in the inner projection 15343 // is an admitted Snowflake direct-attachment shape: 15344 // * hypothetical-set (rank / dense_rank / percent_rank / 15345 // cume_dist with fn.getWithinGroup()!=null and 15346 // fn.getWindowDef()==null) — slice 44; or 15347 // * mode() with the same direct-attachment shape — slice 45. 15348 // Snowflake LISTAGG / STRING_AGG / percentile_cont / 15349 // percentile_disc share the direct-attachment shape but 15350 // their parser-specific argument storage (stringExpr / 15351 // separatorExpr) and / or name-whitelist exclusion keep 15352 // the slice-31/44 rejection (see Slice44Test §C and 15353 // Slice45Test §C boundary tests). 15354 boolean snowflakeAdmittedShape = (v == EDbVendor.dbvsnowflake) 15355 && allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment(projExpr); 15356 if (v != EDbVendor.dbvpostgresql 15357 && v != EDbVendor.dbvoracle 15358 && v != EDbVendor.dbvmssql 15359 && !snowflakeAdmittedShape) { 15360 throw new SemanticIRBuildException( 15361 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_WITHIN_GROUP_AGGREGATE, 15362 "EXISTS in JOIN ON: WITHIN GROUP aggregate inner projection on " 15363 + "vendor=" + v 15364 + " is not supported yet — slice 31 admits PostgreSQL " 15365 + "(direct fn.withinGroup attachment), Oracle, and " 15366 + "SQL Server (windowDef.withinGroup attachment with " 15367 + "isIncludingOverClause=false); slice 44 additionally " 15368 + "admits Snowflake hypothetical-set ordered-set " 15369 + "aggregates (rank / dense_rank / percent_rank / " 15370 + "cume_dist) via direct fn.withinGroup attachment; " 15371 + "slice 45 additionally admits Snowflake mode() via " 15372 + "the same direct attachment; " 15373 + "DB2 / Snowflake LISTAGG / STRING_AGG / " 15374 + "percentile_cont / other direct-attachment vendors " 15375 + "remain rejected pending a probe of their parser-" 15376 + "specific argument storage", null)); 15377 } 15378 // Codex impl-review round-3 MUST: name-whitelist guard. The PG 15379 // parser attaches WITHIN GROUP to generic `func_application`, 15380 // not only to whitelisted aggregate names. Without this check, 15381 // a non-whitelisted call like `foo(x.id) WITHIN GROUP (...)` 15382 // would slip through. Slice 31: same protection applies on 15383 // Oracle / MSSQL — the windowDef-attachment grammar admits 15384 // any function name (PERCENTILE_CONT, RANK, user-defined 15385 // foo); the name guard rejects them so the IR never sees a 15386 // shape whose canonical model is unverified. 15387 String unsupportedName = findUnsupportedWithinGroupFunctionName( 15388 projExpr, inner.dbvendor); 15389 if (unsupportedName != null) { 15390 throw new SemanticIRBuildException( 15391 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_WITHIN_GROUP_NON_WHITELISTED, 15392 "EXISTS in JOIN ON: WITHIN GROUP attached to non-whitelisted " 15393 + "function '" + unsupportedName + "' is not supported yet — " 15394 + "slice 31 admits whitelisted aggregates only " 15395 + "(see SemanticIRBuilder.AGGREGATE_FUNCTION_NAMES); " 15396 + "slice 43 additionally admits PostgreSQL hypothetical-set " 15397 + "ordered-set aggregates (rank / dense_rank / percent_rank / " 15398 + "cume_dist) via direct fn.getWithinGroup attachment", null)); 15399 } 15400 } 15401 // (f) No subqueries in inner WHERE / inner JOIN ON / inner GROUP BY / 15402 // inner HAVING / inner ORDER BY. Reuses the slice-11 helper 15403 // style with a slice-23-specific message prefix. 15404 rejectSubqueriesInPredicateBodyClauses(inner); 15405 // (g) Window functions are caught by the rejecters that fire inside 15406 // buildSelectStatement (rejectWindowFunctionInScope on WHERE / 15407 // GROUP BY / HAVING / ORDER BY); the inner projection itself is 15408 // a constant expression and cannot contain a window call. 15409 } 15410 15411 /** 15412 * Slice 27: true iff {@code e} is an admitted predicate-subquery inner 15413 * projection shape. Admits (in priority order): 15414 * <ul> 15415 * <li>{@link EExpressionType#simple_object_name_t} — single column 15416 * ref (slice 24 carryover); one JOIN canonical edge per inner- 15417 * column lineage terminal.</li> 15418 * <li>{@link #isConstantExpression}-shaped constant (slice-23 15419 * carryover); zero canonical contribution.</li> 15420 * <li>Slice 27 widenings via {@link #isAdmittedSlice27ShapeRoot}: 15421 * expression / function call / CASE / aggregate over inner 15422 * columns. Probes 27 / 27b confirmed dlineage's 15423 * {@code fdr clause="on"} canonical model walks fdd to the 15424 * underlying base columns identically to the IR's 15425 * slice-24 predicate-body sweep — so canonical equivalence 15426 * holds. Aggregate-over-constants (e.g. {@code COUNT(*)}, 15427 * {@code SUM(1)}) produce empty {@code OutputColumn.sources} 15428 * and zero predicate-body JOIN edges; canonical-equivalent 15429 * to the slice-23 constant projection.</li> 15430 * </ul> 15431 * Hard rejecters fire BEFORE the {@link #isAdmittedSlice27ShapeRoot} 15432 * admit-list to keep the surface tight: 15433 * <ul> 15434 * <li>{@link #containsAnySubqueryExpression} — slice-23 invariant.</li> 15435 * <li>{@link #containsWindowFunction} — slice-13 invariant. 15436 * Slice 31 narrowed the rejecter via {@link #isWindowDefBearingFunction} 15437 * so a WITHIN-GROUP-only windowDef (Oracle / MSSQL plain 15438 * WITHIN GROUP attachment) is NOT classified as a window 15439 * function. Real OVER-bearing windowDef shapes ({@code OVER ()}, 15440 * {@code OVER (PARTITION BY ...)}, KEEP DENSE_RANK) continue to 15441 * fire the rejecter. The complementary slice-31 source-skip in 15442 * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} 15443 * removes the WITHIN GROUP ORDER BY column refs from 15444 * {@code OutputColumn.sources} on Oracle / MSSQL so the visitor's 15445 * descent through {@code windowDef.withinGroup.orderBy} doesn't 15446 * leak into projection sources.</li> 15447 * <li>Slice 29 / 31's vendor-gated WITHIN GROUP rejecter at the 15448 * {@link #preflightExistsInnerShape} call site — see that 15449 * method's vendor gate. Slice 31 admits Oracle and MSSQL 15450 * (windowDef.withinGroup attachment with 15451 * {@code !isIncludingOverClause()}) alongside PostgreSQL. 15452 * Snowflake and DB2 both attach {@code WITHIN GROUP} to the 15453 * direct {@code fn.getWithinGroup()} field (same as PG), but 15454 * their parser-specific argument storage may not be 15455 * visitor-visible (DB2 stores LISTAGG args in 15456 * {@code stringExpr} / {@code separatorExpr}, which 15457 * {@code TFunctionCall.acceptChildren} does NOT walk); they 15458 * remain rejected. SparkSQL silently drops the WITHIN GROUP 15459 * attachment at parse time (both {@code fn.withinGroup} and 15460 * {@code fn.windowDef} are null); after slice 29 SparkSQL 15461 * admits the same shape as PG, parity-friendly per probe Q1 15462 * SparkSQL.</li> 15463 * </ul> 15464 * 15465 * <p>Slice 28 lifted the prior {@code containsAggregateWithFilter} 15466 * rejecter; FILTER aggregates are now admitted, with the FILTER 15467 * predicate column refs excluded from {@code OutputColumn.sources} 15468 * globally via the FILTER-aware variant of {@link #collectColumnRefs} 15469 * used in {@link #buildOutputColumns}. See the slice-28 entry in 15470 * §14.5 of the unified roadmap and §B / §C of the slice history 15471 * archive for the load-bearing decision. 15472 * 15473 * <p>Slice 29 lifted the prior unconditional 15474 * {@code containsAggregateWithWithinGroup} rejecter and replaced it 15475 * with a vendor-gated rejecter at the 15476 * {@link #preflightExistsInnerShape} call site (Snowflake / DB2 / 15477 * other non-PostgreSQL vendors that use the direct 15478 * {@code fn.getWithinGroup()} attachment remain rejected). PG 15479 * attaches {@code WITHIN GROUP} to the direct 15480 * {@code fn.getWithinGroup()} field, and 15481 * {@code TFunctionCall.acceptChildren} does NOT descend into that 15482 * field, so {@link #collectColumnRefs} never picks up the ORDER BY 15483 * column refs — no source-skip is needed. dlineage probes Q1–Q10 15484 * confirmed canonical-model JOIN-on edges include only the aggregate's 15485 * primary argument across all four vendors (the WITHIN GROUP ORDER 15486 * BY ref appears as {@code fdr clauseType="orderby"} on PG only, and 15487 * {@code DlineageXmlProjector.projectColumn} follows fdd not fdr). 15488 * Slice 29 is restricted to whitelisted aggregates whose names 15489 * appear in {@link #AGGREGATE_FUNCTION_NAMES}. As of slice 30 the 15490 * whitelist is: {@code count}, {@code sum}, {@code avg}, {@code min}, 15491 * {@code max}, {@code stddev}, {@code variance}, {@code var_samp}, 15492 * {@code var_pop}, {@code stddev_samp}, {@code stddev_pop}, 15493 * {@code listagg}, {@code string_agg}, {@code group_concat}, 15494 * {@code array_agg}, {@code mode} (slice-30 addition — PG 15495 * ordered-set aggregate, gated for the WITHIN GROUP path only; 15496 * see {@code DlineageXmlProjector.ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES}). The predicate-body short-circuit's 15497 * {@code aggregate=true} branch fires for these regardless of 15498 * {@code OutputColumn.sources} content — column-bearing args 15499 * (e.g. {@code LISTAGG(x.id, ',')}) produce a synthesized 15500 * {@code OutputColumn} with {@code sources=[x.id]} that the slice-24 15501 * sweep walks to base-column terminals, while literal-only args 15502 * (e.g. {@code LISTAGG('hello', ',')}) produce {@code sources=[]} 15503 * with zero JOIN canonical edges — canonically equivalent to 15504 * slice-23's constant projection. 15505 * 15506 * <p>Functions NOT in the whitelist (which on PG includes 15507 * {@code percentile_cont}, {@code percentile_disc}, {@code rank}, 15508 * {@code dense_rank}, {@code percent_rank}, {@code cume_dist}, plus 15509 * any user-defined function with a direct {@code fn.withinGroup} 15510 * attachment) remain rejected by the 15511 * {@link #findUnsupportedWithinGroupFunctionName} guard at the 15512 * {@link #preflightExistsInnerShape} call site. Slice 30 lifted 15513 * {@code mode} only — the one PG ordered-set aggregate with no 15514 * documented window form in any GSP-supported vendor. Lifting 15515 * {@code percentile_cont} / {@code percentile_disc} requires either 15516 * a vendor-scoped projector OR a structural discriminator strong 15517 * enough to distinguish the cross-vendor windowed forms (Redshift / 15518 * Vertica / BigQuery / Oracle / SQL Server emit 15519 * {@code PERCENTILE_CONT WITHIN GROUP OVER (...)} variants). Lifting 15520 * {@code rank}/{@code dense_rank}/{@code percent_rank}/{@code cume_dist} 15521 * requires distinguishing window form {@code RANK() OVER (ORDER BY)} 15522 * from hypothetical-set form {@code rank(0.5) WITHIN GROUP (ORDER BY)} 15523 * — dlineage XML for the two is structurally identical on PG. See 15524 * §14.6 of the unified roadmap. 15525 */ 15526 private static boolean isAdmittedPredicateProjection(TExpression e) { 15527 if (e == null) return false; 15528 if (e.getExpressionType() == EExpressionType.simple_object_name_t) { 15529 return true; // slice 24 (column ref) 15530 } 15531 if (isConstantExpression(e)) return true; // slice 23 (constant) 15532 // Slice 27: hard rejecters before admit-list. Slice 28 lifted the 15533 // FILTER rejecter; slice 29 replaced the unconditional WITHIN 15534 // GROUP rejecter with a vendor-gated rejecter at the 15535 // preflightExistsInnerShape call site (see slice-29 §3.2). 15536 if (containsAnySubqueryExpression(e)) return false; // slice 23 invariant 15537 if (containsWindowFunction(e)) return false; // slice 13 invariant 15538 return isAdmittedSlice27ShapeRoot(e); 15539 } 15540 15541 /** 15542 * Slice 29: detect a {@code WITHIN GROUP (ORDER BY ...)} attachment 15543 * on the direct {@code fn.getWithinGroup()} field anywhere in the 15544 * subtree. This is the PG / Snowflake / DB2 attachment style. Used 15545 * as a vendor-gated rejecter in {@link #preflightExistsInnerShape}: 15546 * non-admitted vendors with this attachment remain rejected because 15547 * their parser-specific argument storage may not be visitor-visible 15548 * (DB2's {@code LISTAGG} stores args in {@code stringExpr} / 15549 * {@code separatorExpr}, which the default 15550 * {@code TFunctionCall.acceptChildren} does NOT walk — 15551 * {@code OutputColumn.sources} would be silently empty while dlineage 15552 * walks fdd to the base column, manufacturing 15553 * {@code IR_MISSING_DEPENDENCY} divergence). 15554 * 15555 * <p>Slice 31: also detects WITHIN GROUP attached to 15556 * {@code fn.getWindowDef().getWithinGroup()} when the windowDef is 15557 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} — the 15558 * Oracle / MSSQL attachment style. Both attachments are routed 15559 * through {@link #hasWithinGroupAnyAttachment}. 15560 */ 15561 private static boolean containsAggregateWithWithinGroup(TExpression e) { 15562 if (e == null) return false; 15563 final boolean[] found = {false}; 15564 e.acceptChildren(new TParseTreeVisitor() { 15565 @Override 15566 public void preVisit(TFunctionCall fn) { 15567 if (found[0]) return; 15568 if (hasWithinGroupAnyAttachment(fn)) found[0] = true; 15569 } 15570 }); 15571 if (!found[0] && e.getExpressionType() == EExpressionType.function_t) { 15572 TFunctionCall fn = e.getFunctionCall(); 15573 if (hasWithinGroupAnyAttachment(fn)) found[0] = true; 15574 } 15575 return found[0]; 15576 } 15577 15578 /** 15579 * Slice 31: shared predicate used by {@link #containsAggregateWithWithinGroup} 15580 * and {@link #findUnsupportedWithinGroupFunctionName}. Returns 15581 * {@code true} iff {@code fn} carries {@code WITHIN GROUP} via 15582 * either: 15583 * <ul> 15584 * <li>direct {@code fn.getWithinGroup()} field (PG / Snowflake / 15585 * DB2 / SparkSQL parser style);</li> 15586 * <li>{@code fn.getWindowDef().getWithinGroup()} when the 15587 * windowDef is {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} 15588 * (Oracle / MSSQL parser style).</li> 15589 * </ul> 15590 */ 15591 private static boolean hasWithinGroupAnyAttachment(TFunctionCall fn) { 15592 if (fn == null) return false; 15593 if (fn.getWithinGroup() != null) return true; 15594 return isWithinGroupOnlyWindowDef(fn.getWindowDef()); 15595 } 15596 15597 /** 15598 * Slice 29 (codex impl-review round-3 MUST): walk the expression 15599 * subtree and return the (lower-cased) function name of any 15600 * {@code TFunctionCall} that carries WITHIN GROUP — via direct 15601 * {@code fn.getWithinGroup()} (PG style) or via 15602 * {@code fn.getWindowDef().getWithinGroup()} when the windowDef is 15603 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} (Oracle / 15604 * MSSQL style; slice 31) — whose name is NOT in 15605 * {@link #AGGREGATE_FUNCTION_NAMES}. Returns {@code null} if every 15606 * WITHIN GROUP-bearing call uses a whitelisted aggregate name. 15607 * Used at the {@code preflightExistsInnerShape} call site to reject 15608 * {@code foo(x.id) WITHIN GROUP (...)}-shaped projections where 15609 * {@code foo} isn't an aggregate the IR knows how to model. 15610 * 15611 * <p>Slice 43: now takes the inner {@link EDbVendor} so the 15612 * {@link #isAdmittedWithinGroupName} delegate can apply the 15613 * PG-only hypothetical-set carve-out 15614 * ({@link #isDirectAttachmentHypotheticalSetCall}; widened to 15615 * Snowflake by slice 44). 15616 */ 15617 private static String findUnsupportedWithinGroupFunctionName( 15618 TExpression e, final EDbVendor vendor) { 15619 if (e == null) return null; 15620 final String[] firstUnsupported = {null}; 15621 e.acceptChildren(new TParseTreeVisitor() { 15622 @Override 15623 public void preVisit(TFunctionCall fn) { 15624 if (firstUnsupported[0] != null) return; 15625 if (!hasWithinGroupAnyAttachment(fn)) return; 15626 String name = fn.getFunctionName() == null 15627 ? null : fn.getFunctionName().toString(); 15628 if (isAdmittedWithinGroupName(fn, name, vendor)) return; 15629 firstUnsupported[0] = name == null ? "<unnamed>" : name; 15630 } 15631 }); 15632 if (firstUnsupported[0] == null 15633 && e.getExpressionType() == EExpressionType.function_t) { 15634 TFunctionCall fn = e.getFunctionCall(); 15635 if (hasWithinGroupAnyAttachment(fn)) { 15636 String name = fn.getFunctionName() == null 15637 ? null : fn.getFunctionName().toString(); 15638 if (!isAdmittedWithinGroupName(fn, name, vendor)) { 15639 firstUnsupported[0] = name == null ? "<unnamed>" : name; 15640 } 15641 } 15642 } 15643 return firstUnsupported[0]; 15644 } 15645 15646 /** 15647 * Slice 42 helper used by {@link #findUnsupportedWithinGroupFunctionName}. 15648 * Returns {@code true} iff {@code name} is in the regular 15649 * {@link #AGGREGATE_FUNCTION_NAMES} whitelist, OR — under the 15650 * AST-shape constraint 15651 * {@link #isHypotheticalSetWithinGroupCall} — in the slice-42 15652 * {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES} whitelist (Oracle / 15653 * MSSQL windowDef-bearing attachment), OR — under the slice-43 15654 * AST-shape constraint 15655 * {@link #isDirectAttachmentHypotheticalSetCall} — in the same 15656 * hypothetical-set whitelist on PostgreSQL (slice 43) or Snowflake 15657 * (slice 44) via direct {@code fn.getWithinGroup()} attachment. 15658 * 15659 * <p>The shape constraints pin the carve-outs by parser flavor: 15660 * Oracle / MSSQL produce {@code fn.getWindowDef()!=null} with 15661 * {@code wd.getWithinGroup()!=null} and {@code !wd.isIncludingOverClause()}; 15662 * PG produces {@code fn.getWithinGroup()!=null} with 15663 * {@code fn.getWindowDef()==null}. Slice 43 admits that direct- 15664 * attachment hypothetical-set carve-out for PostgreSQL; slice 44 15665 * widens the same probe-confirmed shape to Snowflake. DB2 and other 15666 * direct-attachment vendors remain outside this helper until their 15667 * AST / dlineage parity is explicitly probed and covered. 15668 */ 15669 private static boolean isAdmittedWithinGroupName( 15670 TFunctionCall fn, String name, EDbVendor vendor) { 15671 if (name == null || name.isEmpty()) return false; 15672 String lower = name.toLowerCase(Locale.ROOT); 15673 if (AGGREGATE_FUNCTION_NAMES.contains(lower)) return true; 15674 if (isHypotheticalSetWithinGroupCall(fn)) return true; 15675 return isDirectAttachmentHypotheticalSetCall(fn, vendor); 15676 } 15677 15678 /** 15679 * Slice 43 / 44: true iff {@code fn} is a direct-attachment 15680 * hypothetical-set ordered-set aggregate call shape — {@code rank} / 15681 * {@code dense_rank} / {@code percent_rank} / {@code cume_dist} with 15682 * {@code fn.getWithinGroup()!=null} AND {@code fn.getWindowDef()==null}, 15683 * AND {@code vendor} is in {PostgreSQL, Snowflake}. 15684 * 15685 * <p>Used as a name-whitelist exception inside 15686 * {@link #isAdmittedWithinGroupName} for predicate-body inner 15687 * projections only. Top-level admission is deliberately not granted: 15688 * top-level lifting requires a vendor-scoped projector override 15689 * (slice 43 introduces the API but defers the override to a future 15690 * slice because PG / Snowflake dlineage XML is structurally 15691 * indistinguishable between the WG and OVER forms — naive override 15692 * breaks {@code rank() OVER (ORDER BY x)} classification). 15693 * 15694 * <p>Vendor gate: PG (slice 43) and Snowflake (slice 44 — probe- 15695 * confirmed AST + dlineage XML byte-identical to PG for all 15696 * four hypothetical-set names). DB2 / Greenplum / Redshift parse-fail 15697 * on the syntax. Other direct-attachment vendors (e.g. SparkSQL drops 15698 * WITHIN GROUP attachment at parse time) remain rejected pending a 15699 * fresh probe. 15700 * 15701 * <p>Probe: {@code /tmp/probe43/Probe43.java} (slice 43) and 15702 * {@code probe44.Probe44Test} (slice 44, captured during slice-44 15703 * implementation) confirmed the AST predicate matches PG / Snowflake 15704 * hypothetical-set forms (and not the OVER form), and confirmed the 15705 * dlineage XML for {@code EXISTS (SELECT rank(0.5) WITHIN GROUP 15706 * (ORDER BY x.salary) FROM locations x)} contributes zero base-table 15707 * edges from the inner predicate body (literal arg + WG ORDER BY ref 15708 * via {@code clauseType="orderby"} fdr that the projector's 15709 * {@code clauseTypeToRole} does not map to FILTER/JOIN). Both 15710 * projectors therefore agree on zero predicate-body lineage edges 15711 * for the slice-43 / slice-44 shape — no projector change required. 15712 */ 15713 private static boolean isDirectAttachmentHypotheticalSetCall( 15714 TFunctionCall fn, EDbVendor vendor) { 15715 if (fn == null) return false; 15716 if (vendor != EDbVendor.dbvpostgresql 15717 && vendor != EDbVendor.dbvsnowflake) return false; 15718 if (!isDirectAttachmentHypotheticalSetCallShape(fn)) return false; 15719 return true; 15720 } 15721 15722 /** 15723 * Slice 44: vendor-agnostic shape predicate for the direct-attachment 15724 * hypothetical-set call form ({@code fn.getWithinGroup()!=null} AND 15725 * {@code fn.getWindowDef()==null} AND function name in 15726 * {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES}). Used together with 15727 * {@link #isDirectAttachmentModeCallShape} (slice 45) by 15728 * {@link #allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment} to 15729 * gate Snowflake admission. Snowflake LISTAGG / STRING_AGG / 15730 * percentile_cont WITHIN GROUP share this attachment style but their 15731 * parser-specific argument storage ({@code stringExpr} / 15732 * {@code separatorExpr}) and dlineage XML parity remain unprobed 15733 * (slice-31 boundary preserved). 15734 */ 15735 private static boolean isDirectAttachmentHypotheticalSetCallShape( 15736 TFunctionCall fn) { 15737 if (fn == null) return false; 15738 if (fn.getWithinGroup() == null) return false; 15739 if (fn.getWindowDef() != null) return false; 15740 if (fn.getFunctionName() == null) return false; 15741 String name = fn.getFunctionName().toString(); 15742 if (name == null || name.isEmpty()) return false; 15743 return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains( 15744 name.toLowerCase(Locale.ROOT)); 15745 } 15746 15747 /** 15748 * Slice 45: vendor-agnostic shape predicate for the direct-attachment 15749 * {@code mode()} ordered-set aggregate call form 15750 * ({@code fn.getWithinGroup()!=null} AND 15751 * {@code fn.getWindowDef()==null} AND function name equals 15752 * {@code mode}). Parallel to 15753 * {@link #isDirectAttachmentHypotheticalSetCallShape}; used by 15754 * {@link #allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment} 15755 * to admit Snowflake {@code mode() WITHIN GROUP (ORDER BY ...)} 15756 * predicate-body inner projections. 15757 * 15758 * <p>Probe-confirmed (see {@code /tmp/Probe45c.java} captured during 15759 * slice-45 implementation): Snowflake parses {@code mode() WITHIN 15760 * GROUP (ORDER BY x.salary)} with {@code fn.getWithinGroup() != null} 15761 * and {@code fn.getWindowDef() == null}, identical to PG. The 15762 * Snowflake dlineage XML for the predicate-body wrapper shape is 15763 * byte-equivalent to PG (same {@code resultset name="mode" 15764 * type="function"} wrapper, same {@code orderby} fdr that 15765 * {@code clauseTypeToRole} does not map to FILTER/JOIN); the 15766 * canonical model has zero predicate-body lineage edges, matching 15767 * the IR side (mode has no args, default visitor descent does not 15768 * walk direct {@code fn.withinGroup}). 15769 * 15770 * <p>Why mode is admitted but Snowflake LISTAGG / STRING_AGG / 15771 * percentile_cont aren't (slice-44/45 boundaries): mode has no 15772 * positional argument, so the OutputColumn.sources collection is 15773 * trivially empty and matches the dlineage zero-edge canonical model. 15774 * LISTAGG / STRING_AGG store args in parser-specific 15775 * {@code stringExpr} / {@code separatorExpr} fields whose visitor 15776 * descent has not been probed; admitting them risks 15777 * silently-empty IR sources against a non-empty dlineage column-arg 15778 * fdd. percentile_cont / percentile_disc use a literal arg 15779 * (slice-44 §C boundary preserved) but are not in 15780 * {@link #AGGREGATE_FUNCTION_NAMES}, so the slice-29 name-whitelist 15781 * guard fires inside {@link #findUnsupportedWithinGroupFunctionName} 15782 * and rejects regardless of vendor gate. 15783 */ 15784 private static boolean isDirectAttachmentModeCallShape( 15785 TFunctionCall fn) { 15786 if (fn == null) return false; 15787 if (fn.getWithinGroup() == null) return false; 15788 if (fn.getWindowDef() != null) return false; 15789 if (fn.getFunctionName() == null) return false; 15790 String name = fn.getFunctionName().toString(); 15791 if (name == null || name.isEmpty()) return false; 15792 return "mode".equals(name.toLowerCase(Locale.ROOT)) 15793 && hasNoFunctionArgs(fn); 15794 } 15795 15796 private static boolean hasNoFunctionArgs(TFunctionCall fn) { 15797 return fn != null && (fn.getArgs() == null || fn.getArgs().size() == 0); 15798 } 15799 15800 /** 15801 * Slice 45 (renamed and widened from the slice-44 helper 15802 * {@code allWithinGroupCallsAreDirectAttachmentHypotheticalSet}): 15803 * returns {@code true} iff {@code e} contains at least one WITHIN 15804 * GROUP-bearing function call AND every such call uses an 15805 * <i>admitted</i> Snowflake direct-attachment shape — either 15806 * hypothetical-set ({@link #isDirectAttachmentHypotheticalSetCallShape}, 15807 * slice 44) or mode ({@link #isDirectAttachmentModeCallShape}, 15808 * slice 45). Used to gate the predicate-body vendor whitelist widen 15809 * at the {@code preflightExistsInnerShape} call site so Snowflake is 15810 * admitted only on these probe-confirmed shapes — Snowflake LISTAGG / 15811 * STRING_AGG / percentile_cont / percentile_disc / other names 15812 * remain rejected (their parser-specific argument storage and 15813 * dlineage XML parity are unprobed; slice-31/44 boundary 15814 * preserved). 15815 * 15816 * <p>Mixed expressions (e.g. {@code mode() WG (...) || rank(0.5) 15817 * WG (...)} in a single predicate-body inner projection) are 15818 * admitted when every WG-bearing call is admitted-shape; 15819 * one non-admitted-shape call blocks the whole expression 15820 * (Slice45Test §D). 15821 */ 15822 private static boolean allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment( 15823 TExpression e) { 15824 if (e == null) return false; 15825 final boolean[] sawAny = {false}; 15826 final boolean[] sawNonAdmitted = {false}; 15827 e.acceptChildren(new TParseTreeVisitor() { 15828 @Override 15829 public void preVisit(TFunctionCall fn) { 15830 if (!hasWithinGroupAnyAttachment(fn)) return; 15831 sawAny[0] = true; 15832 if (!isDirectAttachmentHypotheticalSetCallShape(fn) 15833 && !isDirectAttachmentModeCallShape(fn)) { 15834 sawNonAdmitted[0] = true; 15835 } 15836 } 15837 }); 15838 if (e.getExpressionType() == EExpressionType.function_t) { 15839 TFunctionCall fn = e.getFunctionCall(); 15840 if (fn != null && hasWithinGroupAnyAttachment(fn)) { 15841 sawAny[0] = true; 15842 if (!isDirectAttachmentHypotheticalSetCallShape(fn) 15843 && !isDirectAttachmentModeCallShape(fn)) { 15844 sawNonAdmitted[0] = true; 15845 } 15846 } 15847 } 15848 return sawAny[0] && !sawNonAdmitted[0]; 15849 } 15850 15851 /** 15852 * Slice 27: fail-closed enumeration of admitted projection root shapes 15853 * after the slice-23/24 fast paths and the hard-rejecter guards have 15854 * been considered by {@link #isAdmittedPredicateProjection}. 15855 * Open-ended type checks are intentionally avoided 15856 * (slice-history §C / codex round-1 SHOULD 5). 15857 * 15858 * <p>Admits: 15859 * <ul> 15860 * <li>{@code function_t} — any function call (aggregate or scalar). 15861 * OVER-bearing window functions are rejected by the caller's 15862 * {@code containsWindowFunction} guard (slice 31 narrowed via 15863 * {@link #isWindowDefBearingFunction} so WITHIN-GROUP-only 15864 * windowDef passes; OVER-bearing forms still rejected). 15865 * {@code FILTER (WHERE ...)} was admitted in slice 28 (with 15866 * FILTER predicate refs excluded from {@code OutputColumn.sources} 15867 * via {@link #collectColumnRefsExcludingFilterClauses} — 15868 * slice 31 widens to 15869 * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}). 15870 * PG-style direct {@code fn.withinGroup} attachment was admitted 15871 * in slice 29 via the vendor-gated rejecter at the 15872 * {@link #preflightExistsInnerShape} call site; slice 31 extends 15873 * admission to Oracle / MSSQL windowDef-bearing WITHIN GROUP 15874 * (Snowflake / DB2 / other direct-attachment vendors remain 15875 * rejected pending probe).</li> 15876 * <li>{@code case_t} — simple or searched CASE.</li> 15877 * <li>Pure binary ({@link TExpression#isPureBinaryForDoParse}) — 15878 * arithmetic, concat, comparison.</li> 15879 * <li>{@code parenthesis_t} — descend.</li> 15880 * <li>{@code typecast_t} — PostgreSQL / Snowflake / Redshift 15881 * {@code expr::TYPE} (slice 37; cross-vendor probe slice 38). 15882 * Admit unconditionally; the slice-13 invariant rejecters 15883 * ({@link #containsAnySubqueryExpression} / 15884 * {@link #containsWindowFunction}) fire BEFORE this admit check 15885 * inside {@link #isAdmittedPredicateProjection}, so 15886 * {@code (SELECT 1)::INT} and {@code (ROW_NUMBER() OVER ())::INT} 15887 * are still rejected. The default visitor descent walks 15888 * {@code typecast_t.getLeftOperand()} so 15889 * {@code OutputColumn.sources} populates with the underlying 15890 * column refs (probe-verified for PG — 15891 * {@code /tmp/probe37/Probe37.java}; slice 38 extended the probe 15892 * to Snowflake and Redshift — 15893 * {@code /tmp/probe38/Probe38.java}, {@code CheckCurrent.java} — 15894 * and confirmed byte-identical AST + dlineage XML to PG for both 15895 * {@code x.id::VARCHAR [AS lst]} and {@code LOWER(x.id)::VARCHAR} 15896 * composed forms with zero divergence; slice 39 extended the probe 15897 * to Greenplum, Vertica, GaussDB, Netezza — 15898 * {@code /tmp/probe39/Probe39.java}, {@code Probe39b.java} — and 15899 * confirmed AST + dlineage XML byte-identical to the PG / Snowflake / 15900 * Redshift contract for both aliased and unaliased forms with zero 15901 * divergence; slice 40 extended the probe to BigQuery, Trino, Presto, 15902 * EDB, DuckDB, Databricks — 15903 * {@code /tmp/probe40/Probe40.java}, {@code Probe40b.java}, 15904 * {@code Probe40c.java}, {@code Probe40d.java}, {@code Probe40e.java} 15905 * — and confirmed AST + dlineage XML byte-identical to the 15906 * PG / Snowflake / Redshift contract for aliased, unaliased, and 15907 * {@code LOWER(x.id)::VARCHAR} composed forms with zero divergence; 15908 * slice 41 closes out the residual vendor matrix — 15909 * {@code /tmp/probe41/Probe41.java}, {@code Probe41b.java} — 15910 * confirming Informix native {@code typecast_t} (AST + dlineage XML 15911 * byte-identical to the PG / Snowflake / Redshift contract); 15912 * ClickHouse parser auto-lowers {@code expr::TYPE} to 15913 * {@code function_t} so the slice-27 admission applies; 15914 * Sybase / Flink / Dameng parse-fail on {@code ::TYPE} but accept 15915 * {@code CAST(x AS TYPE)} via {@code function_t} (slice-27 15916 * carryover); Exasol / AzureSQL parse {@code expr::TYPE} as 15917 * {@code simple_object_name_t} (vendor-quirk — the {@code ::} is 15918 * interpreted as a qualified-name separator, mirroring T-SQL's 15919 * {@code tablename::method()} schema-qualified syntax) so the 15920 * slice-32 exclusion routes via normal column handling; 15921 * OceanBase / Impala / StarRocks parse-fail boundary locked in). 15922 * Oracle uses {@code CAST(x AS TYPE)} which parses as 15923 * {@code function_t} (already admitted above), so no Oracle-specific 15924 * {@code typecast_t} admission is needed; Hive / SparkSQL parse-fail 15925 * on the {@code ::TYPE} syntax — slice 39 pins this boundary; slice 15926 * 40 extends the parse-fail boundary lock-in to DB2, Teradata, MySQL, 15927 * and HANA so a future grammar lift fires loudly and re-probe is 15928 * required before relying on zero-divergence for those dialects. 15929 * The slice-37 admission remains structural (no vendor gate); future 15930 * vendors that surface {@code typecast_t} will be admitted 15931 * automatically — re-probe before relying on zero-divergence 15932 * guarantees.</li> 15933 * </ul> 15934 * Implicitly rejects {@code list_t}, {@code subquery_t} (caught by the 15935 * caller), and any unknown expression type. 15936 */ 15937 private static boolean isAdmittedSlice27ShapeRoot(TExpression e) { 15938 if (e == null) return false; 15939 EExpressionType t = e.getExpressionType(); 15940 if (t == EExpressionType.function_t) return true; 15941 if (t == EExpressionType.case_t) return true; 15942 if (t == EExpressionType.parenthesis_t) { 15943 return e.getLeftOperand() != null 15944 && isAdmittedSlice27ShapeRoot(e.getLeftOperand()); 15945 } 15946 if (t == EExpressionType.typecast_t) return true; // slice 37 (cross-vendor parity probed in slice 38; widened in slice 39 + slice 40; residual vendors locked in by slice 41 — Informix typecast_t) 15947 if (TExpression.isPureBinaryForDoParse(t)) return true; 15948 return false; 15949 } 15950 15951 /** 15952 * Slice 27: visitor-based deep window-function detector. Mirrors 15953 * {@link #rejectWindowFunctions} (line ~4530) but boolean-returning so 15954 * it can be used as a guard inside 15955 * {@link #isAdmittedPredicateProjection}. 15956 * 15957 * <p>Slice 31: discriminates WITHIN-GROUP-only windowDef shapes 15958 * (Oracle / MSSQL plain {@code WITHIN GROUP (ORDER BY ...)} attachment 15959 * without OVER) from OVER-bearing ones via 15960 * {@link #isWindowDefBearingFunction}. Plain WITHIN GROUP no longer 15961 * counts as a window function for predicate-body inner-projection 15962 * admission. NOTE: only this helper and {@link #isAggregateFunction} 15963 * are lifted — every other slice-13 invariant rejecter 15964 * ({@link #rejectHavingWindowFunction}, 15965 * {@link #rejectOrderByWindowFunction}, 15966 * {@link #rejectWindowFunctionInScope}, 15967 * {@link #rejectWindowFunctions}, 15968 * {@link #rejectEmbeddedWindowFunction}, 15969 * {@link #isTopLevelWindowProjection}, and the OVER ORDER BY 15970 * window check inside {@code buildWindowOrderRefs}) keeps the 15971 * strict {@code wd != null} check unchanged so HAVING / ORDER BY / 15972 * WHERE / GROUP BY / JOIN ON / top-level projection contexts still 15973 * reject WITHIN-GROUP-only attachments — slice 31 boundary. 15974 */ 15975 private static boolean containsWindowFunction(TExpression e) { 15976 if (e == null) return false; 15977 final boolean[] found = {false}; 15978 e.acceptChildren(new TParseTreeVisitor() { 15979 @Override 15980 public void preVisit(TFunctionCall fn) { 15981 if (found[0]) return; 15982 if (isWindowDefBearingFunction(fn)) found[0] = true; 15983 } 15984 }); 15985 if (!found[0] && e.getExpressionType() == EExpressionType.function_t) { 15986 TFunctionCall fn = e.getFunctionCall(); 15987 if (isWindowDefBearingFunction(fn)) found[0] = true; 15988 } 15989 return found[0]; 15990 } 15991 15992 /** 15993 * Slice 31: discriminate WITHIN-GROUP-only {@link TWindowDef} shapes 15994 * from OVER-bearing ones. Returns {@code true} iff: 15995 * <ul> 15996 * <li>{@code wd.getWithinGroup() != null} — the discriminating 15997 * attachment;</li> 15998 * <li>{@code !wd.isIncludingOverClause()} — no OVER syntax of any 15999 * kind (including empty {@code OVER ()});</li> 16000 * <li>{@code wd.getKeepDenseRankClause() == null} — Oracle KEEP 16001 * DENSE_RANK FIRST/LAST is a slice-22 deferred shape and must 16002 * remain windowed.</li> 16003 * </ul> 16004 * 16005 * <p>Probe-validated: {@code TWindowDef.isIncludingOverClause()} is 16006 * {@code false} for plain {@code WITHIN GROUP} and {@code true} for 16007 * any OVER-bearing form (probe Q8 / Q9 / Q11 in 16008 * {@code /tmp/probe31}). 16009 * 16010 * <p>Used by {@link #isWindowDefBearingFunction} (the slice-13 16011 * invariant lift's discriminator). 16012 */ 16013 private static boolean isWithinGroupOnlyWindowDef(TWindowDef wd) { 16014 if (wd == null) return false; 16015 if (wd.isIncludingOverClause()) return false; 16016 if (wd.getWithinGroup() == null) return false; 16017 if (wd.getKeepDenseRankClause() != null) return false; 16018 return true; 16019 } 16020 16021 /** 16022 * Slice 31: a {@link TFunctionCall} is an OVER-bearing window-def 16023 * function iff its {@code windowDef} is non-null AND not 16024 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}. Replaces 16025 * the historical {@code fn.getWindowDef() != null} check inside 16026 * {@link #containsWindowFunction} and {@link #isAggregateFunction}; 16027 * every other rejecter retains the strict {@code wd != null} check 16028 * unchanged (slice 31 narrow lift). 16029 */ 16030 private static boolean isWindowDefBearingFunction(TFunctionCall fn) { 16031 if (fn == null) return false; 16032 TWindowDef wd = fn.getWindowDef(); 16033 if (wd == null) return false; 16034 return !isWithinGroupOnlyWindowDef(wd); 16035 } 16036 16037 /** 16038 * Slice 33: a {@link TFunctionCall} is an admitted top-level 16039 * WITHIN-GROUP-only aggregate iff: 16040 * 16041 * <ul> 16042 * <li>{@link #isWithinGroupOnlyWindowDef} returns true on its 16043 * windowDef (Oracle / MSSQL plain {@code WITHIN GROUP}, no 16044 * OVER, no KEEP DENSE_RANK);</li> 16045 * <li>{@code vendor} is Oracle or MSSQL — explicit gate mirroring 16046 * the slice-31 predicate-body gate at line ~3860. PG / 16047 * Snowflake / DB2 / SparkSQL produce direct 16048 * {@code fn.withinGroup} attachment with {@code windowDef=null} 16049 * and don't reach this helper today, but the explicit gate 16050 * keeps the contract narrow against future parser changes;</li> 16051 * <li>The function name is in {@link #AGGREGATE_FUNCTION_NAMES} 16052 * (LISTAGG / STRING_AGG / SUM / MIN / MAX / MODE / etc.).</li> 16053 * </ul> 16054 * 16055 * <p>Used only by {@link #buildOutputColumns} to fall through to the 16056 * normal aggregate path. The slice-13 invariant rejecters 16057 * ({@link #isTopLevelWindowProjection}, 16058 * {@link #rejectWindowFunctions}, 16059 * {@link #rejectEmbeddedWindowFunction}, 16060 * {@link #rejectHavingWindowFunction}, 16061 * {@link #rejectOrderByWindowFunction}, 16062 * {@link #rejectWindowFunctionInScope}) 16063 * keep the strict {@code wd != null} check unchanged; slice 33 16064 * admission is gated by a single boolean local to 16065 * {@code buildOutputColumns}. 16066 * 16067 * <p>Non-whitelisted names (PERCENTILE_CONT / PERCENTILE_DISC / 16068 * RANK / DENSE_RANK / PERCENT_RANK / CUME_DIST / user-defined) 16069 * keep routing to {@link #buildWindowOutputColumn} where the 16070 * {@link #WINDOW_FUNCTION_NAMES} guard rejects them as 16071 * "unsupported window function". 16072 */ 16073 private static boolean isAdmittedTopLevelWithinGroupAggregate( 16074 TFunctionCall fn, EDbVendor vendor) { 16075 if (fn == null) return false; 16076 if (!isWithinGroupOnlyWindowDef(fn.getWindowDef())) return false; 16077 if (vendor != EDbVendor.dbvoracle && vendor != EDbVendor.dbvmssql) { 16078 return false; 16079 } 16080 if (fn.getFunctionName() == null) return false; 16081 String name = fn.getFunctionName().toString(); 16082 if (name == null || name.isEmpty()) return false; 16083 String lower = name.toLowerCase(Locale.ROOT); 16084 if (AGGREGATE_FUNCTION_NAMES.contains(lower)) return true; 16085 // Slice 42: hypothetical-set ordered-set aggregates (RANK / 16086 // DENSE_RANK / PERCENT_RANK / CUME_DIST) admitted on 16087 // Oracle / MSSQL with WITHIN-GROUP-only windowDef shape. The 16088 // surrounding {@code isWithinGroupOnlyWindowDef} guard above 16089 // already enforces the shape; the name-set membership here keeps 16090 // PERCENTILE_CONT / PERCENTILE_DISC / user-defined names rejected. 16091 return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains(lower); 16092 } 16093 16094 /** 16095 * Slice 35 / 36 / 46: top-level direct-attachment WITHIN GROUP 16096 * aggregate. 16097 * 16098 * <p>PG stores {@code LISTAGG(... ) WITHIN GROUP (...)} (slice 35) and 16099 * {@code STRING_AGG(... ) WITHIN GROUP (...)} (slice 36) in 16100 * {@code fn.getWithinGroup()} with {@code windowDef=null}. Both bypass 16101 * the slice-33 Oracle/MSSQL helper above, but the plain aggregate path 16102 * is otherwise already correct: the root is not a window function, 16103 * {@link #isAggregateFunction} sees the whitelisted name, and default 16104 * visitor descent does not walk direct {@code fn.withinGroup}, so 16105 * sources contain the function argument but not the WITHIN GROUP ORDER 16106 * BY ref. This helper exists only to unlock the slice-34 expression-text 16107 * fallback for the unaliased top-level form. 16108 * 16109 * <p>Slice 36 widens the PG name whitelist from {@code {listagg}} to 16110 * {@code {listagg, string_agg}}. Snowflake / DB2 / SparkSQL 16111 * {@code LISTAGG} / {@code STRING_AGG} WG remain rejected because 16112 * their argument-storage shape (e.g. DB2's {@code stringExpr}/ 16113 * {@code separatorExpr}) is not yet probed for visitor descent and 16114 * silent empty {@code OutputColumn.sources} would manufacture 16115 * {@code IR_MISSING_DEPENDENCY} divergence. 16116 * 16117 * <p>Slice 46 widens the vendor gate to additionally admit 16118 * Snowflake — but only for {@code mode}, the only Snowflake 16119 * direct-attachment WITHIN GROUP name whose dlineage XML has been 16120 * probe-confirmed byte-equivalent to PG (the projector's slice-30 16121 * vendor-agnostic {@code AGGREGATE_FUNCTION_NAMES} + 16122 * {@code ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} entries already 16123 * cover {@code mode}, so no projector change is needed). Snowflake 16124 * {@code listagg} / {@code string_agg} / hypothetical-set names 16125 * stay out of slice-46 scope. 16126 * 16127 * <p>Slice 47 widens the PG name whitelist from 16128 * {@code {listagg, string_agg}} to {@code {listagg, string_agg, mode}}, 16129 * the symmetrical lift to slice 46's Snowflake widen. The slice-46 16130 * pre-plan probe ({@code /tmp/Probe46Slice30.java}) already 16131 * confirmed PG aliased {@code mode()} WG was zero-divergence and the 16132 * unaliased form was blocked only by {@code effectiveOutputName}; PG 16133 * top-level {@code mode()} dlineage XML is byte-identical to 16134 * Snowflake's. No projector change is needed (slice 30 already 16135 * registered {@code mode}, vendor-agnostic). 16136 */ 16137 private static boolean isAdmittedTopLevelDirectWithinGroupAggregate( 16138 TFunctionCall fn, EDbVendor vendor) { 16139 if (fn == null) return false; 16140 if (fn.getWithinGroup() == null) return false; 16141 if (fn.getWindowDef() != null) return false; 16142 if (fn.getFunctionName() == null) return false; 16143 String name = fn.getFunctionName().toString(); 16144 if (name == null || name.isEmpty()) return false; 16145 String lower = name.toLowerCase(Locale.ROOT); 16146 if (vendor == EDbVendor.dbvpostgresql) { 16147 // Slice 35/36: PG LISTAGG / STRING_AGG WG. 16148 // Slice 47: PG mode() WG (parallels slice-46 Snowflake mode 16149 // lift; probed mode() carries no positional argument so 16150 // OutputColumn.sources is trivially empty matching 16151 // dlineage's zero-edge canonical model). 16152 return "listagg".equals(lower) 16153 || "string_agg".equals(lower) 16154 || ("mode".equals(lower) && hasNoFunctionArgs(fn)); 16155 } 16156 if (vendor == EDbVendor.dbvsnowflake) { 16157 // Slice 46: Snowflake mode() WG only. The admitted shape is 16158 // constrained to the probed no-arg mode() form: OutputColumn. 16159 // sources is trivially empty (matching dlineage's zero-edge 16160 // canonical model), and dlineage XML was probe-confirmed 16161 // byte-identical to PG's mode() WG XML for both aliased and 16162 // unaliased forms. 16163 return "mode".equals(lower) && hasNoFunctionArgs(fn); 16164 } 16165 return false; 16166 } 16167 /** 16168 * Slice 27: detect {@code FILTER (WHERE ...)} on any function call in 16169 * the expression subtree. Probe Q5 (PostgreSQL) confirmed dlineage's 16170 * {@code fdr clause="on"} omits FILTER-predicate column refs while 16171 * {@link #collectColumnRefs} would include them — canonical-model 16172 * divergence. Reject as slice-27 boundary. 16173 */ 16174 private static boolean containsAggregateWithFilter(TExpression e) { 16175 if (e == null) return false; 16176 final boolean[] found = {false}; 16177 e.acceptChildren(new TParseTreeVisitor() { 16178 @Override 16179 public void preVisit(TFunctionCall fn) { 16180 if (found[0]) return; 16181 if (fn.getFilterClause() != null) found[0] = true; 16182 } 16183 }); 16184 if (!found[0] && e.getExpressionType() == EExpressionType.function_t) { 16185 TFunctionCall fn = e.getFunctionCall(); 16186 if (fn != null && fn.getFilterClause() != null) found[0] = true; 16187 } 16188 return found[0]; 16189 } 16190 16191 /** 16192 * Slice 23: true iff every leaf of {@code e} is a {@code simple_constant_t}. 16193 * Admits {@code 1}, {@code 1+1}, {@code (1)}, {@code 'a' || 'b'} (vendor- 16194 * dependent). Slice 61 additionally admits unary {@code +}/{@code -} 16195 * wrappers over a constant operand so common signed literals like 16196 * {@code -1} and {@code -1.5} count as constants. Rejects column refs, 16197 * function calls (including {@code COALESCE}), CASE, scalar subqueries, 16198 * etc. The predicate body may STILL have inner WHERE / GROUP BY / 16199 * HAVING / ORDER BY referencing inner columns — only the projection 16200 * must be constant. 16201 */ 16202 private static boolean isConstantExpression(TExpression e) { 16203 if (e == null) return false; 16204 EExpressionType t = e.getExpressionType(); 16205 if (t == EExpressionType.simple_constant_t) return true; 16206 if (t == EExpressionType.parenthesis_t) { 16207 return e.getLeftOperand() != null && isConstantExpression(e.getLeftOperand()); 16208 } 16209 // Slice 61: unary +/- over a constant operand. The Oracle parser 16210 // emits `-1` as {@code unary_minus_t} with {@code left=null} and 16211 // {@code right=simple_constant_t(1)}. Pre-slice-61 this fell 16212 // through and was rejected; slice 61 lifts it so signed literals 16213 // like `SELECT -1 FROM t` and `SELECT -1 UNION ALL SELECT -2` 16214 // round-trip through the constant-projection path. 16215 if (t == EExpressionType.unary_minus_t || t == EExpressionType.unary_plus_t) { 16216 TExpression operand = e.getRightOperand() != null 16217 ? e.getRightOperand() 16218 : e.getLeftOperand(); 16219 return operand != null && isConstantExpression(operand); 16220 } 16221 // Pure binary ops (slice-22 isPureBinaryForDoParse helper) — both 16222 // operands must be constant. Concatenation, arithmetic, etc. 16223 if (TExpression.isPureBinaryForDoParse(t)) { 16224 TExpression l = e.getLeftOperand(); 16225 TExpression r = e.getRightOperand(); 16226 return l != null && isConstantExpression(l) 16227 && r != null && isConstantExpression(r); 16228 } 16229 return false; 16230 } 16231 16232 /** 16233 * Slice 23: reject subqueries in an EXISTS body's inner WHERE / JOIN ON / 16234 * GROUP BY / HAVING / ORDER BY (would be predicate subqueries OR scalar 16235 * subqueries). Mirrors the slice-11 16236 * {@link #rejectSubqueriesInScalarBodyClauses} structure but uses a 16237 * slice-23-specific error message. 16238 */ 16239 private static void rejectSubqueriesInPredicateBodyClauses(TSelectSqlStatement inner) { 16240 TWhereClause where = inner.getWhereClause(); 16241 if (where != null && containsAnySubquery(where)) { 16242 throw new SemanticIRBuildException( 16243 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_WHERE, 16244 "EXISTS in JOIN ON: inner SELECT has a subquery in its WHERE clause; " 16245 + "not supported yet", null)); 16246 } 16247 if (inner.joins != null) { 16248 for (TJoin join : inner.joins) { 16249 TJoinItemList items = join.getJoinItems(); 16250 if (items == null) continue; 16251 for (int i = 0; i < items.size(); i++) { 16252 TJoinItem item = items.getJoinItem(i); 16253 TExpression onCond = item == null ? null : item.getOnCondition(); 16254 if (onCond != null && containsAnySubqueryExpression(onCond)) { 16255 throw new SemanticIRBuildException( 16256 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_JOIN_ON, 16257 "EXISTS in JOIN ON: inner SELECT has a subquery in a JOIN ON " 16258 + "clause; not supported yet", null)); 16259 } 16260 } 16261 } 16262 } 16263 TGroupBy groupBy = inner.getGroupByClause(); 16264 if (groupBy != null) { 16265 TGroupByItemList items = groupBy.getItems(); 16266 if (items != null && containsAnySubquery(items)) { 16267 throw new SemanticIRBuildException( 16268 Diagnostic.error(DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_GROUP_BY, 16269 "EXISTS in JOIN ON: inner SELECT has a subquery in a GROUP BY clause; " 16270 + "not supported yet", null)); 16271 } 16272 } 16273 // HAVING / ORDER BY subqueries are caught by the slice-9 / 10 16274 // deep-scan rejecters that fire during the recursive build. 16275 } 16276 16277 /** 16278 * Slice 25 (impl-review S2-fix): walk {@code onCond} (root + every 16279 * descendant outside {@code extractedRoots}) and return the first 16280 * wrapper expression whose <b>left</b> operand is a 16281 * {@code subquery_t}. Returns null if none. 16282 */ 16283 private static TExpression findSubqueryOnLeftWrapper(TExpression onCond, 16284 final Set<TExpression> extractedRoots) { 16285 if (onCond == null) return null; 16286 if (isSubqueryOnLeftOfWrapper(onCond)) return onCond; 16287 final TExpression[] found = {null}; 16288 onCond.acceptChildren(new TParseTreeVisitor() { 16289 int skipDepth = 0; 16290 16291 @Override 16292 public void preVisit(TExpression e) { 16293 if (found[0] != null) return; 16294 if (extractedRoots.contains(e)) { 16295 skipDepth++; 16296 return; 16297 } 16298 if (skipDepth > 0) return; 16299 if (isSubqueryOnLeftOfWrapper(e)) { 16300 found[0] = e; 16301 } 16302 } 16303 16304 @Override 16305 public void postVisit(TExpression e) { 16306 if (extractedRoots.contains(e) && skipDepth > 0) { 16307 skipDepth--; 16308 } 16309 } 16310 }); 16311 return found[0]; 16312 } 16313 16314 /** 16315 * Slice 25 / Slice 26: true iff {@code e} is a comparison/IN/ 16316 * quantifier wrapper that the slice-25 / slice-26 surface still 16317 * rejects on subquery-positioning grounds. 16318 * 16319 * <p>Slice 26 narrowing: {@code simple_comparison_t} with subquery 16320 * on the LHS and a non-subquery RHS is now ADMITTED (returns 16321 * false here so the post-extraction rejecter doesn't fire). Other 16322 * shapes still fail: 16323 * <ul> 16324 * <li>{@code in_t} with LHS=subquery: dlineage's 16325 * {@code fdr clause="on"} sources omit the outer column for 16326 * IN-LHS, so admitting on the IR side would manufacture 16327 * canonical-model divergence (still rejected).</li> 16328 * <li>{@code group_comparison_t} with LHS=subquery: borderline 16329 * grammar; defensively rejected (slice-26 boundary).</li> 16330 * <li>{@code simple_comparison_t} with subqueries on BOTH 16331 * sides: would require dual extraction; deferred to a future 16332 * slice (slice-26 boundary). Caller emits a tuned message.</li> 16333 * </ul> 16334 */ 16335 private static boolean isSubqueryOnLeftOfWrapper(TExpression e) { 16336 if (e == null) return false; 16337 EExpressionType t = e.getExpressionType(); 16338 TExpression l = e.getLeftOperand(); 16339 TExpression r = e.getRightOperand(); 16340 boolean lhsIsSubq = l != null && l.getExpressionType() == EExpressionType.subquery_t; 16341 boolean rhsIsSubq = r != null && r.getExpressionType() == EExpressionType.subquery_t; 16342 if (t == EExpressionType.in_t) { 16343 return lhsIsSubq; 16344 } 16345 if (t == EExpressionType.group_comparison_t) { 16346 return lhsIsSubq; 16347 } 16348 if (t == EExpressionType.simple_comparison_t) { 16349 // Slice 26: lifted UNLESS both sides are subqueries. 16350 return lhsIsSubq && rhsIsSubq; 16351 } 16352 return false; 16353 } 16354 16355 /** 16356 * Slice 26: true iff {@code e} is a {@code simple_comparison_t} 16357 * whose BOTH operands are {@code subquery_t}. Used by the 16358 * post-extraction rejecter to emit a slice-26-specific tuned 16359 * message distinguishing this case from the slice-25 LHS-subquery 16360 * shapes. 16361 */ 16362 private static boolean isComparisonWithBothSubqueries(TExpression e) { 16363 if (e == null) return false; 16364 if (e.getExpressionType() != EExpressionType.simple_comparison_t) return false; 16365 TExpression l = e.getLeftOperand(); 16366 TExpression r = e.getRightOperand(); 16367 return l != null && l.getExpressionType() == EExpressionType.subquery_t 16368 && r != null && r.getExpressionType() == EExpressionType.subquery_t; 16369 } 16370 16371 /** 16372 * Slice 23: after extraction, any remaining subquery-bearing expression in 16373 * the JOIN-ON tree is an unsupported shape — EXISTS that failed 16374 * extraction (inner-shape rejection), correlated wrappers, subquery on 16375 * left side, etc. Catch them here with a tuned message before 16376 * {@link #collectColumnRefsSkipping} would otherwise descend into them 16377 * and bind their inner refs against the outer scope. 16378 * 16379 * <p>Slice 25 (impl-review S2-fix): subquery-on-LEFT cases get a 16380 * tuned message (uses the slice-25 outer-shape prefix 16381 * "predicate subquery in JOIN ON:") via 16382 * {@link #findSubqueryOnLeftWrapper}. 16383 * 16384 * <p>Slice 26: a NEW first pass (before the slice-25 LHS-subquery 16385 * pass) detects {@code simple_comparison_t} wrappers with 16386 * subqueries on BOTH sides via 16387 * {@link #findComparisonWithBothSubqueries} and emits a slice-26 16388 * tuned message. Both-sides shape satisfies 16389 * {@link #isSubqueryOnLeftOfWrapper} (which slice 26 narrowed to 16390 * {@code lhsIsSubq && rhsIsSubq} for {@code simple_comparison_t}), 16391 * so ordering matters — without the both-sides first pass, the 16392 * slice-25 LHS-subquery wording would fire first. 16393 */ 16394 private static void rejectAnyRemainingSubqueriesInJoinOn(TExpression onCond, 16395 final Set<TExpression> extractedRoots) { 16396 rejectAnyRemainingSubqueriesFromClause(onCond, extractedRoots, 16397 PredicateClauseContext.JOIN_ON); 16398 } 16399 16400 /** 16401 * Slice 110 — clause-agnostic remaining-subquery rejecter. Mirrors 16402 * the slice-26 logic in {@link #rejectAnyRemainingSubqueriesInJoinOn} 16403 * but uses {@code ctx.*} codes / labels so the same body powers JOIN-ON 16404 * (slice 26) and UPDATE WHERE (slice 110). 16405 */ 16406 private static void rejectAnyRemainingSubqueriesFromClause(TExpression onCond, 16407 final Set<TExpression> extractedRoots, 16408 final PredicateClauseContext ctx) { 16409 if (onCond == null) return; 16410 // Slice 26: tuned message for a comparison with subqueries on 16411 // BOTH sides. Fires at root or any descent. Checked BEFORE the 16412 // slice-25 subquery-on-LEFT pass because both-subqueries 16413 // satisfies isSubqueryOnLeftOfWrapper too — without this 16414 // ordering the slice-25 wording would fire first. 16415 TExpression bothSubqueriesWrapper = findComparisonWithBothSubqueries(onCond, 16416 extractedRoots); 16417 if (bothSubqueriesWrapper != null) { 16418 throw new SemanticIRBuildException( 16419 Diagnostic.error(ctx.scalarComparisonBothSides, 16420 "predicate subquery in " + ctx.clauseLabel + ": scalar comparison with " 16421 + "subqueries on both sides is not supported yet " 16422 + "(slice 26 admits exactly one subquery side, with a " 16423 + "single column reference on the other side; rewrite " 16424 + "as a join across a derived table or a CTE)", null)); 16425 } 16426 // Slice 25 (impl-review S2-fix): tuned message for subquery 16427 // on the LEFT side of a wrapper. Fires at root or any descent. 16428 // Slice 26 narrowed isSubqueryOnLeftOfWrapper: 16429 // simple_comparison_t with LHS=subquery and non-subquery RHS is 16430 // now ADMITTED, so this rejecter only fires for in_t-LHS-subq / 16431 // group_comparison_t-LHS-subq (still rejected as asymmetric / 16432 // borderline shapes). 16433 TExpression leftSubqueryWrapper = findSubqueryOnLeftWrapper(onCond, extractedRoots); 16434 if (leftSubqueryWrapper != null) { 16435 throw new SemanticIRBuildException( 16436 Diagnostic.error(ctx.predicateSubqueryOnLeft, 16437 "predicate subquery in " + ctx.clauseLabel + ": " 16438 + leftSubqueryWrapper.getExpressionType() 16439 + " wrapper has a subquery on the LEFT side " 16440 + "(only RHS-subquery IN / ANY-ALL-SOME and " 16441 + "either-side scalar comparison are admitted; " 16442 + "rewrite to put the subquery on the right side, " 16443 + "or rewrite as a join across a derived table)", null)); 16444 } 16445 // Root check: if the entire condition IS an EXISTS root and it WASN'T 16446 // extracted (meaning extraction threw an exception, which should not 16447 // reach here, OR some other root subquery shape), reject. The root 16448 // walker would otherwise miss it. 16449 TExpression rootSubject = isExistsRoot(onCond) ? unwrapExistsRoot(onCond) : onCond; 16450 if (rootSubject != null 16451 && (rootSubject.getExpressionType() == EExpressionType.subquery_t 16452 || rootSubject.getSubQuery() != null 16453 || rootSubject.getExpressionType() == EExpressionType.exists_t) 16454 && !extractedRoots.contains(rootSubject)) { 16455 throw new SemanticIRBuildException( 16456 Diagnostic.error(ctx.genericSubqueryNotSupported, 16457 "subquery in " + ctx.clauseLabel + " predicate is not supported yet " 16458 + "(slice 26 accepts only uncorrelated EXISTS / " 16459 + "IN-SELECT / scalar-comparison / ANY-ALL-SOME with " 16460 + "single column-ref or constant-only inner projection " 16461 + "and a single column ref on the non-subquery side)", null)); 16462 } 16463 final boolean[] found = {false}; 16464 onCond.acceptChildren(new TParseTreeVisitor() { 16465 int skipDepth = 0; 16466 16467 @Override 16468 public void preVisit(TExpression e) { 16469 if (found[0]) return; 16470 if (extractedRoots.contains(e)) { 16471 skipDepth++; 16472 return; 16473 } 16474 if (skipDepth > 0) return; 16475 if (e.getExpressionType() == EExpressionType.subquery_t 16476 || e.getSubQuery() != null 16477 || e.getExpressionType() == EExpressionType.exists_t) { 16478 found[0] = true; 16479 } 16480 } 16481 16482 @Override 16483 public void postVisit(TExpression e) { 16484 if (extractedRoots.contains(e) && skipDepth > 0) { 16485 skipDepth--; 16486 } 16487 } 16488 }); 16489 if (found[0]) { 16490 throw new SemanticIRBuildException( 16491 Diagnostic.error(ctx.genericSubqueryNotSupported, 16492 "subquery in " + ctx.clauseLabel + " predicate is not supported yet " 16493 + "(slice 26 accepts only uncorrelated EXISTS / " 16494 + "IN-SELECT / scalar-comparison / ANY-ALL-SOME with " 16495 + "single column-ref or constant-only inner projection " 16496 + "and a single column ref on the non-subquery side)", null)); 16497 } 16498 } 16499 16500 /** 16501 * Slice 26: walk {@code onCond} (root + every descendant outside 16502 * {@code extractedRoots}) and return the first 16503 * {@code simple_comparison_t} expression whose BOTH operands are 16504 * {@code subquery_t}. Returns null if none. 16505 */ 16506 private static TExpression findComparisonWithBothSubqueries(TExpression onCond, 16507 final Set<TExpression> extractedRoots) { 16508 if (onCond == null) return null; 16509 if (isComparisonWithBothSubqueries(onCond)) return onCond; 16510 final TExpression[] found = {null}; 16511 onCond.acceptChildren(new TParseTreeVisitor() { 16512 int skipDepth = 0; 16513 16514 @Override 16515 public void preVisit(TExpression e) { 16516 if (found[0] != null) return; 16517 if (extractedRoots.contains(e)) { 16518 skipDepth++; 16519 return; 16520 } 16521 if (skipDepth > 0) return; 16522 if (isComparisonWithBothSubqueries(e)) { 16523 found[0] = e; 16524 } 16525 } 16526 16527 @Override 16528 public void postVisit(TExpression e) { 16529 if (extractedRoots.contains(e) && skipDepth > 0) { 16530 skipDepth--; 16531 } 16532 } 16533 }); 16534 return found[0]; 16535 } 16536 16537 /** 16538 * Slice 23: variant of {@link #rejectWindowFunctionInScope} that skips 16539 * subtrees in {@code skipRoots}. The outer-SELECT JOIN-ON path passes the 16540 * extracted EXISTS roots so a window function inside an extracted body 16541 * is NOT incorrectly rejected as a window in the outer JOIN-ON. (The 16542 * inner statement's own buildSelectStatement does its own 16543 * rejectWindowFunctionInScope sweeps on WHERE / GROUP BY / HAVING / 16544 * ORDER BY, so legitimate inner-window violations still surface.) 16545 */ 16546 private static void rejectWindowFunctionInScopeSkipping( 16547 gudusoft.gsqlparser.nodes.TParseTreeNode root, 16548 String clauseLabel, 16549 final Set<TExpression> skipRoots) { 16550 if (root == null) return; 16551 // Root fast path: if the root itself IS a skipped subtree, nothing to 16552 // check. (acceptChildren wouldn't see the root anyway.) 16553 if (root instanceof TExpression && skipRoots.contains(root)) { 16554 return; 16555 } 16556 final boolean[] found = {false}; 16557 root.acceptChildren(new TParseTreeVisitor() { 16558 int skipDepth = 0; 16559 16560 @Override 16561 public void preVisit(TExpression e) { 16562 if (skipRoots.contains(e)) { 16563 skipDepth++; 16564 } 16565 } 16566 16567 @Override 16568 public void postVisit(TExpression e) { 16569 if (skipRoots.contains(e) && skipDepth > 0) { 16570 skipDepth--; 16571 } 16572 } 16573 16574 @Override 16575 public void preVisit(TFunctionCall fn) { 16576 if (found[0] || skipDepth > 0) return; 16577 if (fn.getWindowDef() != null) found[0] = true; 16578 } 16579 }); 16580 if (found[0]) { 16581 throw new SemanticIRBuildException( 16582 Diagnostic.error(DiagnosticCode.CLAUSE_WINDOW_FUNCTION_LEAK, 16583 clauseLabel + " contains a window function (OVER (...)); " 16584 + "window functions are not allowed in " + clauseLabel 16585 + " per standard SQL", root)); 16586 } 16587 } 16588 16589 /** 16590 * Slice 23: variant of {@link #collectColumnRefs} that skips subtrees in 16591 * {@code skipRoots}. Used by the outer-SELECT JOIN-ON path so the 16592 * extracted EXISTS bodies' inner refs do not leak into outer 16593 * {@code joinColumnRefs}. 16594 */ 16595 private static List<ColumnRef> collectColumnRefsSkipping( 16596 gudusoft.gsqlparser.nodes.TParseTreeNode root, 16597 final NameBindingProvider provider, 16598 final Set<TExpression> skipRoots) { 16599 // Slice 31 refactor: delegate to the extended variant with no 16600 // TWithinGroup skips. Behavior preserved exactly for the 16601 // existing slice-28 caller (outer JOIN-ON path at line ~3021) 16602 // and for the new slice-31 caller via 16603 // {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}. 16604 return collectColumnRefsSkippingExtended(root, provider, 16605 skipRoots, Collections.<TWithinGroup>emptySet()); 16606 } 16607 16608 /** 16609 * Slice 28: collect every non-null {@link TFunctionCall#getFilterClause()} 16610 * subtree reachable from {@code root}. The returned set is identity-keyed 16611 * (uses {@link IdentityHashMap}) — required because the parser may yield 16612 * two structurally-equal FILTER WHERE expressions with different 16613 * identities; a value-keyed set would coalesce them and the 16614 * downstream {@link #collectColumnRefsSkipping} call would skip only one. 16615 * 16616 * <p>Contract: {@code root} is one of {@link TResultColumn} or 16617 * {@link TExpression}. Both call sites 16618 * ({@link #collectColumnRefsExcludingFilterClauses} for projection 16619 * source collection; the slice-28 correlation walk inside 16620 * {@link #extractOnePredicateSubqueryBody}) pass values of those 16621 * two types. Other {@code TParseTreeNode} subclasses are accepted 16622 * defensively (the visitor scan still works) but the top-level 16623 * direct-check fast paths only cover {@code TResultColumn} and 16624 * {@code TExpression}; this is intentional — adding a 16625 * {@code TFunctionCall} fast path would be reachable only if a 16626 * future call site were added with a {@code TFunctionCall} root. 16627 * 16628 * <p>Visitor-driven; descends into all expression subtrees the 16629 * standard {@code TFunctionCall.acceptChildren} path visits — function 16630 * args, {@code OVER} (analyticFunction / windowDef), FILTER, CASE arms, 16631 * parenthesised sub-expressions, AND the 16632 * {@code windowDef.withinGroup.orderBy} path on Oracle / MSSQL / 16633 * SparkSQL parsers. Note: the PostgreSQL parser stores WITHIN GROUP 16634 * on the direct {@code fn.withinGroup} field, which 16635 * {@code TFunctionCall.acceptChildren} does NOT visit, so PG WITHIN 16636 * GROUP ORDER BY refs are invisible to this collector. Slice 29 16637 * relies on that asymmetry to admit PG WITHIN GROUP aggregates in 16638 * predicate-subquery inner projections without a source-skip. 16639 * Used by: 16640 * <ul> 16641 * <li>{@link #collectColumnRefsExcludingFilterClauses} (Pass 1) — the 16642 * global source-skip in {@link #buildOutputColumns}.</li> 16643 * <li>{@link #extractOnePredicateSubqueryBody}'s slice-28 correlation 16644 * walk — projection-only correlation check for FILTER predicate 16645 * refs.</li> 16646 * </ul> 16647 */ 16648 private static Set<TExpression> collectFilterClauses( 16649 gudusoft.gsqlparser.nodes.TParseTreeNode root) { 16650 final Set<TExpression> out = 16651 Collections.newSetFromMap(new IdentityHashMap<TExpression, Boolean>()); 16652 if (root == null) return out; 16653 // Visitor descends into all expression subtrees; preVisit on 16654 // TFunctionCall records the filter clause if present. The 16655 // visitor's preVisit(TFunctionCall) does NOT fire for a top-level 16656 // function_t expression's root TFunctionCall (matches the slice-13 / 16657 // slice-27 visitor descent behavior); the defensive direct checks 16658 // below cover the top-level case for the two supported root types. 16659 root.acceptChildren(new TParseTreeVisitor() { 16660 @Override 16661 public void preVisit(TFunctionCall fn) { 16662 TExpression f = fn.getFilterClause(); 16663 if (f != null) out.add(f); 16664 } 16665 }); 16666 if (root instanceof TExpression) { 16667 TExpression e = (TExpression) root; 16668 if (e.getExpressionType() == EExpressionType.function_t) { 16669 TFunctionCall fn = e.getFunctionCall(); 16670 if (fn != null && fn.getFilterClause() != null) { 16671 out.add(fn.getFilterClause()); 16672 } 16673 } 16674 } else if (root instanceof TResultColumn) { 16675 TResultColumn rc = (TResultColumn) root; 16676 TExpression e = rc.getExpr(); 16677 if (e != null && e.getExpressionType() == EExpressionType.function_t) { 16678 TFunctionCall fn = e.getFunctionCall(); 16679 if (fn != null && fn.getFilterClause() != null) { 16680 out.add(fn.getFilterClause()); 16681 } 16682 } 16683 } 16684 return out; 16685 } 16686 16687 /** 16688 * Slice 30: collect every qualifier alias (the {@code x} of an 16689 * {@code x.region} TObjectName column reference) reachable from 16690 * {@code root}, without going through the resolver. Used by the 16691 * slice-30 WITHIN GROUP ORDER BY correlation walk in 16692 * {@link #extractOnePredicateSubqueryBody}. 16693 * 16694 * <p>Why bypass the resolver: PostgreSQL's parser stores WITHIN GROUP 16695 * on the direct {@code fn.withinGroup} field. {@code TFunctionCall.acceptChildren} 16696 * does NOT descend into that field, AND Resolver2 follows the same 16697 * traversal, so its {@code ResolutionResult} is null on TObjectName 16698 * nodes inside {@code fn.withinGroup.orderBy}. Calling 16699 * {@link #collectColumnRefs} on the WG ORDER BY would route through 16700 * {@code provider.bindColumn} → {@code NOT_FOUND}, and the 16701 * {@code non-exact column bindings} check would throw on legitimate 16702 * non-correlated refs. The qualifier-only collector here reads the 16703 * alias straight off the TObjectName via {@link TObjectName#getTableString()}, 16704 * matching the slice-23 correlation invariant: qualified refs only. 16705 * Unqualified refs are out of scope (same schema-less limitation as 16706 * the rest of slice-23). 16707 * 16708 * <p>Returned in iteration order (so error messages identify the 16709 * first offender) using a list rather than a set. 16710 */ 16711 private static List<String> collectQualifierAliases( 16712 gudusoft.gsqlparser.nodes.TParseTreeNode root) { 16713 final List<String> out = new ArrayList<>(); 16714 if (root == null) return out; 16715 root.acceptChildren(new TParseTreeVisitor() { 16716 @Override 16717 public void preVisit(TObjectName node) { 16718 if (node.getDbObjectType() != EDbObjectType.column) return; 16719 String t = node.getTableString(); 16720 if (t != null && !t.isEmpty()) out.add(t); 16721 } 16722 }); 16723 return out; 16724 } 16725 16726 /** 16727 * Slice 30 / Slice 31: collect every WITHIN-GROUP {@code ORDER BY} 16728 * clause anywhere in the subtree, identity-keyed so two 16729 * structurally-equal order-by clauses don't collapse. 16730 * 16731 * <p>Two attachment styles are covered: 16732 * <ul> 16733 * <li><b>Slice 30 — direct attachment</b>: PostgreSQL / 16734 * Snowflake / DB2 / SparkSQL parsers store WITHIN GROUP on 16735 * {@code fn.getWithinGroup()}. The default 16736 * {@code TFunctionCall.acceptChildren} does NOT descend into 16737 * that field, so the slice-23 {@link #collectAllInnerRefs} 16738 * walk is blind to outer-alias references inside the ORDER BY. 16739 * The slice-30 correlation walk needs explicit access, hence 16740 * this helper.</li> 16741 * <li><b>Slice 31 — windowDef attachment</b>: Oracle / MSSQL 16742 * parsers store WITHIN GROUP on 16743 * {@code fn.getWindowDef().getWithinGroup()} when the 16744 * windowDef is {@link #isWithinGroupOnlyWindowDef 16745 * WITHIN-GROUP-only}. The default {@code acceptChildren} 16746 * DOES descend through {@code windowDef.acceptChildren} 16747 * which calls {@code withinGroup.acceptChildren} which calls 16748 * {@code orderBy.acceptChildren}, so column refs would 16749 * already appear in {@link #collectAllInnerRefs}-driven 16750 * walks. <b>However</b>, the slice-31 source-skip in 16751 * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} 16752 * removes those refs from {@link OutputColumn#getSources()}; 16753 * the slice-23 correlation walk only sees {@code OutputColumn.sources} 16754 * for the projection bucket, so a correlated 16755 * {@code LISTAGG(x.id) WITHIN GROUP (ORDER BY e.region)} on 16756 * Oracle would slip past the slice-23 loop after the source- 16757 * skip. This dual-attachment helper closes that asymmetry. 16758 * (Inner WHERE / JOIN / HAVING / ORDER BY clauses are 16759 * independently rejected by the slice-13 strict 16760 * {@code rejectWindowFunctionInScope} family — Oracle 16761 * LISTAGG WG inside a clause never reaches this helper.)</li> 16762 * </ul> 16763 * 16764 * <p>The visitor's {@code preVisit(TFunctionCall)} does NOT fire for 16765 * a top-level {@code function_t} expression's root TFunctionCall; 16766 * defensive direct checks below cover the top-level case for both 16767 * {@link TExpression} and {@link TResultColumn} roots, mirroring 16768 * {@link #collectFilterClauses}. 16769 * 16770 * <p>Used by {@link #extractOnePredicateSubqueryBody}'s 16771 * projection-only correlation walk (line ~3690) for both 16772 * direct-attachment (slice 30) and windowDef-attachment (slice 31) 16773 * outer-alias references inside WITHIN GROUP ORDER BY. 16774 */ 16775 private static Set<TOrderBy> collectDirectWithinGroupOrderBys( 16776 gudusoft.gsqlparser.nodes.TParseTreeNode root) { 16777 final Set<TOrderBy> out = 16778 Collections.newSetFromMap(new IdentityHashMap<TOrderBy, Boolean>()); 16779 if (root == null) return out; 16780 root.acceptChildren(new TParseTreeVisitor() { 16781 @Override 16782 public void preVisit(TFunctionCall fn) { 16783 TOrderBy direct = fn.getWithinGroup() == null 16784 ? null : fn.getWithinGroup().getOrderBy(); 16785 if (direct != null) out.add(direct); 16786 TWindowDef wd = fn.getWindowDef(); 16787 if (isWithinGroupOnlyWindowDef(wd)) { 16788 TOrderBy wdOb = wd.getWithinGroup().getOrderBy(); 16789 if (wdOb != null) out.add(wdOb); 16790 } 16791 } 16792 }); 16793 if (root instanceof TExpression) { 16794 TExpression e = (TExpression) root; 16795 if (e.getExpressionType() == EExpressionType.function_t) { 16796 addWithinGroupOrderByIfPresent(e.getFunctionCall(), out); 16797 } 16798 } else if (root instanceof TResultColumn) { 16799 TResultColumn rc = (TResultColumn) root; 16800 TExpression e = rc.getExpr(); 16801 if (e != null && e.getExpressionType() == EExpressionType.function_t) { 16802 addWithinGroupOrderByIfPresent(e.getFunctionCall(), out); 16803 } 16804 } 16805 return out; 16806 } 16807 16808 /** 16809 * Slice 30 / 31: helper for top-level direct check inside 16810 * {@link #collectDirectWithinGroupOrderBys}. Adds the WITHIN GROUP 16811 * ORDER BY clause to {@code out} for whichever attachment style 16812 * the function carries. 16813 */ 16814 private static void addWithinGroupOrderByIfPresent(TFunctionCall fn, 16815 Set<TOrderBy> out) { 16816 if (fn == null) return; 16817 if (fn.getWithinGroup() != null && fn.getWithinGroup().getOrderBy() != null) { 16818 out.add(fn.getWithinGroup().getOrderBy()); 16819 } 16820 TWindowDef wd = fn.getWindowDef(); 16821 if (isWithinGroupOnlyWindowDef(wd) && wd.getWithinGroup().getOrderBy() != null) { 16822 out.add(wd.getWithinGroup().getOrderBy()); 16823 } 16824 } 16825 16826 /** 16827 * Slice 28: variant of {@link #collectColumnRefs} that excludes column 16828 * refs inside {@code FILTER (WHERE ...)} clauses on any function call 16829 * in the subtree. Used by {@link #buildOutputColumns} for ALL output 16830 * source collection so the IR's per-projection {@code OutputColumn.sources} 16831 * matches dlineage's lineage-relationship view (which omits FILTER 16832 * predicate column refs entirely; see slice-28 probes Q1–Q4). 16833 * 16834 * <p>For projections that contain no FILTER aggregates (the common case), 16835 * Pass 1 yields zero skip-roots and Pass 2 reduces to the plain 16836 * {@link #collectColumnRefs}. The asymmetry between projection sources 16837 * (FILTER-skipped) and clause refs ({@code filterColumnRefs}, 16838 * {@code joinColumnRefs}, {@code groupByColumnRefs}, 16839 * {@code havingColumnRefs}, {@code orderByColumnRefs} — NOT 16840 * FILTER-skipped) is intentional: it keeps the existing 16841 * {@link #collectAllInnerRefs}-driven correlation check at line ~3603 16842 * sufficient for FILTER refs landing in non-projection clauses, while 16843 * the slice-28 correlation walk in {@link #extractOnePredicateSubqueryBody} 16844 * covers projection-FILTER refs. 16845 */ 16846 private static List<ColumnRef> collectColumnRefsExcludingFilterClauses( 16847 gudusoft.gsqlparser.nodes.TParseTreeNode root, 16848 NameBindingProvider provider) { 16849 Set<TExpression> filterClauses = collectFilterClauses(root); 16850 if (filterClauses.isEmpty()) { 16851 return collectColumnRefs(root, provider); 16852 } 16853 return collectColumnRefsSkipping(root, provider, filterClauses); 16854 } 16855 16856 /** 16857 * Slice 31: identity-keyed set of every {@link TWithinGroup} reachable 16858 * from {@code root} via {@code fn.getWindowDef().getWithinGroup()} — 16859 * the Oracle / MSSQL attachment style for plain {@code WITHIN GROUP 16860 * (ORDER BY ...)} aggregates. Used as additional skip-roots in 16861 * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} so 16862 * the column refs inside the WITHIN GROUP ORDER BY do NOT enter 16863 * {@link OutputColumn#getSources()} on Oracle / MSSQL — matching 16864 * dlineage's omission of those refs from {@code fdr clause="on"} 16865 * sources (probe Q1 / Q3 / Q4 / Q5 in {@code /tmp/probe31}). 16866 * 16867 * <p>Discriminator: {@link #isWithinGroupOnlyWindowDef}. OVER-bearing 16868 * windowDefs (real window functions) are NOT collected here — the 16869 * slice-13 invariant rejecters keep them rejected before this 16870 * collector ever fires for projection sources, so they cannot 16871 * reach the source-skip in practice. Defensive: even if they did, 16872 * the discriminator excludes them so PARTITION BY / OVER ORDER BY 16873 * column refs (slice-13 / slice-19 alias-bound contracts) keep 16874 * their existing semantics. 16875 * 16876 * <p>The PostgreSQL direct {@code fn.getWithinGroup()} attachment 16877 * is NOT collected here because PG's 16878 * {@code TFunctionCall.acceptChildren} does not descend into the 16879 * direct field — slice 29 relied on that asymmetry to admit PG 16880 * WITHIN GROUP aggregates without any source-skip; slice 31 16881 * preserves that asymmetry on PG. 16882 */ 16883 private static Set<TWithinGroup> collectWithinGroupClausesFromWindowDef( 16884 gudusoft.gsqlparser.nodes.TParseTreeNode root) { 16885 final Set<TWithinGroup> out = 16886 Collections.newSetFromMap(new IdentityHashMap<TWithinGroup, Boolean>()); 16887 if (root == null) return out; 16888 root.acceptChildren(new TParseTreeVisitor() { 16889 @Override 16890 public void preVisit(TFunctionCall fn) { 16891 TWindowDef wd = fn.getWindowDef(); 16892 if (isWithinGroupOnlyWindowDef(wd)) { 16893 out.add(wd.getWithinGroup()); 16894 } 16895 } 16896 }); 16897 if (root instanceof TExpression) { 16898 TExpression e = (TExpression) root; 16899 if (e.getExpressionType() == EExpressionType.function_t) { 16900 addWithinGroupFromWindowDefIfPresent(e.getFunctionCall(), out); 16901 } 16902 } else if (root instanceof TResultColumn) { 16903 TResultColumn rc = (TResultColumn) root; 16904 TExpression e = rc.getExpr(); 16905 if (e != null && e.getExpressionType() == EExpressionType.function_t) { 16906 addWithinGroupFromWindowDefIfPresent(e.getFunctionCall(), out); 16907 } 16908 } 16909 return out; 16910 } 16911 16912 /** 16913 * Slice 31: helper for top-level direct check inside 16914 * {@link #collectWithinGroupClausesFromWindowDef}. Mirrors the 16915 * slice-30 {@link #addWithinGroupOrderByIfPresent} helper but adds 16916 * the {@link TWithinGroup} node itself to {@code out} (the entire 16917 * WITHIN GROUP subtree is the skip-root, not just its ORDER BY). 16918 */ 16919 private static void addWithinGroupFromWindowDefIfPresent(TFunctionCall fn, 16920 Set<TWithinGroup> out) { 16921 if (fn == null) return; 16922 TWindowDef wd = fn.getWindowDef(); 16923 if (isWithinGroupOnlyWindowDef(wd)) { 16924 out.add(wd.getWithinGroup()); 16925 } 16926 } 16927 16928 /** 16929 * Slice 31: extends slice-28's filter-skipping projection-source 16930 * collector with an additional skip for Oracle / MSSQL 16931 * {@code fn.windowDef.withinGroup} subtrees. Reduces to slice-28 16932 * behavior on PostgreSQL (where windowDef is null) and to plain 16933 * {@link #collectColumnRefs} when neither FILTER nor WITHIN GROUP 16934 * is present. 16935 * 16936 * <p>Used by {@link #buildOutputColumns} for ALL projection source 16937 * collection (predicate-body short-circuit at line ~4952 and 16938 * normal projection loop at line ~5069) so the IR's 16939 * {@link OutputColumn#getSources()} matches dlineage's 16940 * lineage-relationship view across PG / Oracle / MSSQL. The 16941 * non-projection clause-bucket collectors 16942 * ({@code filterColumnRefs}, {@code joinColumnRefs}, 16943 * {@code groupByColumnRefs}, {@code havingColumnRefs}, 16944 * {@code orderByColumnRefs}) intentionally keep using plain 16945 * {@link #collectColumnRefs} — the slice-13 strict 16946 * {@code rejectWindowFunctionInScope} family rejects any 16947 * {@code wd != null} function in those clauses BEFORE collection 16948 * descends into them, so WITHIN GROUP refs cannot leak into 16949 * clause buckets in practice. 16950 */ 16951 private static List<ColumnRef> collectColumnRefsExcludingFilterAndWithinGroupClauses( 16952 gudusoft.gsqlparser.nodes.TParseTreeNode root, 16953 NameBindingProvider provider) { 16954 Set<TExpression> filterClauses = collectFilterClauses(root); 16955 Set<TWithinGroup> withinGroupClauses = collectWithinGroupClausesFromWindowDef(root); 16956 if (filterClauses.isEmpty() && withinGroupClauses.isEmpty()) { 16957 return collectColumnRefs(root, provider); 16958 } 16959 return collectColumnRefsSkippingExtended(root, provider, 16960 filterClauses, withinGroupClauses); 16961 } 16962 16963 /** 16964 * Slice 31: variant of {@link #collectColumnRefsSkipping} that 16965 * additionally skips column refs inside {@link TWithinGroup} 16966 * subtrees in {@code wgSkipRoots} (Oracle / MSSQL 16967 * {@code fn.windowDef.withinGroup} attachment). The existing 16968 * {@code exprSkipRoots} carries the slice-28 FILTER subtrees. 16969 * Returns column refs in iteration order. 16970 * 16971 * <p>Refactor note: {@link #collectColumnRefsSkipping} now delegates 16972 * to this method with an empty {@code wgSkipRoots} set so its 16973 * behavior is preserved exactly for legacy callers (the outer 16974 * JOIN-ON path at line ~3021). 16975 */ 16976 private static List<ColumnRef> collectColumnRefsSkippingExtended( 16977 gudusoft.gsqlparser.nodes.TParseTreeNode root, 16978 final NameBindingProvider provider, 16979 final Set<TExpression> exprSkipRoots, 16980 final Set<TWithinGroup> wgSkipRoots) { 16981 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 16982 final List<String> rejects = new ArrayList<>(); 16983 // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at 16984 // the wrong side). Such a miss is a genuine error and must stay fatal; 16985 // only all-unqualified rejects are eligible for the join-graph degrade. 16986 final boolean[] sawQualifiedReject = {false}; 16987 // Root fast path: if root IS a skipped TExpression subtree, return empty. 16988 if (root instanceof TExpression && exprSkipRoots.contains(root)) { 16989 return new ArrayList<>(refs); 16990 } 16991 root.acceptChildren(new TParseTreeVisitor() { 16992 int skipDepth = 0; 16993 int nestedSelectDepth = 0; 16994 16995 @Override 16996 public void preVisit(TExpression e) { 16997 if (exprSkipRoots.contains(e)) skipDepth++; 16998 } 16999 17000 @Override 17001 public void postVisit(TExpression e) { 17002 if (exprSkipRoots.contains(e) && skipDepth > 0) skipDepth--; 17003 } 17004 17005 @Override 17006 public void preVisit(TWithinGroup wg) { 17007 if (wgSkipRoots.contains(wg)) skipDepth++; 17008 } 17009 17010 @Override 17011 public void postVisit(TWithinGroup wg) { 17012 if (wgSkipRoots.contains(wg) && skipDepth > 0) skipDepth--; 17013 } 17014 17015 @Override 17016 public void preVisit(TSelectSqlStatement nested) { 17017 nestedSelectDepth++; 17018 } 17019 17020 @Override 17021 public void postVisit(TSelectSqlStatement nested) { 17022 nestedSelectDepth--; 17023 } 17024 17025 @Override 17026 public void preVisit(TObjectName node) { 17027 if (skipDepth > 0) return; 17028 if (nestedSelectDepth > 0) return; 17029 appendMergedOrBoundColumnRef(node, provider, refs, rejects, 17030 sawQualifiedReject); 17031 } 17032 }); 17033 if (!rejects.isEmpty()) { 17034 rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]); 17035 } 17036 return new ArrayList<>(refs); 17037 } 17038 17039 /** 17040 * Reject join shapes that would silently drop predicate semantics: 17041 * semi/anti, vendor-specific kinds; predicate-bearing joins with 17042 * no ON and no USING clause; CROSS / NATURAL JOIN with ON or 17043 * USING. Slice 63 admits {@code CROSS JOIN} via 17044 * {@link #ALLOWED_ON_LESS_JOIN_TYPES}. Slice 64 admits 17045 * {@code JOIN ... USING (...)} on predicate join types; the 17046 * per-key {@code joinColumnRefs} emission is handled in 17047 * {@link #buildRelations}. Slice 66 admits {@code NATURAL JOIN} 17048 * via {@link #NATURAL_JOIN_TYPES} when catalog metadata is 17049 * available on both sides; the catalog-required reject fires 17050 * inside {@link #buildRelations}, not here. 17051 */ 17052 private static void rejectUnsupportedJoinShape(TJoinItem item) { 17053 EJoinType jt = item.getJoinType(); 17054 boolean isPredicate = jt != null && ALLOWED_PREDICATE_JOIN_TYPES.contains(jt); 17055 boolean isOnLess = jt != null && ALLOWED_ON_LESS_JOIN_TYPES.contains(jt); 17056 boolean isNatural = isNaturalJoinType(jt); 17057 boolean isLateral = isLateralJoinType(jt); 17058 if (!isPredicate && !isOnLess && !isNatural && !isLateral) { 17059 throw new SemanticIRBuildException( 17060 Diagnostic.error(DiagnosticCode.UNSUPPORTED_JOIN_TYPE, 17061 "join type " + jt + " is not supported yet; " 17062 + "only INNER/LEFT/RIGHT/FULL [OUTER] JOIN ... ON, " 17063 + "JOIN ... USING (...), CROSS JOIN, CROSS/OUTER APPLY, and " 17064 + "NATURAL [INNER/LEFT/RIGHT/FULL [OUTER]] JOIN are accepted", item)); 17065 } 17066 boolean hasUsing = item.getUsingColumns() != null 17067 && item.getUsingColumns().size() > 0; 17068 boolean hasOn = item.getOnCondition() != null; 17069 if (isLateral) { 17070 // CROSS/OUTER APPLY is an ON-less lateral join; the correlation 17071 // lives inside the right operand, never on an ON/USING clause. 17072 // The parser does not attach ON/USING to APPLY items, so these 17073 // are defensive guards (reuse the CROSS_WITH_* codes — no new 17074 // diagnostic code is introduced for APPLY). 17075 if (hasOn) { 17076 throw new SemanticIRBuildException( 17077 Diagnostic.error(DiagnosticCode.CROSS_WITH_ON, 17078 "CROSS/OUTER APPLY must not carry an ON condition; the " 17079 + "correlation belongs inside the right operand", item)); 17080 } 17081 if (hasUsing) { 17082 throw new SemanticIRBuildException( 17083 Diagnostic.error(DiagnosticCode.CROSS_WITH_USING, 17084 "CROSS/OUTER APPLY must not carry a USING clause; the " 17085 + "correlation belongs inside the right operand", item)); 17086 } 17087 return; 17088 } 17089 if (isNatural) { 17090 if (hasOn) { 17091 throw new SemanticIRBuildException( 17092 Diagnostic.error(DiagnosticCode.NATURAL_WITH_ON, 17093 "NATURAL JOIN must not carry an ON condition; rewrite " 17094 + "as JOIN ... ON, or drop the NATURAL keyword", item)); 17095 } 17096 if (hasUsing) { 17097 throw new SemanticIRBuildException( 17098 Diagnostic.error(DiagnosticCode.NATURAL_WITH_USING, 17099 "NATURAL JOIN must not carry a USING clause; choose " 17100 + "either NATURAL or USING, not both", item)); 17101 } 17102 return; 17103 } 17104 if (isOnLess) { 17105 if (hasOn) { 17106 throw new SemanticIRBuildException( 17107 Diagnostic.error(DiagnosticCode.CROSS_WITH_ON, 17108 "CROSS JOIN must not carry an ON condition; rewrite " 17109 + "as INNER JOIN ... ON, or drop the ON clause", item)); 17110 } 17111 if (hasUsing) { 17112 throw new SemanticIRBuildException( 17113 Diagnostic.error(DiagnosticCode.CROSS_WITH_USING, 17114 "CROSS JOIN must not carry a USING clause; rewrite " 17115 + "as INNER JOIN ... USING (...) or drop USING", item)); 17116 } 17117 return; 17118 } 17119 // Predicate-bearing path. 17120 if (hasUsing && hasOn) { 17121 throw new SemanticIRBuildException( 17122 Diagnostic.error(DiagnosticCode.JOIN_WITH_BOTH_ON_AND_USING, 17123 "JOIN cannot carry both ON and USING; choose one", item)); 17124 } 17125 if (!hasUsing && !hasOn) { 17126 throw new SemanticIRBuildException( 17127 Diagnostic.error(DiagnosticCode.JOIN_MISSING_ON_OR_USING, 17128 "JOIN with no ON or USING condition is not supported yet " 17129 + "(implicit joins must be explicit and supported)", item)); 17130 } 17131 } 17132 17133 /** 17134 * Slice 64 — populate {@code joinColumnRefs} for a USING-shaped 17135 * join item. Emits refs in <b>left-then-right</b> order per key. 17136 * 17137 * <p>Column-source resolution looks at two sources, in order: 17138 * <ol> 17139 * <li>The catalog (via 17140 * {@link NameBindingProvider#getRelationColumnNames(TTable)}) 17141 * for base tables;</li> 17142 * <li>The slice-60 in-scope-relation-columns map (via 17143 * {@link NameBindingProvider#getInScopeRelationColumns()}) 17144 * for CTE and FROM-subquery relations, keyed by effective 17145 * alias.</li> 17146 * </ol> 17147 * 17148 * <p>Left side uses these two sources to narrow to the prior 17149 * relations that actually declare the USING key, walking 17150 * {@code topJoin.getTable()} then 17151 * {@code items[0..itemIndex-1].getTable()} in FROM order. 17152 * 17153 * <p>Right side is always {@code item.getTable()}. When either 17154 * source declares the right relation's columns and the USING key 17155 * is absent there, the build is failed-fast with a 17156 * non-exact-binding-style reject — matching what the resolver 17157 * does for plain {@code SELECT k} where {@code k} doesn't exist. 17158 * 17159 * <p>When neither source has any column info for the prior 17160 * relations (no catalog and no in-scope map), fall back to 17161 * emitting one ref for the immediately-prior relation so the 17162 * slice-64 admission still works without a catalog. Same 17163 * fallback applies to the right side (emit unconditionally). 17164 * 17165 * <p>This matches resolver2's all-chain-tables linkage 17166 * ({@code ScopeBuilder.preVisit(TJoinItem)}) for the cases where 17167 * catalog/in-scope info is missing, without adopting its 17168 * over-approximation when info IS available. 17169 */ 17170 private static void populateUsingJoinRefs(TJoin topJoin, 17171 TJoinItemList items, 17172 int itemIndex, 17173 TTable rightTable, 17174 TObjectNameList usingCols, 17175 NameBindingProvider provider, 17176 List<ColumnRef> joinRefsOut) { 17177 // Slice 66: collect the SQL-written USING key spellings and 17178 // delegate to the shared {@link #emitMergedJoinRefs} helper 17179 // which serves both USING (this path) and NATURAL. 17180 List<String> keyNames = new ArrayList<>(usingCols.size()); 17181 for (int k = 0; k < usingCols.size(); k++) { 17182 TObjectName usingKey = usingCols.getObjectName(k); 17183 if (usingKey == null) continue; 17184 String keyName = usingKey.getColumnNameOnly(); 17185 if (keyName == null || keyName.isEmpty()) continue; 17186 keyNames.add(keyName); 17187 } 17188 emitMergedJoinRefs(JoinKind.USING, keyNames, topJoin, items, 17189 itemIndex, rightTable, provider, joinRefsOut); 17190 } 17191 17192 /** 17193 * Slice 66 — discriminator for {@link #emitMergedJoinRefs}. USING 17194 * comes from a syntactic clause and enforces left-and-right 17195 * "key-must-exist" rejects; NATURAL comes from catalog inference 17196 * and never has a missing key by construction. 17197 */ 17198 private enum JoinKind { USING, NATURAL } 17199 17200 /** 17201 * Slice 66 — shared emit-refs helper used by USING and NATURAL. 17202 * Emits per-key {@code joinColumnRefs} in <b>left-then-right</b> 17203 * order, walking every prior FROM relation for the left side. The 17204 * {@code kind} discriminator controls: 17205 * 17206 * <ul> 17207 * <li><b>CTE-explicit-column-list deferral</b>: applies to BOTH 17208 * (the diagnostic wording mentions JOIN kind);</li> 17209 * <li><b>Right-side "missing key" reject</b>: USING-only — 17210 * NATURAL keys come from catalog intersection so the key 17211 * must be present on the right by construction;</li> 17212 * <li><b>Left-side "missing key" reject</b>: USING-only — same 17213 * rationale.</li> 17214 * </ul> 17215 * 17216 * <p>Spelling: the caller supplies the emitted spelling (USING 17217 * passes the SQL-written spelling; NATURAL passes the catalog- 17218 * declared spelling of the first contributor — see 17219 * {@link #naturalSharedKeys}). 17220 */ 17221 private static void emitMergedJoinRefs(JoinKind kind, 17222 List<String> keyNames, 17223 TJoin topJoin, 17224 TJoinItemList items, 17225 int itemIndex, 17226 TTable rightTable, 17227 NameBindingProvider provider, 17228 List<ColumnRef> joinRefsOut) { 17229 String rightAlias = effectiveAliasOf(rightTable); 17230 List<TTable> priorRelations = new ArrayList<>(); 17231 if (topJoin.getTable() != null) { 17232 priorRelations.add(topJoin.getTable()); 17233 } 17234 for (int j = 0; j < itemIndex; j++) { 17235 TJoinItem prevItem = items.getJoinItem(j); 17236 if (prevItem != null && prevItem.getTable() != null) { 17237 priorRelations.add(prevItem.getTable()); 17238 } 17239 } 17240 // Slice 60 / 64 / 66 originally rejected USING / NATURAL joins 17241 // against a CTE with an explicit column list because the CTE 17242 // body's StatementGraph published inner-projection names rather 17243 // than the renamed list. Slice 103 lifts that rejection by 17244 // wiring the slice-102 rename helper into the SELECT-side CTE 17245 // walker; the published column list now matches the explicit 17246 // list, so lookupRelationColumnNames returns the renamed names 17247 // from the in-scope map and the merged-key emit below works. 17248 // `MERGED_JOIN_AGAINST_CTE_WITH_EXPLICIT_COLUMN_LIST` stays 17249 // declared-but-unreached (slice 71/72/82/86/95/96/97/98/99/100/ 17250 // 101/102 precedent). 17251 for (String keyName : keyNames) { 17252 if (keyName == null || keyName.isEmpty()) continue; 17253 17254 // Left side FIRST (matches ON-clause natural reading order). 17255 // For USING: priorRelations with metadata-unknown or 17256 // declared-key emit a ref; missing-key skips. If no ref 17257 // emitted and all priors had known info → USING-only 17258 // reject (NATURAL never reaches this because the catalog 17259 // intersection guarantees at least one contributor). 17260 boolean emittedAnyLeft = false; 17261 boolean allPriorsHadColumnInfo = true; 17262 for (TTable prior : priorRelations) { 17263 List<String> cols = lookupRelationColumnNames(prior, provider); 17264 if (cols == null) { 17265 allPriorsHadColumnInfo = false; 17266 joinRefsOut.add(new ColumnRef( 17267 effectiveAliasOf(prior), keyName)); 17268 emittedAnyLeft = true; 17269 continue; 17270 } 17271 for (String c : cols) { 17272 if (c != null && c.equalsIgnoreCase(keyName)) { 17273 joinRefsOut.add(new ColumnRef( 17274 effectiveAliasOf(prior), keyName)); 17275 emittedAnyLeft = true; 17276 break; 17277 } 17278 } 17279 } 17280 if (kind == JoinKind.USING 17281 && !emittedAnyLeft && allPriorsHadColumnInfo 17282 && !priorRelations.isEmpty()) { 17283 throw new SemanticIRBuildException( 17284 Diagnostic.error(DiagnosticCode.USING_KEY_NOT_DECLARED, 17285 "USING key '" + keyName + "' is not declared on " 17286 + "any left-side relation; check that the " 17287 + "key exists on at least one of the " 17288 + "joined-in relations", rightTable)); 17289 } 17290 17291 // Right side. USING: must exist OR catalog unknown. 17292 // NATURAL: by construction the key is in right's catalog. 17293 // For the unknown-catalog case we still emit (over-approximate). 17294 List<String> rightCols = lookupRelationColumnNames(rightTable, provider); 17295 if (rightCols == null) { 17296 joinRefsOut.add(new ColumnRef(rightAlias, keyName)); 17297 } else { 17298 boolean rightHasKey = false; 17299 for (String c : rightCols) { 17300 if (c != null && c.equalsIgnoreCase(keyName)) { 17301 rightHasKey = true; 17302 break; 17303 } 17304 } 17305 if (!rightHasKey) { 17306 if (kind == JoinKind.USING) { 17307 throw new SemanticIRBuildException( 17308 Diagnostic.error(DiagnosticCode.USING_KEY_NOT_DECLARED, 17309 "USING key '" + keyName + "' is not declared on " 17310 + "right-side relation '" + rightAlias 17311 + "'; USING requires the key to exist on " 17312 + "both sides", rightTable)); 17313 } 17314 // NATURAL: silently skip — should be unreachable 17315 // because keys come from the intersection. 17316 continue; 17317 } 17318 joinRefsOut.add(new ColumnRef(rightAlias, keyName)); 17319 } 17320 } 17321 } 17322 17323 /** 17324 * Slice 64 — true iff the given table reference is a CTE with an 17325 * explicit column list (e.g. {@code WITH x(a, b) AS ...}). Slice 17326 * 64 originally used this to defer USING joins against such CTEs; 17327 * slice 103 lifted that deferral by wiring the slice-102 rename 17328 * helper into the SELECT-side CTE walker. The helper is retained 17329 * for {@link #buildUsingScope}'s ambiguity check (defense in depth) 17330 * and may be reused by future call sites that need to discriminate 17331 * the shape. 17332 */ 17333 private static boolean hasExplicitCteColumnList(TTable table) { 17334 if (table == null) return false; 17335 TCTE cte = table.getCTE(); 17336 return cte != null && cte.getColumnList() != null 17337 && cte.getColumnList().size() > 0; 17338 } 17339 17340 /** 17341 * Slice 65 — read the renamed column names from a CTE's explicit 17342 * column list ({@code WITH x(a, b) AS ...}). Used by 17343 * {@link #buildUsingScope}'s ambiguity check as a defense-in-depth 17344 * complement to {@link #lookupRelationColumnNames}. Slice 65 17345 * originally needed this because the CTE body's StatementGraph 17346 * published inner-projection names; slice 103 lifted that gap by 17347 * applying the slice-102 rename helper on the SELECT side, so the 17348 * in-scope-map path now returns the renamed list too. 17349 */ 17350 private static java.util.List<String> explicitCteColumnNames(TTable table) { 17351 if (table == null) return null; 17352 TCTE cte = table.getCTE(); 17353 if (cte == null) return null; 17354 if (cte.getColumnList() == null || cte.getColumnList().size() == 0) { 17355 return null; 17356 } 17357 java.util.List<String> names = new java.util.ArrayList<>(cte.getColumnList().size()); 17358 for (int i = 0; i < cte.getColumnList().size(); i++) { 17359 TObjectName col = cte.getColumnList().getObjectName(i); 17360 if (col == null) continue; 17361 String n = col.getColumnNameOnly(); 17362 if (n == null || n.isEmpty()) continue; 17363 names.add(n); 17364 } 17365 return names.isEmpty() ? null : names; 17366 } 17367 17368 /** 17369 * Slice 64 — look up column names for a FROM-clause relation 17370 * combining the slice-58 base-table catalog and the slice-60 17371 * in-scope CTE/subquery map. Returns {@code null} when neither 17372 * source has column info for the table. 17373 * 17374 * <p>The in-scope map is consulted <b>first</b>: when a CTE or 17375 * FROM-subquery has the same name as a base table in the catalog, 17376 * the scoped definition shadows the catalog (codex diff-review 17377 * round-2 P2 #1 — without this precedence, USING against the CTE 17378 * would see the catalog table's columns and reject a valid join). 17379 * 17380 * <p>Slice 103 — CTEs with an explicit column list are no longer 17381 * rejected upstream. The SELECT-side CTE walker now invokes the 17382 * slice-102 rename helper, so {@code ctePublishedColumns} carries 17383 * the renamed names; {@code addRelationToInScopeMap} reads from 17384 * that map, and this lookup returns the renamed list. (Slice 64's 17385 * older comment said the rename was deferred — that deferral was 17386 * lifted by slice 103.) 17387 */ 17388 private static List<String> lookupRelationColumnNames(TTable table, 17389 NameBindingProvider provider) { 17390 String key = effectiveAliasLowerCaseOrNull(table); 17391 if (key != null) { 17392 java.util.Map<String, List<String>> inScope = provider.getInScopeRelationColumns(); 17393 if (inScope != null) { 17394 List<String> scoped = inScope.get(key); 17395 if (scoped != null) return scoped; 17396 } 17397 } 17398 return provider.getRelationColumnNames(table); 17399 } 17400 17401 /** 17402 * True when EVERY FROM relation of {@code select} has a known column list 17403 * (catalog metadata via {@link NameBindingProvider#getRelationColumnNames} 17404 * or in-scope derived columns via {@code getInScopeRelationColumns}). Used to 17405 * gate the join-graph {@code COLUMN_BINDING_NON_EXACT} degrade: the degrade 17406 * is justified only when the FROM is catalog-less for at least one endpoint, 17407 * because then an unqualified column that fails to bind <em>might</em> live 17408 * on the unknown side and we cannot prove otherwise. When every endpoint's 17409 * columns are known, the resolver can adjudicate — an unqualified column 17410 * matching no side is a genuine error and must stay fatal. 17411 * 17412 * <p>Returns {@code false} (i.e. not fully known → degrade-eligible) for an 17413 * empty/absent FROM list; callers gate on {@code relations.size() >= 2} 17414 * first, so that branch is not reached in practice. 17415 */ 17416 private static boolean fromRelationColumnsFullyKnown(TSelectSqlStatement select, 17417 NameBindingProvider provider) { 17418 if (select == null) { 17419 return false; 17420 } 17421 gudusoft.gsqlparser.nodes.TTableList tables = select.getTables(); 17422 if (tables == null || tables.size() == 0) { 17423 return false; 17424 } 17425 for (int i = 0; i < tables.size(); i++) { 17426 TTable t = tables.getTable(i); 17427 if (t == null) { 17428 continue; 17429 } 17430 List<String> cols = lookupRelationColumnNames(t, provider); 17431 if (cols == null || cols.isEmpty()) { 17432 return false; 17433 } 17434 } 17435 return true; 17436 } 17437 17438 /** 17439 * Slice 66 — accumulated row type of the LEFT side of a top-level 17440 * {@code TJoin}. Maintained per top-level TJoin so that mixed 17441 * ON/CROSS/USING/NATURAL chains can be reasoned about against the 17442 * full visible row type (NATURAL JOIN's right operand sees every 17443 * column visible in the accumulated left, not just the immediate 17444 * prior table). 17445 * 17446 * <p>{@link #complete} flips to {@code false} when any contributor 17447 * along the chain has no resolvable catalog. A {@code false} 17448 * {@code complete} blocks subsequent NATURAL JoinItems from 17449 * inferring their shared-key list — they reject with a tuned 17450 * catalog-required diagnostic naming whichever side(s) lack 17451 * catalog metadata. 17452 */ 17453 private static final class LeftOutputState { 17454 final java.util.LinkedHashMap<String, List<TTable>> columns = new java.util.LinkedHashMap<>(); 17455 boolean complete = true; 17456 final List<String> missingAliases = new ArrayList<>(); 17457 17458 void markMissing(TTable t) { 17459 complete = false; 17460 String alias = effectiveAliasOf(t); 17461 if (alias != null && !alias.isEmpty()) { 17462 if (!missingAliases.contains(alias)) { 17463 missingAliases.add(alias); 17464 } 17465 } 17466 } 17467 } 17468 17469 /** 17470 * Slice 66 — result of {@link #naturalSharedKeys}. Either a SUCCESS 17471 * (with the inferred key list in left-output insertion order) or 17472 * one of three failure kinds: 17473 * 17474 * <ul> 17475 * <li>{@code INCOMPLETE_LEFT}: at least one prior contributor on 17476 * the accumulated left side had null/empty catalog;</li> 17477 * <li>{@code MISSING_RIGHT}: right table has null/empty catalog;</li> 17478 * <li>{@code BOTH_MISSING}: both above conditions hold.</li> 17479 * </ul> 17480 * 17481 * <p>Failures carry diagnostic aliases so the caller can produce 17482 * a side-specific reject message. 17483 */ 17484 private static final class NaturalKeyResult { 17485 enum Kind { SUCCESS, INCOMPLETE_LEFT, MISSING_RIGHT, BOTH_MISSING } 17486 final Kind kind; 17487 final List<String> keys; 17488 final List<String> leftMissingAliases; 17489 final String rightAlias; 17490 17491 private NaturalKeyResult(Kind kind, List<String> keys, 17492 List<String> leftMissingAliases, 17493 String rightAlias) { 17494 this.kind = kind; 17495 this.keys = keys; 17496 this.leftMissingAliases = leftMissingAliases; 17497 this.rightAlias = rightAlias; 17498 } 17499 static NaturalKeyResult success(List<String> keys) { 17500 return new NaturalKeyResult(Kind.SUCCESS, keys, null, null); 17501 } 17502 static NaturalKeyResult incompleteLeft(List<String> missing) { 17503 return new NaturalKeyResult(Kind.INCOMPLETE_LEFT, null, missing, null); 17504 } 17505 static NaturalKeyResult missingRight(String alias) { 17506 return new NaturalKeyResult(Kind.MISSING_RIGHT, null, null, alias); 17507 } 17508 static NaturalKeyResult bothMissing(List<String> missing, String alias) { 17509 return new NaturalKeyResult(Kind.BOTH_MISSING, null, missing, alias); 17510 } 17511 } 17512 17513 /** 17514 * Slice 66 — seed the {@link LeftOutputState} with the top-left 17515 * table of a top-level TJoin. Used at the start of each TJoin walk. 17516 */ 17517 private static void seedLeftOutput(LeftOutputState state, TTable t, 17518 NameBindingProvider provider) { 17519 if (t == null) return; 17520 List<String> cols = lookupRelationColumnNames(t, provider); 17521 if (cols == null || cols.isEmpty()) { 17522 state.markMissing(t); 17523 return; 17524 } 17525 for (String c : cols) { 17526 if (c == null || c.isEmpty()) continue; 17527 String colLC = c.toLowerCase(Locale.ROOT); 17528 List<TTable> contributors = state.columns.get(colLC); 17529 if (contributors == null) { 17530 contributors = new ArrayList<>(); 17531 state.columns.put(colLC, contributors); 17532 } 17533 contributors.add(t); 17534 } 17535 } 17536 17537 /** 17538 * Slice 66 — append the right table of an ON-shaped or CROSS 17539 * JoinItem into the running {@link LeftOutputState}. Each catalog 17540 * column is added as a new entry (or extends the contributor list 17541 * for an existing same-named entry). Mirrors 17542 * {@link #seedLeftOutput} but additive. 17543 */ 17544 /** 17545 * Merge the row type accumulated in {@code src} (a nested joined-table 17546 * operand's own {@link LeftOutputState}) into {@code dst} (the enclosing 17547 * join's running state). Used after recursing into a parenthesized 17548 * {@code <joined_table>} right operand so that a NATURAL join FOLLOWING the 17549 * nested operand at the enclosing level sees the sub-tree's columns, while 17550 * the sub-tree's own inner NATURAL inference ran against a fresh state. 17551 */ 17552 private static void mergeLeftOutputState(LeftOutputState dst, LeftOutputState src) { 17553 if (src == null) return; 17554 for (Map.Entry<String, List<TTable>> e : src.columns.entrySet()) { 17555 List<TTable> contributors = dst.columns.get(e.getKey()); 17556 if (contributors == null) { 17557 contributors = new ArrayList<>(); 17558 dst.columns.put(e.getKey(), contributors); 17559 } 17560 contributors.addAll(e.getValue()); 17561 } 17562 if (!src.complete) { 17563 dst.complete = false; 17564 for (String a : src.missingAliases) { 17565 if (!dst.missingAliases.contains(a)) { 17566 dst.missingAliases.add(a); 17567 } 17568 } 17569 } 17570 } 17571 17572 private static void appendRightToLeftOutput(LeftOutputState state, TTable right, 17573 NameBindingProvider provider) { 17574 if (right == null) return; 17575 List<String> cols = lookupRelationColumnNames(right, provider); 17576 if (cols == null || cols.isEmpty()) { 17577 state.markMissing(right); 17578 return; 17579 } 17580 for (String c : cols) { 17581 if (c == null || c.isEmpty()) continue; 17582 String colLC = c.toLowerCase(Locale.ROOT); 17583 List<TTable> contributors = state.columns.get(colLC); 17584 if (contributors == null) { 17585 contributors = new ArrayList<>(); 17586 state.columns.put(colLC, contributors); 17587 } 17588 contributors.add(right); 17589 } 17590 } 17591 17592 /** 17593 * Slice 66 — merge the right table of a USING-shaped or 17594 * NATURAL-shaped JoinItem. Columns in {@code mergedKeys} are 17595 * appended to the existing same-named contributor list at their 17596 * original output position (no new slot); other columns are 17597 * appended as new entries (or contributed to an existing same-named 17598 * entry — slice-59 plain-vs-plain duplicate admit). 17599 */ 17600 private static void mergeRightIntoLeftOutput(LeftOutputState state, TTable right, 17601 NameBindingProvider provider, 17602 List<String> mergedKeys) { 17603 if (right == null) return; 17604 java.util.Set<String> mergedKeysLC = new HashSet<>(); 17605 if (mergedKeys != null) { 17606 for (String k : mergedKeys) { 17607 if (k != null && !k.isEmpty()) { 17608 mergedKeysLC.add(k.toLowerCase(Locale.ROOT)); 17609 } 17610 } 17611 } 17612 List<String> cols = lookupRelationColumnNames(right, provider); 17613 if (cols == null || cols.isEmpty()) { 17614 state.markMissing(right); 17615 return; 17616 } 17617 for (String c : cols) { 17618 if (c == null || c.isEmpty()) continue; 17619 String colLC = c.toLowerCase(Locale.ROOT); 17620 // mergedKeysLC.contains(colLC) — append to existing entry; 17621 // !mergedKeysLC.contains(colLC) && state.columns.containsKey(colLC) 17622 // — append to existing entry (plain-vs-plain duplicate); 17623 // !state.columns.containsKey(colLC) — new entry. 17624 List<TTable> contributors = state.columns.get(colLC); 17625 if (contributors == null) { 17626 contributors = new ArrayList<>(); 17627 state.columns.put(colLC, contributors); 17628 } 17629 contributors.add(right); 17630 } 17631 } 17632 17633 /** 17634 * Slice 66 — infer the NATURAL JOIN shared-column list for the 17635 * current JoinItem. Returns one of four results per §6.1 of the 17636 * slice-66 plan. The shared list uses catalog-declared spelling 17637 * from the FIRST contributor that publishes each key (NATURAL has 17638 * no SQL-written key token, so the catalog form is the only 17639 * source of truth). 17640 */ 17641 private static NaturalKeyResult naturalSharedKeys(LeftOutputState leftState, 17642 TTable right, 17643 NameBindingProvider provider) { 17644 List<String> rightCols = lookupRelationColumnNames(right, provider); 17645 boolean rightMissing = (rightCols == null || rightCols.isEmpty()); 17646 if (!leftState.complete && rightMissing) { 17647 return NaturalKeyResult.bothMissing(leftState.missingAliases, 17648 effectiveAliasOf(right)); 17649 } 17650 if (!leftState.complete) { 17651 return NaturalKeyResult.incompleteLeft(leftState.missingAliases); 17652 } 17653 if (rightMissing) { 17654 return NaturalKeyResult.missingRight(effectiveAliasOf(right)); 17655 } 17656 java.util.Set<String> rightLC = new HashSet<>(); 17657 for (String c : rightCols) { 17658 if (c != null && !c.isEmpty()) { 17659 rightLC.add(c.toLowerCase(Locale.ROOT)); 17660 } 17661 } 17662 List<String> shared = new ArrayList<>(); 17663 for (java.util.Map.Entry<String, List<TTable>> e 17664 : leftState.columns.entrySet()) { 17665 String keyLC = e.getKey(); 17666 if (rightLC.contains(keyLC)) { 17667 shared.add(firstCatalogSpelling(e.getValue(), keyLC, provider)); 17668 } 17669 } 17670 return NaturalKeyResult.success(shared); 17671 } 17672 17673 /** 17674 * Slice 66 — return the catalog-declared spelling of {@code keyLC} 17675 * from the first contributor in insertion order that publishes the 17676 * key with a non-null spelling. Defensive fallback to {@code keyLC} 17677 * if no contributor exposes the spelling (unreachable in practice 17678 * because contributors are catalogued by construction). 17679 */ 17680 private static String firstCatalogSpelling(List<TTable> contributors, 17681 String keyLC, 17682 NameBindingProvider provider) { 17683 if (contributors != null) { 17684 for (TTable t : contributors) { 17685 List<String> cols = lookupRelationColumnNames(t, provider); 17686 if (cols == null) continue; 17687 for (String c : cols) { 17688 if (c != null && c.equalsIgnoreCase(keyLC)) { 17689 return c; 17690 } 17691 } 17692 } 17693 } 17694 return keyLC; 17695 } 17696 17697 /** 17698 * Slice 66 — diagnostic helper. Joins a list of aliases for the 17699 * NATURAL-required catalog reject message. 17700 */ 17701 private static String formatAliasList(List<String> aliases) { 17702 if (aliases == null || aliases.isEmpty()) return "<none>"; 17703 StringBuilder sb = new StringBuilder(); 17704 for (int i = 0; i < aliases.size(); i++) { 17705 if (i > 0) sb.append(", "); 17706 sb.append("'").append(aliases.get(i)).append("'"); 17707 } 17708 return sb.toString(); 17709 } 17710 17711 /** 17712 * Slice 66 — turn a {@link NaturalKeyResult} failure into a 17713 * structured diagnostic for the gated reject inside 17714 * {@link #buildRelations}. 17715 */ 17716 private static String formatNaturalCatalogReject(NaturalKeyResult r) { 17717 switch (r.kind) { 17718 case INCOMPLETE_LEFT: 17719 return "NATURAL JOIN requires catalog metadata for both sides; " 17720 + "left-side row type is incomplete due to uncatalogued " 17721 + "relation(s) " + formatAliasList(r.leftMissingAliases) 17722 + "; supply a TSQLEnv (or in-scope CTE / FROM-subquery " 17723 + "body) for the missing relation(s), or rewrite as " 17724 + "JOIN ... ON"; 17725 case MISSING_RIGHT: 17726 return "NATURAL JOIN requires catalog metadata for both sides; " 17727 + "right-side relation '" + r.rightAlias 17728 + "' has no resolvable column list; supply a TSQLEnv " 17729 + "(or in-scope CTE / FROM-subquery body) for this " 17730 + "relation, or rewrite as JOIN ... ON"; 17731 case BOTH_MISSING: 17732 return "NATURAL JOIN requires catalog metadata for both sides; " 17733 + "left-side row type is incomplete due to uncatalogued " 17734 + "relation(s) " + formatAliasList(r.leftMissingAliases) 17735 + " and right-side relation '" + r.rightAlias 17736 + "' also has no resolvable column list; supply a " 17737 + "TSQLEnv for the missing relation(s), or rewrite " 17738 + "as JOIN ... ON"; 17739 default: 17740 return "NATURAL JOIN: unexpected result kind " + r.kind; 17741 } 17742 } 17743 17744 /** 17745 * Slice 65 — fail fast when a JOIN ON clause references a USING 17746 * merged key by its bare (unqualified) name. JOIN ON requires 17747 * per-position scope (only relations BEFORE that JoinItem are 17748 * visible), which slice 65 does not yet model; the merged-key 17749 * collector applied by other clauses would over-include later 17750 * relations. Reject the shape so the slice-66+ slice can lift 17751 * with proper per-position scope. 17752 * 17753 * <p>This is the narrowed replacement for slice-64's 17754 * {@code rejectUnqualifiedUsingKeyReferences}, which scanned the 17755 * entire SELECT body. Slice 65 admits unqualified USING-key refs 17756 * in every other clause via the merged-key collector. 17757 * 17758 * <p>Qualified references (e.g. {@code a.k}, {@code b.k}) and 17759 * column references whose names don't match a USING key are 17760 * unaffected. 17761 */ 17762 private static void rejectUnqualifiedMergedKeyInJoinOn(TSelectSqlStatement select, 17763 NameBindingProvider provider) { 17764 if (select.joins == null) return; 17765 // Walk each TOP-LEVEL TJoin independently — each comma-FROM 17766 // group has its own scope for JOIN ON purposes (codex slice-65 17767 // diff-review round-4 P2 #1). Within one TJoin, walk JoinItems 17768 // in FROM order and track which merged keys (USING-declared OR 17769 // NATURAL-inferred) have been established. An ON clause is only 17770 // checked against keys that are ALREADY merged at that position; 17771 // a bare `k` in an ON before any USING(k) / NATURAL is just 17772 // resolver2's unqualified-binding case. 17773 // 17774 // Slice 66: NATURAL JoinItems contribute their catalog-inferred 17775 // key list to declaredKeysSoFar. When NATURAL would fail the 17776 // catalog requirement (INCOMPLETE_LEFT / MISSING_RIGHT / 17777 // BOTH_MISSING), the preflight silently skips recording this 17778 // JoinItem's keys — the gated reject in buildRelations will 17779 // fire with a catalog-required diagnostic and the user sees 17780 // that error first. 17781 // 17782 // Identity skip set: USING-clause own TObjectNames are 17783 // declarations not references; never matched against the 17784 // declaredKeysSoFar set since the preflight only walks ON 17785 // conditions. Kept as a defensive no-op. 17786 final java.util.Set<TObjectName> skip = 17787 java.util.Collections.newSetFromMap( 17788 new java.util.IdentityHashMap<TObjectName, Boolean>()); 17789 for (int j = 0; j < select.joins.size(); j++) { 17790 TJoin top = select.joins.getJoin(j); 17791 if (top == null) continue; 17792 TJoinItemList items = top.getJoinItems(); 17793 if (items == null) continue; 17794 // Reset per top-level TJoin so independent comma-FROM 17795 // groups don't poison each other's ON clauses. 17796 final java.util.Set<String> declaredKeysSoFar = new java.util.HashSet<>(); 17797 LeftOutputState leftState = new LeftOutputState(); 17798 seedLeftOutput(leftState, top.getTable(), provider); 17799 for (int i = 0; i < items.size(); i++) { 17800 TJoinItem item = items.getJoinItem(i); 17801 if (item == null) continue; 17802 // Check ON FIRST (uses scope BEFORE this JoinItem), then 17803 // record this JoinItem's USING/NATURAL declarations so 17804 // future siblings see them. 17805 TExpression onCond = item.getOnCondition(); 17806 if (onCond != null && !declaredKeysSoFar.isEmpty()) { 17807 final java.util.Set<String> alreadyDeclared = 17808 new java.util.HashSet<>(declaredKeysSoFar); 17809 onCond.acceptChildren(new TParseTreeVisitor() { 17810 int nestedSelectDepth = 0; 17811 17812 @Override 17813 public void preVisit(TSelectSqlStatement nested) { 17814 nestedSelectDepth++; 17815 } 17816 17817 @Override 17818 public void postVisit(TSelectSqlStatement nested) { 17819 nestedSelectDepth--; 17820 } 17821 17822 @Override 17823 public void preVisit(TObjectName node) { 17824 if (nestedSelectDepth > 0) return; 17825 if (skip.contains(node)) return; 17826 if (node.getDbObjectType() != EDbObjectType.column) return; 17827 String name = node.getColumnNameOnly(); 17828 if (name == null || name.isEmpty() || "*".equals(name)) return; 17829 if (!alreadyDeclared.contains(name.toLowerCase(Locale.ROOT))) return; 17830 String qualifier = node.getTableString(); 17831 if (qualifier == null || qualifier.isEmpty()) { 17832 throw new SemanticIRBuildException( 17833 Diagnostic.error(DiagnosticCode.UNQUALIFIED_MERGED_KEY_IN_JOIN_ON, 17834 "unqualified reference to merged key '" 17835 + name + "' inside a JOIN ON condition " 17836 + "is deferred to a future slice " 17837 + "(per-position scope semantics needed); " 17838 + "qualify with a table alias " 17839 + "(e.g. a." + name + ") to disambiguate", null)); 17840 } 17841 } 17842 }); 17843 } 17844 // Record this JoinItem's contribution to declaredKeysSoFar 17845 // and update leftState for NATURAL's accumulated-left 17846 // semantics. 17847 TTable rightTable = item.getTable(); 17848 TObjectNameList usingCols = item.getUsingColumns(); 17849 if (usingCols != null && usingCols.size() > 0) { 17850 List<String> usingKeyNames = new ArrayList<>(usingCols.size()); 17851 for (int k = 0; k < usingCols.size(); k++) { 17852 TObjectName n = usingCols.getObjectName(k); 17853 if (n == null) continue; 17854 skip.add(n); 17855 String name = n.getColumnNameOnly(); 17856 if (name != null && !name.isEmpty()) { 17857 declaredKeysSoFar.add(name.toLowerCase(Locale.ROOT)); 17858 usingKeyNames.add(name); 17859 } 17860 } 17861 if (rightTable != null) { 17862 mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames); 17863 } 17864 } else if (isNaturalJoinType(item.getJoinType()) && rightTable != null) { 17865 NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider); 17866 if (r.kind == NaturalKeyResult.Kind.SUCCESS) { 17867 for (String s : r.keys) { 17868 if (s != null && !s.isEmpty()) { 17869 declaredKeysSoFar.add(s.toLowerCase(Locale.ROOT)); 17870 } 17871 } 17872 mergeRightIntoLeftOutput(leftState, rightTable, provider, r.keys); 17873 } else { 17874 // Catalog-required reject fires upstream in 17875 // buildRelations. Defensively append for state 17876 // consistency. 17877 appendRightToLeftOutput(leftState, rightTable, provider); 17878 } 17879 } else if (rightTable != null) { 17880 appendRightToLeftOutput(leftState, rightTable, provider); 17881 } 17882 } 17883 } 17884 } 17885 17886 /** 17887 * Slice 65 — compute the {@link UsingScope} for the current SELECT 17888 * body from its FROM-clause USING joins. Walks every {@link TJoin} 17889 * in {@code select.joins} and for each USING(k) JoinItem, builds 17890 * the per-key equivalence class via DSU-like union over prior 17891 * relations + the right-side relation. Then materializes each 17892 * class by a separate FROM-order pass with identity dedup so 17893 * chained USING joins (`a JOIN b USING(k) JOIN c USING(k)`) 17894 * produce {@code [a, b, c]}, never duplicates. 17895 * 17896 * <p>For each class, builds a {@link UsingScope.MergedKeyEntry} 17897 * with FROM-ordered merged source refs (one per relation that 17898 * publishes the key per catalog / in-scope map; unknown-metadata 17899 * priors emit refs unconditionally, matching slice-64's over- 17900 * approximation policy in {@link #populateUsingJoinRefs}). 17901 * 17902 * <p>Ambiguity is precomputed: 17903 * <ul> 17904 * <li>{@code entries.size() > 1}: two disconnected USING classes 17905 * share the same key name.</li> 17906 * <li>{@code entries.size() == 1} AND a FROM relation outside 17907 * the class has catalog metadata that declares the key: 17908 * out-of-class same-named column.</li> 17909 * </ul> 17910 * 17911 * <p>Returns {@link UsingScope#EMPTY} when no USING clauses are 17912 * present in {@code select.joins}. 17913 */ 17914 private static UsingScope buildUsingScope(TSelectSqlStatement select, 17915 NameBindingProvider provider) { 17916 if (select.joins == null) return UsingScope.EMPTY; 17917 // Slice 86 — delegate to the shared TJoinList-taking helper so 17918 // joined UPDATE (slice 86 buildUpdateUsingScope) can reuse the 17919 // identical scope-build pipeline. 17920 return buildUsingScopeFromJoinList(select.joins, provider); 17921 } 17922 17923 /** 17924 * Slice 86 — compute the {@link UsingScope} for a joined UPDATE's 17925 * FROM clause via {@code update.getJoins()}. Mirrors slice-65 17926 * {@link #buildUsingScope}: USING / NATURAL JoinItems contribute 17927 * merged-key equivalence classes; unqualified merged-key references 17928 * in SET RHS / WHERE / RETURNING resolve to the merged source list. 17929 * 17930 * <p>Returns {@link UsingScope#EMPTY} when no USING/NATURAL JoinItems 17931 * appear in the FROM clause. 17932 */ 17933 private static UsingScope buildUpdateUsingScope(TUpdateSqlStatement update, 17934 NameBindingProvider provider) { 17935 if (update == null) return UsingScope.EMPTY; 17936 return buildUsingScopeFromJoinList(update.getJoins(), provider); 17937 } 17938 17939 /** 17940 * Slice 86 — shared {@link UsingScope} computation extracted from 17941 * slice-65 {@link #buildUsingScope}. Takes the {@link TJoinList} 17942 * directly so it can be invoked from both SELECT 17943 * ({@link #buildUsingScope}) and joined UPDATE 17944 * ({@link #buildUpdateUsingScope}). 17945 * 17946 * <p>Behavior identical to slice 65/66: per-key DSU union over prior 17947 * relations + right-side relation per top-level {@link TJoin}; 17948 * disconnected comma-FROM groups keep their own per-key components; 17949 * NATURAL JoinItems infer shared keys against accumulated left row 17950 * type via {@link LeftOutputState}; ambiguity detection walks all 17951 * FROM relations for out-of-class same-named columns. 17952 */ 17953 private static UsingScope buildUsingScopeFromJoinList(TJoinList joins, 17954 NameBindingProvider provider) { 17955 if (joins == null) return UsingScope.EMPTY; 17956 // Pass 1: per-key DSU. For each USING(k) or NATURAL JoinItem, 17957 // union the prior relations PUBLISHING the key (catalog-narrowed 17958 // per codex slice-66 round-1 P1 #1) with the right-side relation, 17959 // scoped to the enclosing top-level TJoin (chained merges within 17960 // one TJoin transitively connect through DSU). Disconnected 17961 // top-level TJoins (comma-FROM) keep their own per-key components. 17962 // Slice 66 maintains a LeftOutputState alongside the loop so 17963 // NATURAL JoinItems can infer their shared-key list against the 17964 // accumulated left row type. 17965 java.util.Map<String, java.util.List<java.util.List<TTable>>> perKeyComponents = 17966 new java.util.LinkedHashMap<>(); 17967 // Track the SQL-written spelling of each merged key (the first 17968 // occurrence in FROM order). For USING keys this is the 17969 // SQL-written USING-clause case (slice-64 contract); for 17970 // NATURAL keys this is the catalog-declared spelling from the 17971 // first contributor. 17972 java.util.Map<String, String> originalSpellingByKey = new java.util.HashMap<>(); 17973 // Catalog-less NATURAL JOIN degrade: ordered (FROM-order) list of 17974 // left-side relation aliases for NATURAL joins whose shared-key set 17975 // could not be computed. Lets unqualified references to a merged 17976 // NATURAL key resolve deterministically to the left side instead of 17977 // failing as COLUMN_BINDING_NON_EXACT (GSP R6 degrade-not-fail). 17978 java.util.List<String> naturalDegradeAliases = new java.util.ArrayList<>(); 17979 // The degrade only applies when the FROM is PURELY NATURAL-connected — 17980 // a single top-level TJoin whose every join item is NATURAL. A mixed 17981 // FROM (plain ON/CROSS/USING item, or a comma-separated second TJoin) 17982 // introduces an independent relation, so an unqualified reference there 17983 // is genuinely ambiguous and MUST still reject (doc §5.3: "do not apply 17984 // this special-case rule to non-NATURAL joins"). Cleared below if any 17985 // non-NATURAL linkage is seen. 17986 boolean pureNatural = (joins.size() == 1); 17987 for (int jx = 0; jx < joins.size(); jx++) { 17988 TJoin top = joins.getJoin(jx); 17989 if (top == null) continue; 17990 TJoinItemList items = top.getJoinItems(); 17991 if (items == null) continue; 17992 TTable topTable = top.getTable(); 17993 // Slice 66: per-TJoin LeftOutputState for NATURAL inference. 17994 LeftOutputState leftState = new LeftOutputState(); 17995 seedLeftOutput(leftState, topTable, provider); 17996 // Per-key in-progress chain for THIS top-level TJoin. 17997 java.util.Map<String, java.util.List<TTable>> inProgressByKey = 17998 new java.util.HashMap<>(); 17999 for (int i = 0; i < items.size(); i++) { 18000 TJoinItem item = items.getJoinItem(i); 18001 if (item == null) continue; 18002 TTable rightTable = item.getTable(); 18003 if (rightTable == null) continue; 18004 18005 // Determine merged keys for this JoinItem and the 18006 // emitted spelling per key. Three cases: 18007 // USING: keys = syntactic usingCols; spelling = USING-clause text. 18008 // NATURAL: keys = catalog intersection (when SUCCESS); 18009 // spelling = catalog spelling. 18010 // ON/CROSS/other: skip — append to leftState only. 18011 List<String> keyNames; 18012 java.util.Map<String, String> spellingByKeyLC = new java.util.HashMap<>(); 18013 TObjectNameList usingCols = item.getUsingColumns(); 18014 if (usingCols != null && usingCols.size() > 0) { 18015 // USING introduces an explicit (non-NATURAL) merge — the 18016 // FROM is no longer purely NATURAL, so disable the degrade. 18017 pureNatural = false; 18018 keyNames = new java.util.ArrayList<>(usingCols.size()); 18019 for (int k = 0; k < usingCols.size(); k++) { 18020 TObjectName keyNode = usingCols.getObjectName(k); 18021 if (keyNode == null) continue; 18022 String keyName = keyNode.getColumnNameOnly(); 18023 if (keyName == null || keyName.isEmpty()) continue; 18024 keyNames.add(keyName); 18025 spellingByKeyLC.put(keyName.toLowerCase(Locale.ROOT), keyName); 18026 } 18027 } else if (isNaturalJoinType(item.getJoinType())) { 18028 NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider); 18029 if (r.kind != NaturalKeyResult.Kind.SUCCESS) { 18030 // Catalog-required reject already fired (or will 18031 // fire) inside buildRelations. Defensively skip 18032 // this JoinItem in the scope build; it does NOT 18033 // contribute to the merged-key scope. Record the 18034 // left-side relation so unqualified references to a 18035 // merged NATURAL key degrade rather than reject. 18036 String leftAlias = effectiveAliasOf(topTable); 18037 if (leftAlias != null && !leftAlias.isEmpty() 18038 && !naturalDegradeAliases.contains(leftAlias)) { 18039 naturalDegradeAliases.add(leftAlias); 18040 } 18041 appendRightToLeftOutput(leftState, rightTable, provider); 18042 continue; 18043 } 18044 keyNames = r.keys; 18045 for (String s : keyNames) { 18046 if (s != null && !s.isEmpty()) { 18047 spellingByKeyLC.put(s.toLowerCase(Locale.ROOT), s); 18048 } 18049 } 18050 } else { 18051 // ON / CROSS / other — no merged-key contribution. This 18052 // join item introduces an independent relation, so an 18053 // unqualified reference is genuinely ambiguous: disable the 18054 // NATURAL degrade for the whole FROM. 18055 pureNatural = false; 18056 appendRightToLeftOutput(leftState, rightTable, provider); 18057 continue; 18058 } 18059 18060 // Prior relations for this JoinItem in FROM order: 18061 // topTable + items[0..i-1].getTable(). 18062 java.util.List<TTable> priorRelations = new java.util.ArrayList<>(); 18063 if (topTable != null) priorRelations.add(topTable); 18064 for (int p = 0; p < i; p++) { 18065 TJoinItem prev = items.getJoinItem(p); 18066 if (prev != null && prev.getTable() != null) { 18067 priorRelations.add(prev.getTable()); 18068 } 18069 } 18070 for (String keyName : keyNames) { 18071 if (keyName == null || keyName.isEmpty()) continue; 18072 String keyLC = keyName.toLowerCase(Locale.ROOT); 18073 // Record the first emitted spelling we see for this 18074 // key. USING uses SQL-written spelling; NATURAL uses 18075 // catalog-declared spelling. 18076 if (!originalSpellingByKey.containsKey(keyLC)) { 18077 originalSpellingByKey.put(keyLC, spellingByKeyLC.get(keyLC)); 18078 } 18079 java.util.List<TTable> chain = inProgressByKey.get(keyLC); 18080 if (chain == null) { 18081 chain = new java.util.ArrayList<>(); 18082 inProgressByKey.put(keyLC, chain); 18083 } 18084 // Slice 66 catalog-narrowed union (codex round-1 P1 #1): 18085 // for each prior relation, include in this key's 18086 // equivalence class only if (a) catalog is unknown 18087 // (over-approximate; slice-64 fallback), or (b) 18088 // catalog declares the key. Skip if catalog is 18089 // known and the key is proven absent. 18090 for (TTable prior : priorRelations) { 18091 if (containsByIdentity(chain, prior)) continue; 18092 List<String> priorCols = lookupRelationColumnNames(prior, provider); 18093 if (priorCols == null) { 18094 chain.add(prior); 18095 continue; 18096 } 18097 boolean priorPublishes = false; 18098 for (String pc : priorCols) { 18099 if (pc != null && pc.equalsIgnoreCase(keyLC)) { 18100 priorPublishes = true; 18101 break; 18102 } 18103 } 18104 if (priorPublishes) { 18105 chain.add(prior); 18106 } 18107 } 18108 if (!containsByIdentity(chain, rightTable)) { 18109 chain.add(rightTable); 18110 } 18111 } 18112 // After the merged-key bookkeeping, merge right into 18113 // the leftState so subsequent NATURAL JoinItems see 18114 // the accumulated row type. 18115 mergeRightIntoLeftOutput(leftState, rightTable, provider, keyNames); 18116 } 18117 // Flush this TJoin's in-progress chains as one component 18118 // per key. 18119 for (java.util.Map.Entry<String, java.util.List<TTable>> e : 18120 inProgressByKey.entrySet()) { 18121 java.util.List<java.util.List<TTable>> bucket = perKeyComponents.get(e.getKey()); 18122 if (bucket == null) { 18123 bucket = new java.util.ArrayList<>(); 18124 perKeyComponents.put(e.getKey(), bucket); 18125 } 18126 bucket.add(e.getValue()); 18127 } 18128 } 18129 // Mixed FROM (any non-NATURAL linkage seen): drop the degrade fallback 18130 // so genuinely-ambiguous unqualified references still reject. 18131 if (!pureNatural) { 18132 naturalDegradeAliases.clear(); 18133 } 18134 if (perKeyComponents.isEmpty()) { 18135 // No merged-key scope, but a catalog-less NATURAL JOIN may still 18136 // need the degrade fallback for unqualified merged-key refs. 18137 return naturalDegradeAliases.isEmpty() 18138 ? UsingScope.EMPTY 18139 : new UsingScope( 18140 java.util.Collections.<String, java.util.List<UsingScope.MergedKeyEntry>>emptyMap(), 18141 java.util.Collections.<String, String>emptyMap(), 18142 naturalDegradeAliases); 18143 } 18144 // Pass 2: materialize EquivalenceClass + MergedKeyEntry per 18145 // component. FROM-order is already preserved by Pass 1's 18146 // accumulation order (priorRelations + rightTable). 18147 java.util.Map<String, java.util.List<UsingScope.MergedKeyEntry>> entriesByName = 18148 new java.util.LinkedHashMap<>(); 18149 for (java.util.Map.Entry<String, java.util.List<java.util.List<TTable>>> e : 18150 perKeyComponents.entrySet()) { 18151 String keyLC = e.getKey(); 18152 // ColumnRef-emit spelling: SQL-written USING-clause spelling 18153 // (matches slice-64 populateUsingJoinRefs). Falls back to 18154 // keyLC if no spelling was recorded (defensive). 18155 String emitKeyName = originalSpellingByKey.containsKey(keyLC) 18156 ? originalSpellingByKey.get(keyLC) 18157 : keyLC; 18158 java.util.List<UsingScope.MergedKeyEntry> entries = new java.util.ArrayList<>(); 18159 for (java.util.List<TTable> componentMembers : e.getValue()) { 18160 if (componentMembers.isEmpty()) continue; 18161 UsingScope.EquivalenceClass cls = new UsingScope.EquivalenceClass( 18162 keyLC, componentMembers); 18163 java.util.List<ColumnRef> sources = new java.util.ArrayList<>(); 18164 java.util.Set<String> seenAliases = new java.util.HashSet<>(); 18165 for (TTable t : componentMembers) { 18166 String effAlias = effectiveAliasOf(t); 18167 if (effAlias == null || effAlias.isEmpty()) continue; 18168 String aliasKey = effAlias.toLowerCase(Locale.ROOT); 18169 if (seenAliases.contains(aliasKey)) continue; 18170 java.util.List<String> cols = lookupRelationColumnNames(t, provider); 18171 if (cols == null) { 18172 // Metadata-unknown: emit ref (over-approximate). 18173 sources.add(new ColumnRef(effAlias, emitKeyName)); 18174 seenAliases.add(aliasKey); 18175 continue; 18176 } 18177 for (String c : cols) { 18178 if (c != null && c.equalsIgnoreCase(keyLC)) { 18179 sources.add(new ColumnRef(effAlias, emitKeyName)); 18180 seenAliases.add(aliasKey); 18181 break; 18182 } 18183 } 18184 } 18185 if (!sources.isEmpty()) { 18186 entries.add(new UsingScope.MergedKeyEntry(cls, sources)); 18187 } 18188 } 18189 if (!entries.isEmpty()) { 18190 entriesByName.put(keyLC, entries); 18191 } 18192 } 18193 if (entriesByName.isEmpty()) { 18194 return naturalDegradeAliases.isEmpty() 18195 ? UsingScope.EMPTY 18196 : new UsingScope( 18197 java.util.Collections.<String, java.util.List<UsingScope.MergedKeyEntry>>emptyMap(), 18198 java.util.Collections.<String, String>emptyMap(), 18199 naturalDegradeAliases); 18200 } 18201 // Pass 3: precompute ambiguity per key. 18202 java.util.Map<String, String> ambiguityByName = new java.util.HashMap<>(); 18203 java.util.List<TTable> allFromRelations = walkAllFromRelationsFromJoinList(joins); 18204 for (java.util.Map.Entry<String, java.util.List<UsingScope.MergedKeyEntry>> e : 18205 entriesByName.entrySet()) { 18206 String keyLC = e.getKey(); 18207 java.util.List<UsingScope.MergedKeyEntry> entries = e.getValue(); 18208 if (entries.size() > 1) { 18209 ambiguityByName.put(keyLC, 18210 "multiple disconnected USING(" + keyLC + ") equivalence " 18211 + "classes appear in this FROM (their merged " 18212 + "columns share the same key name)"); 18213 continue; 18214 } 18215 // Single class. Walk all FROM relations; if any out-of-class 18216 // relation is catalog-known to publish the key, mark ambiguous. 18217 UsingScope.EquivalenceClass cls = entries.get(0).getEquivClass(); 18218 java.util.IdentityHashMap<TTable, Boolean> inClass = new java.util.IdentityHashMap<>(); 18219 for (TTable m : cls.getMembers()) inClass.put(m, Boolean.TRUE); 18220 for (TTable r : allFromRelations) { 18221 if (inClass.containsKey(r)) continue; 18222 // Slice 65 diff-review round-3 P2 #2 (slice 103 update): 18223 // post-slice-103 both branches return the same data — 18224 // `lookupRelationColumnNames` consults the in-scope map 18225 // populated from `ctePublishedColumns`, which now holds 18226 // the renamed names. The discriminator is retained as a 18227 // defense-in-depth path for any call site that bypasses 18228 // the SELECT-side CTE walker but still wants to detect 18229 // renamed-key collisions; the slice-103 path falls into 18230 // the `lookupRelationColumnNames` branch and gets the 18231 // same renamed list. 18232 java.util.List<String> cols; 18233 if (hasExplicitCteColumnList(r)) { 18234 cols = explicitCteColumnNames(r); 18235 } else { 18236 cols = lookupRelationColumnNames(r, provider); 18237 } 18238 if (cols == null) continue; // unknown → trust writer 18239 for (String c : cols) { 18240 if (c != null && c.equalsIgnoreCase(keyLC)) { 18241 String outAlias = effectiveAliasOf(r); 18242 ambiguityByName.put(keyLC, 18243 "the USING(" + keyLC + ") merged column collides " 18244 + "with column '" + keyLC + "' on relation '" 18245 + (outAlias != null ? outAlias : "<unnamed>") 18246 + "' which is not part of the USING equivalence class"); 18247 break; 18248 } 18249 } 18250 if (ambiguityByName.containsKey(keyLC)) break; 18251 } 18252 } 18253 return new UsingScope(entriesByName, ambiguityByName, naturalDegradeAliases); 18254 } 18255 18256 private static boolean containsByIdentity(java.util.List<TTable> list, TTable t) { 18257 for (TTable x : list) { 18258 if (x == t) return true; 18259 } 18260 return false; 18261 } 18262 18263 /** 18264 * Slice 65 — every FROM-clause relation reachable directly from 18265 * {@code select.joins} (every {@code top.getTable()} + every 18266 * {@code joinItem.getTable()}). Used by {@link #buildUsingScope} 18267 * to detect out-of-equivalence-class same-named columns. 18268 */ 18269 private static java.util.List<TTable> walkAllFromRelations(TSelectSqlStatement select) { 18270 if (select == null) return new java.util.ArrayList<>(); 18271 return walkAllFromRelationsFromJoinList(select.joins); 18272 } 18273 18274 /** 18275 * Slice 86 — shared {@link TJoinList}-taking walker for ambiguity 18276 * detection inside {@link #buildUsingScopeFromJoinList}. Used by both 18277 * SELECT ({@link #walkAllFromRelations}) and joined UPDATE 18278 * ({@link #buildUpdateUsingScope}). 18279 */ 18280 private static java.util.List<TTable> walkAllFromRelationsFromJoinList(TJoinList joins) { 18281 java.util.List<TTable> out = new java.util.ArrayList<>(); 18282 if (joins == null) return out; 18283 for (int j = 0; j < joins.size(); j++) { 18284 TJoin top = joins.getJoin(j); 18285 if (top == null) continue; 18286 if (top.getTable() != null) out.add(top.getTable()); 18287 TJoinItemList items = top.getJoinItems(); 18288 if (items == null) continue; 18289 for (int i = 0; i < items.size(); i++) { 18290 TJoinItem item = items.getJoinItem(i); 18291 if (item != null && item.getTable() != null) { 18292 out.add(item.getTable()); 18293 } 18294 } 18295 } 18296 return out; 18297 } 18298 18299 /** 18300 * Slice 64 — true iff any TJoinItem in {@code select.joins} 18301 * carries a non-empty USING list. Used by {@link #tryExpandStar} 18302 * to defer bare {@code *} over USING JOIN to S65 (merged-key 18303 * output naming). 18304 */ 18305 private static boolean hasUsingInFromClause(TSelectSqlStatement select) { 18306 if (select.joins == null) return false; 18307 for (int j = 0; j < select.joins.size(); j++) { 18308 TJoin top = select.joins.getJoin(j); 18309 if (top == null) continue; 18310 TJoinItemList items = top.getJoinItems(); 18311 if (items == null) continue; 18312 for (int i = 0; i < items.size(); i++) { 18313 TJoinItem item = items.getJoinItem(i); 18314 if (item != null 18315 && item.getUsingColumns() != null 18316 && item.getUsingColumns().size() > 0) { 18317 return true; 18318 } 18319 } 18320 } 18321 return false; 18322 } 18323 18324 /** 18325 * Slice 66 — true iff any JoinItem is NATURAL AND its inferred 18326 * shared-column list is non-empty (catalog-resolved on both sides 18327 * and the intersection contains at least one column). 18328 * 18329 * <p>Routes bare {@code *} expansion through 18330 * {@link #expandBareStarOverUsing} when NATURAL contributes merged 18331 * keys. NATURAL with empty intersection or with INCOMPLETE_LEFT / 18332 * MISSING_RIGHT / BOTH_MISSING returns false for THIS JoinItem 18333 * (codex slice-66 round-4 P2 #3) — the bare-* path then falls 18334 * through to per-relation expansion, which is correct for an 18335 * empty-intersection NATURAL (Cartesian, no dedup needed). The 18336 * catalog-required reject for NATURAL fires upstream inside 18337 * {@link #buildRelations} before bare-* runs. 18338 * 18339 * <p>The walk maintains its own per-top-level-TJoin 18340 * {@link LeftOutputState} (so NATURAL inference against accumulated 18341 * left works the same way as in {@link #buildUsingScope} and 18342 * {@link #buildRelations}). 18343 */ 18344 private static boolean hasNaturalJoinMergedKeysInFromClause( 18345 TSelectSqlStatement select, NameBindingProvider provider) { 18346 if (select.joins == null) return false; 18347 for (int j = 0; j < select.joins.size(); j++) { 18348 TJoin top = select.joins.getJoin(j); 18349 if (top == null) continue; 18350 TJoinItemList items = top.getJoinItems(); 18351 if (items == null) continue; 18352 LeftOutputState leftState = new LeftOutputState(); 18353 seedLeftOutput(leftState, top.getTable(), provider); 18354 for (int i = 0; i < items.size(); i++) { 18355 TJoinItem item = items.getJoinItem(i); 18356 if (item == null) continue; 18357 TTable rightTable = item.getTable(); 18358 if (rightTable == null) continue; 18359 if (isNaturalJoinType(item.getJoinType())) { 18360 NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider); 18361 if (r.kind == NaturalKeyResult.Kind.SUCCESS 18362 && r.keys != null && !r.keys.isEmpty()) { 18363 return true; 18364 } 18365 appendRightToLeftOutput(leftState, rightTable, provider); 18366 continue; 18367 } 18368 TObjectNameList usingCols = item.getUsingColumns(); 18369 if (usingCols != null && usingCols.size() > 0) { 18370 List<String> usingKeyNames = new ArrayList<>(usingCols.size()); 18371 for (int k = 0; k < usingCols.size(); k++) { 18372 TObjectName usingKey = usingCols.getObjectName(k); 18373 if (usingKey == null) continue; 18374 String keyName = usingKey.getColumnNameOnly(); 18375 if (keyName != null && !keyName.isEmpty()) { 18376 usingKeyNames.add(keyName); 18377 } 18378 } 18379 mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames); 18380 } else { 18381 appendRightToLeftOutput(leftState, rightTable, provider); 18382 } 18383 } 18384 } 18385 return false; 18386 } 18387 18388 /** 18389 * Two relations sharing the same effective alias would make 18390 * {@link ColumnRef#getRelationAlias()} ambiguous in the IR. Resolver2 18391 * may already flag column references in this case, but the IR-level 18392 * invariant still needs to hold. 18393 */ 18394 private static void rejectDuplicateAliases(List<RelationSource> relations) { 18395 Set<String> seen = new HashSet<>(); 18396 for (RelationSource r : relations) { 18397 if (!seen.add(r.getAlias())) { 18398 throw new SemanticIRBuildException( 18399 Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS, 18400 "duplicate relation alias '" + r.getAlias() 18401 + "' is not supported (would make ColumnRef ambiguous)", null)); 18402 } 18403 } 18404 } 18405 18406 private static RelationSource buildRelation(TTable table, NameBindingProvider provider, 18407 boolean allowFromSubqueries) { 18408 return buildRelation(table, provider, allowFromSubqueries, 18409 RelationSource.NO_INSTANCE_ID); 18410 } 18411 18412 private static RelationSource buildRelation(TTable table, NameBindingProvider provider, 18413 boolean allowFromSubqueries, int instanceId) { 18414 // Reject FROM-subqueries when the caller did not extract them as 18415 // separate statements. After slice 18 the still-uncovered scopes 18416 // are scalar bodies (slice-11 boundary), set-op branches (slice-16 18417 // boundary), and set-op CTE bodies (build()'s set-op CTE dispatch 18418 // passes allowFromSubqueries=false to each branch). 18419 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.subquery 18420 && !allowFromSubqueries) { 18421 // Slice 74: use effectiveAliasOf so anonymous subqueries 18422 // surface their synth name in the diagnostic instead of the 18423 // empty-string the prior `getAliasName() == null` ternary 18424 // produced. 18425 String bodyAlias = effectiveAliasOf(table); 18426 throw new SemanticIRBuildException( 18427 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_BODY_CONTEXT_NOT_SUPPORTED, 18428 "FROM-clause subquery '" + (bodyAlias == null || bodyAlias.isEmpty() ? "<anonymous>" : bodyAlias) 18429 + "' inside a scalar body, set-op branch, or set-op CTE body is not supported yet", table)); 18430 } 18431 RelationBinding binding = provider.bindRelation(table); 18432 if (binding == null) { 18433 throw new SemanticIRBuildException( 18434 Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED, 18435 "could not bind table " + safeName(table) + " (only base tables and in-scope CTEs are supported)", table)); 18436 } 18437 // Effective alias: prefer the SQL-written alias, then the slice-74 18438 // synthetic alias for anonymous FROM-subqueries, then the table 18439 // name (mirrors effectiveAliasOf so RelationSource.alias and 18440 // ColumnRef.relationAlias stay aligned). 18441 String alias = effectiveAliasOf(table); 18442 // Slice 164 (S3): attach the FROM-clause table-reference span 18443 // (includes alias, e.g. "t1 a"). Null-safe via SourceSpan.of. 18444 // Slice 179 (R4): carry the stable FROM-order instanceId. 18445 return new RelationSource(alias, binding, SourceSpan.of(table), instanceId); 18446 } 18447 18448 private static String safeName(TTable t) { 18449 try { 18450 return t.getName(); 18451 } catch (RuntimeException e) { 18452 return "<unnamed>"; 18453 } 18454 } 18455 18456 // ----------------------------------------------------------------- 18457 // Slice 167 (join-analysis S6) — ordered, chain-correct structured 18458 // JoinGraph (GAP 1). Walks the same TJoinList the flat join refs come 18459 // from, but stops flattening: each TJoinItem becomes a JoinEntity, and 18460 // comma-separated top-level TJoins become IMPLICIT_CROSS entities. 18461 // 18462 // Left-deep chaining: the right endpoint is always the newly added 18463 // RELATION; the left endpoint is the first RELATION for join order 0, 18464 // else the JOIN_RESULT accumulated by the prior join. No predicate 18465 // decomposition here (that is slice 168). The flat joinColumnRefs are 18466 // untouched. 18467 // ----------------------------------------------------------------- 18468 /** 18469 * R8 — append predicate-derived semi-join entities to a FROM-clause 18470 * {@link JoinGraph}. Each {@link SemiJoinFact} becomes a 18471 * {@link SemanticJoinType#SEMI} / {@link SemanticJoinType#ANTI_SEMI} 18472 * {@link JoinEntity} whose left endpoint is the outer relation owning 18473 * the correlated outer column (resolved from {@code relations} by 18474 * alias; falls back to the first FROM relation) and whose right 18475 * endpoint is the lifted subquery's {@link JoinEndpointKind#SUBQUERY} 18476 * block. Orders continue after the FROM joins. Returns the input graph 18477 * unchanged when there are no facts. 18478 */ 18479 private static JoinGraph appendSemiJoins(JoinGraph base, 18480 List<SemiJoinFact> semiFacts, 18481 List<RelationSource> relations) { 18482 if (semiFacts == null || semiFacts.isEmpty()) { 18483 return base; 18484 } 18485 List<JoinEntity> joins = new ArrayList<>(base.getJoins()); 18486 int order = joins.size(); 18487 for (SemiJoinFact f : semiFacts) { 18488 JoinEndpoint left = outerEndpointFor(f.outerAliases, relations, joins); 18489 if (left == null) { 18490 // No FROM relation to anchor the outer side — skip rather 18491 // than fabricate an endpoint (honest omission). 18492 continue; 18493 } 18494 JoinEndpoint right = JoinEndpoint.subquery(f.innerStatementIndex, f.innerLabel); 18495 joins.add(new JoinEntity(f.polarity, left, right, order++, 18496 JoinSourceSyntax.SEMI, /*natural=*/ false, 18497 /*usingColumns=*/ null, f.conditions, f.span, 18498 f.conditionText, /*lateral=*/ false)); 18499 } 18500 return joins.isEmpty() ? JoinGraph.EMPTY : new JoinGraph(joins); 18501 } 18502 18503 /** 18504 * R8 — left endpoint for a semi-join. When the correlation references a 18505 * single outer relation, that relation is the endpoint. When it 18506 * references several (or none can be matched but FROM joins exist), the 18507 * endpoint is the accumulated outer rowset (a {@link JoinEndpointKind#JOIN_RESULT} 18508 * over the last FROM join, carrying every FROM alias) — codex R8 [P2]: 18509 * a single-relation endpoint would be wrong when the semi-join's 18510 * conditions span multiple outer relations. Falls back to the first 18511 * FROM relation; returns {@code null} only when there is no FROM 18512 * relation to anchor. 18513 */ 18514 private static JoinEndpoint outerEndpointFor(List<String> outerAliases, 18515 List<RelationSource> relations, 18516 List<JoinEntity> priorJoins) { 18517 if (relations == null || relations.isEmpty()) { 18518 return null; 18519 } 18520 // Multiple distinct outer relations → the joined outer rowset. 18521 if (outerAliases != null && countMatchingRelations(outerAliases, relations) > 1 18522 && priorJoins != null && !priorJoins.isEmpty()) { 18523 List<String> allAliases = new ArrayList<>(); 18524 for (RelationSource rs : relations) { 18525 if (rs.getAlias() != null && !rs.getAlias().isEmpty()) { 18526 allAliases.add(rs.getAlias()); 18527 } 18528 } 18529 int lastOrder = priorJoins.get(priorJoins.size() - 1).getOrder(); 18530 return JoinEndpoint.joinResult(lastOrder, allAliases); 18531 } 18532 // Single outer relation: match it by alias, else the first FROM relation. 18533 RelationSource chosen = null; 18534 if (outerAliases != null) { 18535 for (String a : outerAliases) { 18536 for (RelationSource rs : relations) { 18537 if (rs.getAlias() != null && rs.getAlias().equalsIgnoreCase(a)) { 18538 chosen = rs; 18539 break; 18540 } 18541 } 18542 if (chosen != null) break; 18543 } 18544 } 18545 if (chosen == null) { 18546 chosen = relations.get(0); 18547 } 18548 String alias = chosen.getAlias(); 18549 if (alias == null || alias.isEmpty()) { 18550 return null; 18551 } 18552 String qn = chosen.getBinding() == null 18553 ? null : chosen.getBinding().getQualifiedName(); 18554 return JoinEndpoint.relation(alias, qn, chosen.getInstanceId()); 18555 } 18556 18557 /** R8 — count how many FROM relations are named by {@code outerAliases}. */ 18558 private static int countMatchingRelations(List<String> outerAliases, 18559 List<RelationSource> relations) { 18560 int n = 0; 18561 for (RelationSource rs : relations) { 18562 if (rs.getAlias() == null) continue; 18563 for (String a : outerAliases) { 18564 if (rs.getAlias().equalsIgnoreCase(a)) { 18565 n++; 18566 break; 18567 } 18568 } 18569 } 18570 return n; 18571 } 18572 18573 private static JoinGraph buildJoinGraph(TSelectSqlStatement select, 18574 NameBindingProvider provider) { 18575 if (select == null || select.getJoins() == null || select.getJoins().size() == 0) { 18576 return JoinGraph.EMPTY; 18577 } 18578 List<JoinEntity> entities = new ArrayList<JoinEntity>(); 18579 JoinEndpoint currentLeft = null; 18580 List<String> accumulated = new ArrayList<String>(); 18581 int order = 0; 18582 boolean firstRelationSeen = false; 18583 // Slice 179 (R4): same FROM-order ordinal as buildRelations (driver 18584 // first, then each join-item right table) so endpoint instanceIds 18585 // align with RelationSource instanceIds by construction. 18586 int relInstanceId = 0; 18587 18588 for (int j = 0; j < select.getJoins().size(); j++) { 18589 TJoin topJoin = select.getJoins().getJoin(j); 18590 if (topJoin == null) continue; 18591 TTable driver = topJoin.getTable(); 18592 if (driver != null) { 18593 String dAlias = effectiveAliasOf(driver); 18594 String dQn = relationQnOf(driver); 18595 if (!firstRelationSeen) { 18596 currentLeft = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId++); 18597 accumulated.add(currentLeft.getAlias()); 18598 firstRelationSeen = true; 18599 } else { 18600 // Comma-FROM: implicit cross between the accumulated 18601 // left result and this new top-level relation. A 18602 // `FROM m, LATERAL (...)` operand is a lateral cross — mark 18603 // the entity lateral (and surface LATERAL syntax) so a 18604 // consumer does not flag it as an accidental cartesian. 18605 boolean driverLateral = isLateralTable(driver); 18606 JoinEndpoint right = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId++); 18607 entities.add(new JoinEntity(SemanticJoinType.IMPLICIT_CROSS, 18608 currentLeft, right, order, 18609 driverLateral ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.COMMA, 18610 false, null, null, SourceSpan.of(driver), null, driverLateral)); 18611 accumulated.add(right.getAlias()); 18612 currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated)); 18613 order++; 18614 } 18615 } else if (topJoin.getJoin() != null) { 18616 // Parenthesized joined-table as the top-level driver operand, 18617 // e.g. `FROM (a JOIN b ON …) JOIN c ON …`. Recurse to emit the 18618 // sub-tree's entities and obtain its result endpoint, then treat 18619 // it as this position's left input (or comma-cross partner). 18620 int[] o = {order}; 18621 int[] rid = {relInstanceId}; 18622 JoinEndpoint sub = buildJoinGraphForJoin(topJoin.getJoin(), provider, entities, o, rid); 18623 order = o[0]; 18624 relInstanceId = rid[0]; 18625 if (sub == null) return JoinGraph.EMPTY; 18626 if (!firstRelationSeen) { 18627 currentLeft = sub; 18628 accumulated.addAll(aliasesOf(sub)); 18629 firstRelationSeen = true; 18630 } else { 18631 entities.add(new JoinEntity(SemanticJoinType.IMPLICIT_CROSS, 18632 currentLeft, sub, order, JoinSourceSyntax.COMMA, 18633 false, null, null, SourceSpan.of(topJoin), null)); 18634 accumulated.addAll(aliasesOf(sub)); 18635 currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated)); 18636 order++; 18637 } 18638 } 18639 TJoinItemList items = topJoin.getJoinItems(); 18640 if (items == null) continue; 18641 for (int i = 0; i < items.size(); i++) { 18642 TJoinItem item = items.getJoinItem(i); 18643 if (item == null) continue; 18644 TTable right = item.getTable(); 18645 JoinEndpoint rightEp; 18646 if (right != null) { 18647 String rAlias = effectiveAliasOf(right); 18648 String rQn = relationQnOf(right); 18649 rightEp = JoinEndpoint.relation(safeAlias(rAlias, rQn), rQn, relInstanceId++); 18650 } else if (item.getJoin() != null) { 18651 // Parenthesized joined-table as the right operand, 18652 // e.g. `FROM a JOIN (b JOIN c ON …) ON …`. Recurse to emit 18653 // the sub-tree's inner entities and use its result endpoint 18654 // as the right side of this outer join. 18655 int[] o = {order}; 18656 int[] rid = {relInstanceId}; 18657 rightEp = buildJoinGraphForJoin(item.getJoin(), provider, entities, o, rid); 18658 order = o[0]; 18659 relInstanceId = rid[0]; 18660 if (rightEp == null) return JoinGraph.EMPTY; 18661 } else { 18662 // Genuinely neither a table nor a nested join — skip safely 18663 // (no endpoint guesswork). 18664 continue; 18665 } 18666 if (currentLeft == null) { 18667 // A join item with no established left input — never guess a 18668 // left endpoint; surface the graph as empty. 18669 return JoinGraph.EMPTY; 18670 } 18671 SemanticJoinType jt = mapSemanticJoinType(item.getJoinType()); 18672 boolean natural = isNaturalJoinType(item.getJoinType()); 18673 // Lateral via the APPLY join type (SQL Server) OR a LATERAL 18674 // right operand (ANSI / PostgreSQL `CROSS JOIN LATERAL (...)`, 18675 // `JOIN LATERAL (...) ON ...`, `LEFT JOIN LATERAL (...) ON ...`). 18676 boolean lateral = isLateralJoinType(item.getJoinType()) 18677 || isLateralTable(item.getTable()); 18678 List<String> using = usingColumnNames(item); 18679 // Slice 168 (S7): decompose this join's ON condition into 18680 // resolved predicate trees and attach them to the entity. 18681 // CROSS/OUTER APPLY carry no ON (correlation is inside the 18682 // right operand), so onPredicates stays null and conditions 18683 // stay empty — the correlation is preserved by the child 18684 // StatementGraph the right derived table produces. 18685 List<Predicate> onPredicates = (item.getOnCondition() != null) 18686 ? PredicateTreeBuilder.build(item.getOnCondition(), provider) 18687 : null; 18688 // Slice 177 (R1): verbatim ON-condition text (null when no ON). 18689 String conditionText = verbatimText(item.getOnCondition()); 18690 // A condition-less lateral join (APPLY, CROSS JOIN LATERAL) is 18691 // self-described by sourceSyntax=LATERAL plus the isLateral flag 18692 // so a consumer never mistakes its empty condition for a buggy 18693 // cartesian INNER. A lateral join that DOES carry an explicit 18694 // condition (`JOIN LATERAL ... ON`, or a USING/NATURAL form) 18695 // keeps EXPLICIT syntax — the user wrote an explicit join 18696 // condition; its laterality rides on the orthogonal isLateral 18697 // flag only. 18698 boolean hasExplicitCondition = item.getOnCondition() != null 18699 || (using != null && !using.isEmpty()) || natural; 18700 JoinSourceSyntax syntax = (lateral && !hasExplicitCondition) 18701 ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.EXPLICIT; 18702 // R7: a USING join's TJoinItem ends at the last USING column 18703 // name, not the closing ')', so SourceSpan.of(item) drops the 18704 // ')'. Extend the span end to one-past the closing ')' for 18705 // USING joins. ON / NATURAL / CROSS items already reach their 18706 // true clause end, so they keep SourceSpan.of(item). 18707 SourceSpan joinSpan = usingJoinSpan(item); 18708 entities.add(new JoinEntity(jt, currentLeft, rightEp, order, 18709 syntax, natural, using, onPredicates, 18710 joinSpan, conditionText, lateral)); 18711 accumulated.addAll(aliasesOf(rightEp)); 18712 currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated)); 18713 order++; 18714 } 18715 } 18716 return new JoinGraph(entities); 18717 } 18718 18719 /** 18720 * Recursive join-graph builder for a parenthesized / nested 18721 * {@code <joined_table>} sub-tree (a {@link TJoin} reached via 18722 * {@link TJoinItem#getJoin()} or {@link TJoin#getJoin()}). Emits the 18723 * sub-tree's {@link JoinEntity}s into {@code entities}, advances the shared 18724 * {@code order} and {@code relInstanceId} counters (one-element arrays so the 18725 * caller sees the updates), and returns the sub-tree's result endpoint to be 18726 * used as a left/right endpoint of the enclosing join. Returns {@code null} 18727 * when an operand is genuinely neither a table nor a nested join, so the 18728 * caller surfaces the graph as empty rather than guessing. 18729 * 18730 * <p>Traversal order (driver first, then items left-to-right, recursing 18731 * inline) is identical to {@link #appendJoinRelations}, so endpoint 18732 * instanceIds align with RelationSource instanceIds by construction. 18733 */ 18734 private static JoinEndpoint buildJoinGraphForJoin(TJoin join, NameBindingProvider provider, 18735 List<JoinEntity> entities, int[] order, int[] relInstanceId) { 18736 if (join == null) return null; 18737 JoinEndpoint currentLeft; 18738 List<String> accumulated = new ArrayList<String>(); 18739 TTable driver = join.getTable(); 18740 if (driver != null) { 18741 String dAlias = effectiveAliasOf(driver); 18742 String dQn = relationQnOf(driver); 18743 currentLeft = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId[0]++); 18744 accumulated.add(currentLeft.getAlias()); 18745 } else if (join.getJoin() != null) { 18746 currentLeft = buildJoinGraphForJoin(join.getJoin(), provider, entities, order, relInstanceId); 18747 if (currentLeft == null) return null; 18748 accumulated.addAll(aliasesOf(currentLeft)); 18749 } else { 18750 return null; 18751 } 18752 TJoinItemList items = join.getJoinItems(); 18753 for (int i = 0; items != null && i < items.size(); i++) { 18754 TJoinItem item = items.getJoinItem(i); 18755 if (item == null) continue; 18756 JoinEndpoint rightEp; 18757 TTable right = item.getTable(); 18758 if (right != null) { 18759 String rAlias = effectiveAliasOf(right); 18760 String rQn = relationQnOf(right); 18761 rightEp = JoinEndpoint.relation(safeAlias(rAlias, rQn), rQn, relInstanceId[0]++); 18762 } else if (item.getJoin() != null) { 18763 rightEp = buildJoinGraphForJoin(item.getJoin(), provider, entities, order, relInstanceId); 18764 if (rightEp == null) return null; 18765 } else { 18766 return null; 18767 } 18768 SemanticJoinType jt = mapSemanticJoinType(item.getJoinType()); 18769 boolean natural = isNaturalJoinType(item.getJoinType()); 18770 boolean lateral = isLateralJoinType(item.getJoinType()) 18771 || isLateralTable(item.getTable()); 18772 List<String> using = usingColumnNames(item); 18773 List<Predicate> onPredicates = (item.getOnCondition() != null) 18774 ? PredicateTreeBuilder.build(item.getOnCondition(), provider) 18775 : null; 18776 String conditionText = verbatimText(item.getOnCondition()); 18777 boolean hasExplicitCondition = item.getOnCondition() != null 18778 || (using != null && !using.isEmpty()) || natural; 18779 JoinSourceSyntax syntax = (lateral && !hasExplicitCondition) 18780 ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.EXPLICIT; 18781 SourceSpan joinSpan = usingJoinSpan(item); 18782 entities.add(new JoinEntity(jt, currentLeft, rightEp, order[0], 18783 syntax, natural, using, onPredicates, 18784 joinSpan, conditionText, lateral)); 18785 accumulated.addAll(aliasesOf(rightEp)); 18786 currentLeft = JoinEndpoint.joinResult(order[0], new ArrayList<String>(accumulated)); 18787 order[0]++; 18788 } 18789 return currentLeft; 18790 } 18791 18792 /** 18793 * FROM-order aliases contributed by a join endpoint: the full contributing 18794 * list for a join result, or the single alias for a relation / subquery 18795 * endpoint. Used to extend the accumulated alias list when a nested join 18796 * sub-tree is folded into an enclosing join. 18797 */ 18798 private static List<String> aliasesOf(JoinEndpoint ep) { 18799 if (ep == null) return new ArrayList<String>(); 18800 List<String> contrib = ep.getContributingAliases(); 18801 if (contrib != null && !contrib.isEmpty()) { 18802 return new ArrayList<String>(contrib); 18803 } 18804 List<String> single = new ArrayList<String>(); 18805 if (ep.getAlias() != null && !ep.getAlias().isEmpty()) { 18806 single.add(ep.getAlias()); 18807 } 18808 return single; 18809 } 18810 18811 /** 18812 * R7 — compute the {@link SourceSpan} for a join entity, extending a 18813 * {@code USING (...)} join's span to include the closing {@code ')'}. 18814 * 18815 * <p>For a {@code USING} join the {@link TJoinItem}'s end token is the 18816 * last USING column name (e.g. {@code k} in {@code USING (id, k)}), so 18817 * {@link SourceSpan#of(TParseTreeNode)} ends one token early and the 18818 * slice drops the trailing {@code ')'}. Here we advance the span end to 18819 * one-past the closing {@code ')'} that follows the USING column list. 18820 * 18821 * <p>ON / NATURAL / CROSS / APPLY items already reach their true clause 18822 * end (the ON condition expression reaches the clause end; the others 18823 * carry no parenthesised key list), so they fall through to the plain 18824 * {@code SourceSpan.of(item)}. 18825 */ 18826 private static SourceSpan usingJoinSpan(TJoinItem item) { 18827 if (item == null) return null; 18828 TObjectNameList using = item.getUsingColumns(); 18829 TSourceToken start = item.getStartToken(); 18830 if (using == null || using.size() == 0 || start == null) { 18831 return SourceSpan.of(item); 18832 } 18833 TSourceToken end = using.getEndToken(); 18834 if (end == null) { 18835 return SourceSpan.of(item); 18836 } 18837 // Advance to the closing ')' that follows the USING column list. The 18838 // list's end token is the last column name; the next solid token is 18839 // the ')'. Scan a small bounded window so a stray whitespace/comment 18840 // layout never wedges the walk, and never run past it. 18841 if (!")".equals(end.getAstext())) { 18842 TSourceToken t = end.nextSolidToken(); 18843 int guard = 0; 18844 while (t != null && guard++ < 8 && !")".equals(t.getAstext())) { 18845 t = t.nextSolidToken(); 18846 } 18847 if (t != null && ")".equals(t.getAstext())) { 18848 end = t; 18849 } 18850 } 18851 return SourceSpan.of(start, end); 18852 } 18853 18854 /** 18855 * Slice 177 (R1) — reconstruct the verbatim source substring spanned by 18856 * a node, by concatenating the token chain from start to end WITHOUT 18857 * inserting separators (so original whitespace / comments are 18858 * preserved). Returns null when the node or its boundary tokens are 18859 * null. Used to populate {@code JoinEntity.conditionText} so consumers 18860 * don't re-implement span-slicing for the common "show me the ON text" 18861 * case. 18862 */ 18863 private static String verbatimText(TParseTreeNode node) { 18864 if (node == null) return null; 18865 TSourceToken s = node.getStartToken(); 18866 TSourceToken e = node.getEndToken(); 18867 if (s == null || e == null) return null; 18868 StringBuilder sb = new StringBuilder(); 18869 TSourceToken c = s; 18870 int guard = 0; 18871 boolean reachedEnd = false; 18872 while (c != null && guard++ < 2_000_000) { 18873 if (c.astext != null) sb.append(c.astext); 18874 if (c == e) { 18875 reachedEnd = true; 18876 break; 18877 } 18878 c = c.getNextTokenInChain(); 18879 } 18880 // If the end token was not reachable from the start (or the guard 18881 // tripped), the accumulated prefix would be truncated/unrelated SQL. 18882 // Return null (unavailable) rather than silently-wrong verbatim text. 18883 if (!reachedEnd) return null; 18884 return sb.length() == 0 ? null : sb.toString(); 18885 } 18886 18887 /** Endpoint alias: the effective alias, falling back to the qualified name. */ 18888 private static String safeAlias(String alias, String qn) { 18889 if (alias != null && !alias.isEmpty()) return alias; 18890 if (qn != null && !qn.isEmpty()) return qn; 18891 return "<anonymous>"; 18892 } 18893 18894 /** Bare relation name for an endpoint's qualifiedName; null when unavailable. */ 18895 private static String relationQnOf(TTable t) { 18896 if (t == null) return null; 18897 TObjectName n = t.getTableName(); 18898 if (n != null) { 18899 String s = n.toString(); 18900 if (s != null && !s.isEmpty()) return s; 18901 } 18902 return null; 18903 } 18904 18905 /** USING column names (empty for non-USING joins). */ 18906 private static List<String> usingColumnNames(TJoinItem item) { 18907 List<String> out = new ArrayList<String>(); 18908 if (item.getUsingColumns() != null) { 18909 for (int u = 0; u < item.getUsingColumns().size(); u++) { 18910 TObjectName c = item.getUsingColumns().getObjectName(u); 18911 if (c != null) { 18912 String name = c.getColumnNameOnly(); 18913 if (name == null || name.isEmpty()) name = c.toString(); 18914 if (name != null && !name.isEmpty()) out.add(name); 18915 } 18916 } 18917 } 18918 return out; 18919 } 18920 18921 /** 18922 * Map the parser's {@link EJoinType} to the stable 18923 * {@link SemanticJoinType}. NATURAL variants map to the underlying 18924 * join type ({@code natural_left} -> LEFT); the NATURAL semantics 18925 * are carried by {@code JoinEntity.isNatural()}. Bare {@code natural} 18926 * maps to {@link SemanticJoinType#NATURAL}. {@code crossapply} / 18927 * {@code outerapply} degrade to INNER / LEFT with the lateral nature 18928 * carried by {@code JoinEntity.isLateral()}. Shapes the semantic 18929 * builder does not admit (nested / semi / anti / vendor extensions) 18930 * map to {@link SemanticJoinType#UNSUPPORTED} — never guessed. 18931 */ 18932 private static SemanticJoinType mapSemanticJoinType(EJoinType jt) { 18933 if (jt == null) return SemanticJoinType.UNSUPPORTED; 18934 switch (jt) { 18935 case inner: 18936 case join: 18937 case straight: 18938 case natural_inner: 18939 return SemanticJoinType.INNER; 18940 case left: 18941 case leftouter: 18942 case natural_left: 18943 case natural_leftouter: 18944 return SemanticJoinType.LEFT; 18945 case right: 18946 case rightouter: 18947 case natural_right: 18948 case natural_rightouter: 18949 return SemanticJoinType.RIGHT; 18950 case full: 18951 case fullouter: 18952 case natural_full: 18953 case natural_fullouter: 18954 return SemanticJoinType.FULL; 18955 case cross: 18956 return SemanticJoinType.CROSS; 18957 case crossapply: 18958 // SQL Server CROSS APPLY: inner lateral join (drops left 18959 // rows with no right match). Lateral nature carried by 18960 // JoinEntity.isLateral(). 18961 return SemanticJoinType.INNER; 18962 case outerapply: 18963 // SQL Server OUTER APPLY: left lateral join (keeps left 18964 // rows, right side nullable). 18965 return SemanticJoinType.LEFT; 18966 case natural: 18967 return SemanticJoinType.NATURAL; 18968 default: 18969 return SemanticJoinType.UNSUPPORTED; 18970 } 18971 } 18972 18973 // ----------------------------------------------------------------- 18974 // Slice 58 / 59 — catalog-backed SELECT * expansion. 18975 // 18976 // The hook in buildOutputColumns calls tryExpandStar(rc, select, 18977 // provider, isPredicateBody, stmtName) for any result column whose 18978 // columnNameOnly is "*" (and as defense in depth for any 18979 // EExpressionType.list_t expression). tryExpandStar returns a 18980 // StarExpansionResult that is either EXPANDED (with a list of 18981 // OutputColumns) or one of several reasoned rejection kinds. The 18982 // hook then either appends the expanded columns or throws a 18983 // structured SemanticIRBuildException whose message is unique per 18984 // kind so external callers can pattern-match without parsing a 18985 // generic "not supported yet" string. 18986 // 18987 // Scope (slice 58): 18988 // - single base-table FROM (1 join, no join items) 18989 // - bare `*` or qualified `t.*` 18990 // - catalog provided via NameBindingProvider#getRelationColumnNames 18991 // 18992 // Slice 59 lift: 18993 // - multi-relation FROM is now supported when a single top-level 18994 // TJoin carries one or more explicit JOIN clauses (joinItems). 18995 // Each FROM relation must individually satisfy slice-58 rules 18996 // (binding kind TABLE, catalog declares columns). Bare `*` 18997 // concatenates per-relation expansions in FROM order; qualified 18998 // `t.*` selects the one relation whose effective alias matches. 18999 // - qualifier matching is now effective-alias only 19000 // (alias if present, else table name) — case-insensitive. This 19001 // unifies the rule across single- and multi-relation paths; 19002 // `SELECT employees.* FROM employees e` rejects because the 19003 // effective alias is `e`, not `employees`. 19004 // - star expansion is rejected inside synthetic body contexts 19005 // (scalar-subquery / set-op-branch / predicate-subquery) via 19006 // SYNTHETIC_BODY_CONTEXT; the slice-58 path silently allowed 19007 // this for catalog-equipped builds even though a multi-column 19008 // expansion would corrupt scalar-body shape downstream. 19009 // 19010 // Slice 60 lift: 19011 // - CTE star and FROM-subquery star (`a.*` and bare `*` over a CTE 19012 // or FROM-clause subquery alias) are now supported via the 19013 // in-scope-relation-columns map carried on the provider. The 19014 // map is populated at each consuming-SELECT call site in build() 19015 // and extractFromSubqueriesAsStatements before the consumer's 19016 // buildOutputColumns runs; tryExpandStar reads it for CTE / 19017 // SUBQUERY bindings. Explicit CTE column lists 19018 // (`WITH a(x, y) AS ...`) stay rejected because the CTE body's 19019 // StatementGraph publishes inner-projection names, not the 19020 // explicit list, and emitLineageForStatement would point at 19021 // non-existent body outputs. Lifting that path needs either 19022 // body-output renaming or a published-name → body-name lineage 19023 // map; deferred to a future slice. 19024 // 19025 // Slice 62 lift: 19026 // - Comma-FROM (multiple top-level TJoin elements parsed from 19027 // `FROM a, b, c`) is now admitted at the outer / CTE-body / 19028 // FROM-subquery-body call sites. {@link #tryExpandStar} walks 19029 // every top-level TJoin and accumulates relations in FROM 19030 // order; bare `*` concatenates per-relation expansions and 19031 // qualified `t.*` selects the matching effective-alias. 19032 // Synthetic body contexts (scalar / set-op-branch / set-op-CTE 19033 // / predicate) still reject comma-FROM via the gated reject 19034 // in buildRelations and the slice-62 reject inside 19035 // preflightExistsInnerShape. 19036 // 19037 // Out of scope (slice 60+): 19038 // - SELECT * EXCEPT/REPLACE (BigQuery extensions; no slice scheduled) 19039 // - Explicit CTE column list star expansion (slice 61+) 19040 // ----------------------------------------------------------------- 19041 19042 enum StarExpansionKind { 19043 EXPANDED, 19044 PREDICATE_BODY_GUARD, 19045 // Defensive catch-all for malformed FROM lists: missing top-level 19046 // TJoin, null table on a top-level TJoin or a join item, or 19047 // empty {@code select.joins}. Slice 62 made comma-FROM admit 19048 // here (the walk iterates every top-level TJoin), so reaching 19049 // this kind indicates a parse-tree anomaly rather than a comma- 19050 // FROM rejection. 19051 MULTI_RELATION_FROM, 19052 NON_BASE_TABLE_RELATION, 19053 QUALIFIER_NOT_FOUND, 19054 // Slice 59: a qualifier matches 2+ relations (case-insensitive 19055 // effective-alias collision). Real SQL never reaches this case 19056 // unless `rejectDuplicateAliases` permitted a case-only collision 19057 // (it is case-sensitive at SemanticIRBuilder.java:5621). 19058 QUALIFIER_AMBIGUOUS, 19059 NO_CATALOG_OR_UNKNOWN_TABLE, 19060 // Slice 59: star expansion in synthetic body contexts 19061 // (scalar-subquery, set-op-branch, predicate-subquery) is rejected 19062 // because multi-column expansion would violate the body's shape 19063 // contract (e.g. scalar bodies must project exactly one column). 19064 SYNTHETIC_BODY_CONTEXT, 19065 // Slice 60: CTE has an explicit column list 19066 // (`WITH a(x, y) AS ...`). Deferred to a future slice because 19067 // the CTE body's StatementGraph publishes inner-projection 19068 // names, not the explicit list, and lineage emission cannot 19069 // bridge that without either body-output renaming or a 19070 // published-name → body-name map. 19071 EXPLICIT_CTE_COLUMN_LIST_DEFERRED, 19072 // Slice 60: CTE / SUBQUERY binding's published-column map 19073 // lookup returned null or empty. This indicates a builder 19074 // invariant failure (the body should have been built and 19075 // registered before the consumer's buildOutputColumns runs); 19076 // user SQL cannot reach this kind under normal builds — only 19077 // fabricated providers or a missed plumbing path would. The 19078 // diagnostic names the binding kind and qualified name so 19079 // regressions are loud, not silent. 19080 NO_INSCOPE_RELATION_COLUMNS 19081 } 19082 19083 static final class StarExpansionResult { 19084 final StarExpansionKind kind; 19085 final List<OutputColumn> columns; 19086 final String qualifier; 19087 final String detail; 19088 19089 private StarExpansionResult(StarExpansionKind kind, 19090 List<OutputColumn> columns, 19091 String qualifier, 19092 String detail) { 19093 this.kind = kind; 19094 this.columns = columns; 19095 this.qualifier = qualifier; 19096 this.detail = detail; 19097 } 19098 19099 static StarExpansionResult expanded(List<OutputColumn> cols) { 19100 return new StarExpansionResult(StarExpansionKind.EXPANDED, cols, null, null); 19101 } 19102 19103 static StarExpansionResult reject(StarExpansionKind kind) { 19104 return new StarExpansionResult(kind, null, null, null); 19105 } 19106 19107 static StarExpansionResult reject(StarExpansionKind kind, String qualifier, String detail) { 19108 return new StarExpansionResult(kind, null, qualifier, detail); 19109 } 19110 } 19111 19112 /** 19113 * Effective alias for a FROM-clause {@link TTable}: the SQL-written 19114 * alias if present, else the slice-74 synthetic alias for unaliased 19115 * FROM-subquery TTables (position-keyed via 19116 * {@link FromSubqueryNaming#synthAliasFor}), else the table name. 19117 * Mirrors the rule used by {@link #buildRelation} so 19118 * {@link ColumnRef#getRelationAlias()} stays aligned with what the 19119 * lineage emitter expects. 19120 */ 19121 private static String effectiveAliasOf(TTable t) { 19122 if (t == null) return null; 19123 String alias = t.getAliasName(); 19124 if (alias != null && !alias.isEmpty()) return alias; 19125 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 19126 return FromSubqueryNaming.synthAliasFor(t); 19127 } 19128 return t.getName(); 19129 } 19130 19131 /** 19132 * Slice 58 / 59 — attempt to expand a {@code SELECT *} or 19133 * {@code SELECT alias.*} result column using the catalog exposed via 19134 * {@link NameBindingProvider#getRelationColumnNames(TTable)}. 19135 * 19136 * <p>Slice 58 supported only single-base-table FROM. Slice 59 lifts 19137 * the multi-relation case to JOIN forms (single top-level TJoin with 19138 * explicit JOIN clauses). Comma-FROM stays rejected by 19139 * {@code buildRelations}. 19140 * 19141 * <p>Returns {@link StarExpansionKind#EXPANDED} with one 19142 * {@link OutputColumn} per catalog-declared column on success. 19143 * Otherwise returns a reasoned rejection so the caller can throw a 19144 * shape-specific {@link SemanticIRBuildException}. 19145 */ 19146 /** 19147 * True when some FROM operand of {@code select} is a parenthesized / nested 19148 * {@code <joined_table>}: a {@link TJoin} reached via {@link TJoin#getJoin()} 19149 * (nested driver) or {@link TJoinItem#getJoin()} (nested right operand) whose 19150 * {@code getTable()} is null. Used to scope the MULTI_RELATION_FROM 19151 * star-expansion degrade to the genuinely-analyzable nested-join shape — a 19152 * null FROM table WITHOUT a nested join, or a null top-level {@link TJoin}, 19153 * is a broken invariant and stays fatal. 19154 */ 19155 private static boolean hasNestedJoinOperand(TSelectSqlStatement select) { 19156 if (select == null || select.joins == null) { 19157 return false; 19158 } 19159 for (int j = 0; j < select.joins.size(); j++) { 19160 TJoin topJoin = select.joins.getJoin(j); 19161 if (topJoin == null) { 19162 continue; 19163 } 19164 if (topJoin.getTable() == null && topJoin.getJoin() != null) { 19165 return true; 19166 } 19167 TJoinItemList items = topJoin.getJoinItems(); 19168 for (int i = 0; items != null && i < items.size(); i++) { 19169 TJoinItem item = items.getJoinItem(i); 19170 if (item != null && item.getTable() == null && item.getJoin() != null) { 19171 return true; 19172 } 19173 } 19174 } 19175 return false; 19176 } 19177 19178 private static StarExpansionResult tryExpandStar(TResultColumn rc, 19179 TSelectSqlStatement select, 19180 NameBindingProvider provider, 19181 boolean isPredicateBody, 19182 String stmtName) { 19183 if (isPredicateBody) { 19184 // Defensive: the active rejection lives in 19185 // preflightExistsInnerShape (~line 3880) and fires before 19186 // this code runs. Slice-24 EXISTS-with-* tests pin that path. 19187 return StarExpansionResult.reject(StarExpansionKind.PREDICATE_BODY_GUARD); 19188 } 19189 // Slice 59: reject star expansion in synthetic body contexts. 19190 // Scalar-subquery bodies must project exactly one column; 19191 // set-op-branch bodies must keep per-branch column-count parity; 19192 // predicate-subquery bodies are constant or column-ref shapes 19193 // (slice 23/24/27). Multi-column expansion would corrupt all 19194 // three. The preflight at SemanticIRBuilder.java:1007 only 19195 // checks AST result-column count/name, not "*", so without this 19196 // guard a catalog-equipped scalar body `SELECT * FROM small` 19197 // would silently emit multiple OutputColumns. 19198 if (stmtName != null 19199 && (isScalarSyntheticName(stmtName) 19200 || isSetOpBranchSyntheticName(stmtName) 19201 || isPredicateSubquerySyntheticName(stmtName))) { 19202 return StarExpansionResult.reject( 19203 StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null, 19204 "star expansion is not supported inside synthetic body '" 19205 + stmtName + "' (scalar, set-op branch, or predicate body)"); 19206 } 19207 // Extract qualifier (empty string for bare `*`, alias/name for `t.*`). 19208 String qualifier = ""; 19209 TExpression expr = rc.getExpr(); 19210 if (expr != null && expr.getObjectOperand() != null) { 19211 String q = expr.getObjectOperand().getTableString(); 19212 if (q != null && !q.isEmpty()) { 19213 qualifier = q; 19214 } 19215 } 19216 // FROM-clause shape gate. Slice 59 supported a single top-level 19217 // TJoin with zero or more explicit JOIN clauses. Slice 62 lifts 19218 // comma-FROM to multi-TJoin: walk every top-level TJoin in 19219 // {@code select.joins} (a comma-FROM list parses as multiple 19220 // top-level TJoins) and accumulate every relation in FROM order. 19221 if (select.joins == null || select.joins.size() == 0) { 19222 return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM); 19223 } 19224 List<TTable> fromRelations = new ArrayList<>(); 19225 for (int j = 0; j < select.joins.size(); j++) { 19226 TJoin topJoin = select.joins.getJoin(j); 19227 if (topJoin == null) { 19228 return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM); 19229 } 19230 TTable leftTable = topJoin.getTable(); 19231 if (leftTable == null) { 19232 return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM); 19233 } 19234 fromRelations.add(leftTable); 19235 TJoinItemList items = topJoin.getJoinItems(); 19236 if (items == null) continue; 19237 for (int i = 0; i < items.size(); i++) { 19238 TJoinItem item = items.getJoinItem(i); 19239 TTable rightTable = item.getTable(); 19240 if (rightTable == null) { 19241 return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM); 19242 } 19243 fromRelations.add(rightTable); 19244 } 19245 } 19246 // Slice 65 / 66: bare `*` over a USING / NATURAL JOIN collapses 19247 // merged keys. For each FROM relation in order, walk catalog/ 19248 // in-scope columns; emit one OutputColumn per merged key (sources 19249 // = merged ref list) and one per non-merged column. Qualified 19250 // `t.*` is unaffected (single-relation path, no merged-key dedup). 19251 if (qualifier.isEmpty() 19252 && (hasUsingInFromClause(select) 19253 || hasNaturalJoinMergedKeysInFromClause(select, provider))) { 19254 return expandBareStarOverUsing(select, provider, fromRelations); 19255 } 19256 // Qualified `t.*`: pick the (unique) FROM relation whose 19257 // effective alias matches the qualifier (case-insensitive). 19258 // Effective alias = `alias != null && !alias.isEmpty() ? alias : 19259 // tableName`, matching buildRelation at line 5649. Slice 58's 19260 // alias-OR-name match (line 5785 before slice 59) is replaced 19261 // here so `SELECT employees.* FROM employees e` rejects 19262 // (qualifier=`employees` ≠ effective alias `e`), consistent 19263 // with standard SQL correlation-name semantics. 19264 if (!qualifier.isEmpty()) { 19265 List<TTable> matches = new ArrayList<>(); 19266 for (TTable t : fromRelations) { 19267 String ea = effectiveAliasOf(t); 19268 if (ea != null && ea.equalsIgnoreCase(qualifier)) { 19269 matches.add(t); 19270 } 19271 } 19272 if (matches.isEmpty()) { 19273 return StarExpansionResult.reject( 19274 StarExpansionKind.QUALIFIER_NOT_FOUND, qualifier, null); 19275 } 19276 if (matches.size() > 1) { 19277 StringBuilder names = new StringBuilder(); 19278 for (int i = 0; i < matches.size(); i++) { 19279 if (i > 0) names.append(", "); 19280 names.append(effectiveAliasOf(matches.get(i))); 19281 } 19282 return StarExpansionResult.reject( 19283 StarExpansionKind.QUALIFIER_AMBIGUOUS, qualifier, 19284 "matches " + matches.size() + " FROM-clause relations: " 19285 + names); 19286 } 19287 return expandSingleRelation(matches.get(0), provider, qualifier); 19288 } 19289 // Bare `*`: expand every FROM relation in order. Fail fast on 19290 // the first relation that does not satisfy the slice-58 rules 19291 // (binding kind TABLE, catalog declares columns); the caller 19292 // sees the per-relation rejection kind and detail. No partial 19293 // outputs are returned. 19294 List<OutputColumn> all = new ArrayList<>(); 19295 for (TTable t : fromRelations) { 19296 StarExpansionResult one = expandSingleRelation(t, provider, ""); 19297 if (one.kind != StarExpansionKind.EXPANDED) { 19298 return one; 19299 } 19300 all.addAll(one.columns); 19301 } 19302 return StarExpansionResult.expanded(all); 19303 } 19304 19305 /** 19306 * Slice 58 / 59 — pure per-relation star expander. Applies the 19307 * base-table-only + catalog rules and builds one 19308 * {@link OutputColumn} per catalog-declared column with a 19309 * {@link ColumnRef} whose {@code relationAlias} is the effective 19310 * alias of {@code target}. Returns {@link StarExpansionKind#EXPANDED} 19311 * on success, otherwise a tuned rejection. 19312 * 19313 * <p>The {@code qualifier} parameter is the SQL-written qualifier 19314 * for qualified `t.*` (empty for bare `*`); it is plumbed back into 19315 * the rejection result so the caller can include it in 19316 * user-visible diagnostics. 19317 */ 19318 private static StarExpansionResult expandSingleRelation(TTable target, 19319 NameBindingProvider provider, 19320 String qualifier) { 19321 // The TTable's tableType cannot distinguish CTE from base table — 19322 // CTE references arrive as ETableSource.objectname. Use the 19323 // provider's bindRelation to get the resolved RelationKind. 19324 RelationBinding binding = provider.bindRelation(target); 19325 if (binding == null) { 19326 String diagAlias = effectiveAliasOf(target); 19327 return StarExpansionResult.reject( 19328 StarExpansionKind.NON_BASE_TABLE_RELATION, qualifier, 19329 "FROM source '" 19330 + (diagAlias != null ? diagAlias : "<unnamed>") 19331 + "' could not be bound (only base tables, in-scope CTEs, and FROM-subqueries are supported)"); 19332 } 19333 RelationKind kind = binding.getKind(); 19334 if (kind == RelationKind.TABLE) { 19335 // Slice 58 catalog-backed path. Returns null when no 19336 // catalog, when the catalog doesn't declare this table, 19337 // or when the table has no columns. 19338 List<String> columnNames = provider.getRelationColumnNames(target); 19339 if (columnNames == null || columnNames.isEmpty()) { 19340 String diagAlias = effectiveAliasOf(target); 19341 return StarExpansionResult.reject( 19342 StarExpansionKind.NO_CATALOG_OR_UNKNOWN_TABLE, qualifier, 19343 diagAlias); 19344 } 19345 return buildExpansionFromColumnNames(target, columnNames); 19346 } 19347 if (kind == RelationKind.CTE) { 19348 // Slice 60 + Slice 103: key by EFFECTIVE ALIAS in the consuming 19349 // SELECT, not by CTE name. This avoids a collision when a 19350 // FROM-subquery alias equals a visible CTE name and the 19351 // CTE is referenced under a different alias (codex 19352 // diff-review): `WITH a AS (...) SELECT c.*, a.* FROM a c 19353 // JOIN (SELECT ...) a ON ...` — both 'a' (CTE) and 'a' 19354 // (subquery alias) live in the FROM clause; effective 19355 // aliases are 'c' and 'a' respectively, so per-relation 19356 // entries cannot overwrite each other. 19357 // 19358 // Slice 103 — explicit CTE column lists (WITH a(x, y) AS ...) 19359 // are no longer rejected here. The slice-102 rename helper now 19360 // runs on the SELECT-side CTE walker too; the in-scope map 19361 // populated by addRelationToInScopeMap reads from 19362 // ctePublishedColumns, which the helper has populated with the 19363 // renamed names. Star expansion just falls through to the 19364 // in-scope lookup below; the renamed list comes back. 19365 String lookupKey = effectiveAliasLowerCaseOrNull(target); 19366 List<String> cteColumns = (lookupKey == null) ? null 19367 : provider.getInScopeRelationColumns().get(lookupKey); 19368 if (cteColumns == null || cteColumns.isEmpty()) { 19369 String diagAlias = effectiveAliasOf(target); 19370 return StarExpansionResult.reject( 19371 StarExpansionKind.NO_INSCOPE_RELATION_COLUMNS, 19372 qualifier, 19373 "CTE '" + diagAlias + "' has no published columns in " 19374 + "the in-scope map (builder invariant: the " 19375 + "CTE body should have been built and " 19376 + "registered before this consumer ran)"); 19377 } 19378 return buildExpansionFromColumnNames(target, cteColumns); 19379 } 19380 if (kind == RelationKind.SUBQUERY) { 19381 // Slice 60: same effective-alias keying as the CTE branch 19382 // above (codex diff-review). For a subquery the effective 19383 // alias IS the alias the SQL writer wrote (preflight 19384 // rejects anonymous subqueries), so this branch is also 19385 // unambiguous under the alias-collision example. 19386 String lookupKey = effectiveAliasLowerCaseOrNull(target); 19387 List<String> subColumns = (lookupKey == null) ? null 19388 : provider.getInScopeRelationColumns().get(lookupKey); 19389 if (subColumns == null || subColumns.isEmpty()) { 19390 String diagAlias = effectiveAliasOf(target); 19391 return StarExpansionResult.reject( 19392 StarExpansionKind.NO_INSCOPE_RELATION_COLUMNS, 19393 qualifier, 19394 "FROM-clause subquery '" 19395 + (diagAlias != null ? diagAlias : "<unnamed>") 19396 + "' has no published columns in the in-scope " 19397 + "map (builder invariant: the subquery body " 19398 + "should have been extracted and registered " 19399 + "before this consumer ran)"); 19400 } 19401 return buildExpansionFromColumnNames(target, subColumns); 19402 } 19403 // OUTER_REFERENCE / UNION / UNKNOWN: keep the slice-58 / 59 19404 // rejection contract. None of these arrive on a slice-60 19405 // FROM-clause relation via the current builder paths 19406 // (OUTER_REFERENCE bindings live only on RelationSource for 19407 // correlated scalar lookup; UNION is a set-op branch concept, 19408 // not a FROM-clause relation). Defensive catch-all. 19409 String diagAlias = effectiveAliasOf(target); 19410 String detail; 19411 switch (kind) { 19412 case OUTER_REFERENCE: 19413 detail = "OUTER_REFERENCE star expansion is not supported (relation '" 19414 + diagAlias + "')"; 19415 break; 19416 case UNION: 19417 case UNKNOWN: 19418 default: 19419 detail = "FROM source '" + diagAlias 19420 + "' must be a base table, CTE, or FROM-subquery (got kind=" 19421 + kind + ")"; 19422 break; 19423 } 19424 return StarExpansionResult.reject( 19425 StarExpansionKind.NON_BASE_TABLE_RELATION, qualifier, detail); 19426 } 19427 19428 /** 19429 * Slice 60 — shared helper that turns a column-name list into the 19430 * star-expansion OutputColumn list with one {@link ColumnRef} per 19431 * column whose {@code relationAlias} is the effective alias of the 19432 * target table (alias if present, else the table name). Used by all 19433 * three slice-58 / 59 / 60 paths. 19434 */ 19435 private static StarExpansionResult buildExpansionFromColumnNames( 19436 TTable target, List<String> columnNames) { 19437 String alias = effectiveAliasOf(target); 19438 List<OutputColumn> outputs = new ArrayList<>(columnNames.size()); 19439 for (String colName : columnNames) { 19440 ColumnRef ref = new ColumnRef(alias, colName); 19441 outputs.add(new OutputColumn( 19442 colName, 19443 /*derived=*/ false, 19444 /*aggregate=*/ false, 19445 Collections.singletonList(ref), 19446 /*windowSpec=*/ null)); 19447 } 19448 return StarExpansionResult.expanded(outputs); 19449 } 19450 19451 /** 19452 * Slice 65 — bare {@code *} over a USING JOIN: deduplicate the 19453 * merged key within each equivalence class. For each FROM relation 19454 * in order: 19455 * <ul> 19456 * <li>look up columns via the existing 19457 * {@link #lookupRelationColumnNames} (catalog + in-scope map); 19458 * reject with {@link StarExpansionKind#NO_CATALOG_OR_UNKNOWN_TABLE} 19459 * when null (we can't dedup without knowing what's there);</li> 19460 * <li>for each column, check 19461 * {@link UsingScope#entryContaining(String, TTable)}; 19462 * if a class contains this relation, emit a single 19463 * merged-source {@link OutputColumn} the first time the class 19464 * is seen and skip duplicates from later class members;</li> 19465 * <li>otherwise emit a plain single-source OutputColumn.</li> 19466 * </ul> 19467 * 19468 * <p>Duplicate-output guard fires ONLY when the conflicting names 19469 * involve a USING-merged entry (merged-vs-plain or two disconnected 19470 * merged classes for the same key). Plain duplicates from 19471 * non-USING multi-relation expansion remain admitted (slice-59 19472 * behavior). 19473 * 19474 * <p>Output column order is <b>left-table order with USING-key 19475 * dedup within each equivalence class</b>: the merged column 19476 * appears at the position of its first member in FROM order. E.g. 19477 * {@code a(id, k), b(k, name)} → {@code [id, k, name]}. 19478 * 19479 * <p>This is INTENTIONALLY DIFFERENT from the ANSI/PostgreSQL 19480 * physical column order (which puts USING columns first, then 19481 * remaining left, then remaining right — would yield 19482 * {@code [k, id, name]}). The Semantic IR is not a query 19483 * executor; the order it surfaces is a lineage-tracking 19484 * presentation choice. Left-table order: 19485 * <ol> 19486 * <li>matches the slice-65 roadmap resume protocol 19487 * ({@code docs/designs/sql-semantic-governance-unified-roadmap.md} 19488 * §13.1) which fixed this order before implementation;</li> 19489 * <li>keeps the merged column physically adjacent to its 19490 * left-side neighbors, matching how lineage tooling 19491 * traditionally renders combined JOIN output;</li> 19492 * <li>does not depend on USING-clause ordering (which is a 19493 * syntactic choice, not a semantic one).</li> 19494 * </ol> 19495 * Codex diff-review round 5 flagged this as P2 (non-ANSI). The 19496 * choice was confirmed in plan-review and is locked by 19497 * {@code bareStarOverUsingLeftPositionPreserved} so any future 19498 * change to ANSI order is a deliberate observable contract 19499 * change, not a silent fix. 19500 */ 19501 private static StarExpansionResult expandBareStarOverUsing( 19502 TSelectSqlStatement select, 19503 NameBindingProvider provider, 19504 List<TTable> fromRelations) { 19505 UsingScope scope = provider.getUsingScope(); 19506 if (scope.isEmpty()) { 19507 // The slice-65 caller checks hasUsingInFromClause before 19508 // routing here, so an empty scope here means buildUsingScope 19509 // computed empty entries (unreachable in practice). Fall 19510 // back to a generic reject so the caller surfaces a 19511 // structured diagnostic. 19512 return StarExpansionResult.reject( 19513 StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null, 19514 "bare * over JOIN ... USING reached the merged-key expander " 19515 + "with an empty UsingScope (builder invariant failure)"); 19516 } 19517 LinkedHashSet<String> emittedNamesLC = new LinkedHashSet<>(); 19518 Set<String> mergedNamesLC = new HashSet<>(); 19519 java.util.IdentityHashMap<UsingScope.EquivalenceClass, Boolean> emittedClasses = 19520 new java.util.IdentityHashMap<>(); 19521 List<OutputColumn> outputs = new ArrayList<>(); 19522 for (TTable t : fromRelations) { 19523 // Slice 103 lifted the explicit-CTE-column-list deferral: 19524 // populateUsingJoinRefs no longer rejects, and 19525 // lookupRelationColumnNames returns the renamed names from 19526 // the in-scope map (populated by the slice-102 rename 19527 // helper that the SELECT-side CTE walker now invokes). 19528 List<String> cols = lookupRelationColumnNames(t, provider); 19529 if (cols == null) { 19530 return StarExpansionResult.reject( 19531 StarExpansionKind.NO_CATALOG_OR_UNKNOWN_TABLE, null, 19532 effectiveAliasOf(t)); 19533 } 19534 for (String c : cols) { 19535 if (c == null) continue; 19536 String keyLC = c.toLowerCase(Locale.ROOT); 19537 UsingScope.MergedKeyEntry entry = scope.entryContaining(keyLC, t); 19538 if (entry != null) { 19539 // USING-merged column. Dedup per class. 19540 if (emittedClasses.containsKey(entry.getEquivClass())) { 19541 continue; 19542 } 19543 emittedClasses.put(entry.getEquivClass(), Boolean.TRUE); 19544 OutputColumn cand = new OutputColumn( 19545 c, /*derived=*/ false, /*aggregate=*/ false, 19546 entry.getSources(), /*windowSpec=*/ null); 19547 StarExpansionResult dup = appendMergedAwareOrReject( 19548 outputs, emittedNamesLC, mergedNamesLC, cand, /*isMerged=*/ true); 19549 if (dup != null) return dup; 19550 } else { 19551 // Plain column. Slice-59 behavior: duplicate plain 19552 // names are admitted. Codex round-5: the merged-aware 19553 // guard fires only when ONE side is merged. 19554 OutputColumn cand = new OutputColumn( 19555 c, /*derived=*/ false, /*aggregate=*/ false, 19556 Collections.singletonList(new ColumnRef(effectiveAliasOf(t), c)), 19557 /*windowSpec=*/ null); 19558 StarExpansionResult dup = appendMergedAwareOrReject( 19559 outputs, emittedNamesLC, mergedNamesLC, cand, /*isMerged=*/ false); 19560 if (dup != null) return dup; 19561 } 19562 } 19563 } 19564 return StarExpansionResult.expanded(outputs); 19565 } 19566 19567 /** 19568 * Slice 65 — duplicate-output helper for 19569 * {@link #expandBareStarOverUsing}. Fires the merged-vs-non-merged 19570 * collision guard. Returns a {@link StarExpansionResult} when the 19571 * caller should reject; returns {@code null} when the candidate is 19572 * appended successfully. 19573 */ 19574 private static StarExpansionResult appendMergedAwareOrReject( 19575 List<OutputColumn> outputs, 19576 LinkedHashSet<String> emittedNamesLC, 19577 Set<String> mergedNamesLC, 19578 OutputColumn cand, 19579 boolean isMerged) { 19580 String nameLC = cand.getName().toLowerCase(Locale.ROOT); 19581 boolean alreadyEmitted = emittedNamesLC.contains(nameLC); 19582 boolean alreadyMerged = mergedNamesLC.contains(nameLC); 19583 // Reject only when at least one side is a USING-merged entry. 19584 // Plain-vs-plain duplicates remain admitted (slice-59 behavior). 19585 if (alreadyEmitted && (isMerged || alreadyMerged)) { 19586 return StarExpansionResult.reject( 19587 StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null, 19588 "bare * over JOIN ... USING produces ambiguous output " 19589 + "column '" + cand.getName() + "': a USING-merged " 19590 + "entry and a same-named column from outside the " 19591 + "USING equivalence class collide (or two disconnected " 19592 + "USING classes share the same key name); qualify " 19593 + "with t.* per relation or rename a column to " 19594 + "disambiguate"); 19595 } 19596 emittedNamesLC.add(nameLC); 19597 if (isMerged) mergedNamesLC.add(nameLC); 19598 outputs.add(cand); 19599 return null; 19600 } 19601 19602 /** 19603 * Slice 60 — read the published column names for an already-built 19604 * statement (CTE body or FROM-subquery body) from its 19605 * {@link StatementGraph#getOutputColumns()}. Used by {@code build()} 19606 * and {@code extractFromSubqueriesAsStatements} to populate the 19607 * in-scope map before each consuming SELECT's 19608 * {@code buildOutputColumns} runs. 19609 */ 19610 private static List<String> outputColumnNames(StatementGraph body) { 19611 List<OutputColumn> cols = body.getOutputColumns(); 19612 List<String> names = new ArrayList<>(cols.size()); 19613 for (OutputColumn c : cols) names.add(c.getName()); 19614 return Collections.unmodifiableList(names); 19615 } 19616 19617 /** 19618 * Slice 60 — effective alias of a TTable lower-cased, or null when 19619 * the table has neither an alias nor a name. The alias-collision 19620 * fix (codex diff-review) replaced CTE-name / subquery-alias 19621 * keying with effective-alias keying; this helper centralises the 19622 * lookup-key computation. 19623 */ 19624 private static String effectiveAliasLowerCaseOrNull(TTable t) { 19625 String alias = effectiveAliasOf(t); 19626 if (alias == null || alias.isEmpty()) return null; 19627 return alias.toLowerCase(Locale.ROOT); 19628 } 19629 19630 /** 19631 * Slice 60 — build a per-consumer effective-alias-keyed map of 19632 * "FROM-clause relation alias → published column names" by walking 19633 * the consumer's direct FROM/JOIN list (single top-level TJoin, 19634 * left table + each joinItem.getTable()). 19635 * 19636 * <p>Each CTE-bound relation contributes its effective alias → 19637 * {@code ctePublishedColumns.get(cteName.toLowerCase())}. Each 19638 * FROM-subquery contributes its alias → {@code 19639 * outputColumnNames(stmts.get(subqueryAliasToIndex.get(alias)))}. 19640 * Base-table relations are skipped because their star expansion 19641 * uses the catalog path (TSQLEnv); adding them here would force 19642 * dialect-specific catalog walks before catalog access is required. 19643 * 19644 * <p>The codex diff-review found that a single name-keyed map 19645 * collides when a FROM-subquery alias equals a visible CTE name 19646 * (`WITH a AS (...) ... FROM a c JOIN (SELECT ...) a ...`). Keying 19647 * by effective alias (which is unique per FROM clause — 19648 * {@link #preflightDirectFromList} rejects duplicates) closes the 19649 * collision class. 19650 * 19651 * @param consumer the SELECT whose FROM list to walk 19652 * @param consumerProvider provider used only for bindRelation 19653 * (CTE vs TABLE discrimination) 19654 * @param ctePublishedColumns CTE-name → columns lookup 19655 * populated as CTE bodies are built 19656 * @param subqueryAliasToIndex this consumer's own subquery alias 19657 * → stmts index lookup 19658 * @param stmts already-built statement list 19659 * @return mutable effective-alias-keyed in-scope map for this 19660 * consumer; callers wrap it via 19661 * {@code provider.withInScopeRelationColumns(map)} 19662 */ 19663 private static Map<String, List<String>> buildEffectiveAliasInScopeMap( 19664 TSelectSqlStatement consumer, 19665 NameBindingProvider consumerProvider, 19666 Map<String, List<String>> ctePublishedColumns, 19667 Map<String, Integer> subqueryAliasToIndex, 19668 List<StatementGraph> stmts) { 19669 Map<String, List<String>> result = new HashMap<>(); 19670 if (consumer.joins == null) return result; 19671 for (TJoin join : consumer.joins) { 19672 addRelationToInScopeMap(join.getTable(), consumerProvider, 19673 ctePublishedColumns, subqueryAliasToIndex, stmts, result); 19674 TJoinItemList items = join.getJoinItems(); 19675 if (items == null) continue; 19676 for (int i = 0; i < items.size(); i++) { 19677 TJoinItem item = items.getJoinItem(i); 19678 if (item == null) continue; 19679 addRelationToInScopeMap(item.getTable(), consumerProvider, 19680 ctePublishedColumns, subqueryAliasToIndex, stmts, result); 19681 } 19682 } 19683 return result; 19684 } 19685 19686 private static void addRelationToInScopeMap( 19687 TTable t, 19688 NameBindingProvider consumerProvider, 19689 Map<String, List<String>> ctePublishedColumns, 19690 Map<String, Integer> subqueryAliasToIndex, 19691 List<StatementGraph> stmts, 19692 Map<String, List<String>> result) { 19693 if (t == null) return; 19694 // Slice 137 — a PIVOT / UNPIVOT source can itself be a FROM-subquery 19695 // (or CTE): `FROM (SELECT ...) [alias] PIVOT(...)`. Unwrap the 19696 // pivoted_table to its source relation so the source's published 19697 // columns are registered under the SAME effective alias that 19698 // lookupRelationColumnNames resolves in expandPivot/UnpivotBareStar. 19699 // The source-subquery alias is already registered in 19700 // subqueryAliasToIndex by the slice-136 processDirectSubqueryTable 19701 // pre-pass (which performs the same unwrap), so the subquery branch 19702 // below finds its statement index. A base-table pivot source falls 19703 // through unchanged: bindRelation returns TABLE → no in-scope entry, 19704 // and the slice-133 catalog path (getRelationColumnNames) handles it. 19705 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 19706 && t.getPivotedTable() != null 19707 && !t.getPivotedTable().getRelations().isEmpty()) { 19708 TTable src = t.getPivotedTable().getRelations().get(0); 19709 if (src == null) return; 19710 t = src; 19711 } 19712 String key = effectiveAliasLowerCaseOrNull(t); 19713 if (key == null) return; 19714 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 19715 Integer idx = subqueryAliasToIndex.get(key); 19716 if (idx != null) { 19717 result.put(key, outputColumnNames(stmts.get(idx))); 19718 } 19719 return; 19720 } 19721 // objectname (base-table OR CTE reference). Use bindRelation 19722 // to discriminate; base tables don't need an in-scope entry 19723 // (slice 58 catalog path handles them via getRelationColumnNames). 19724 RelationBinding b = consumerProvider.bindRelation(t); 19725 if (b == null) return; 19726 if (b.getKind() == RelationKind.CTE) { 19727 String cteName = t.getName(); 19728 if (cteName == null) return; 19729 List<String> cols = ctePublishedColumns.get(cteName.toLowerCase(Locale.ROOT)); 19730 if (cols != null && !cols.isEmpty()) { 19731 result.put(key, cols); 19732 } 19733 } 19734 // For TABLE, OUTER_REFERENCE, UNION, UNKNOWN bindings the 19735 // in-scope map is intentionally not populated; the 19736 // base-table catalog path or rejection path applies. 19737 } 19738 19739 /** 19740 * Build the {@link OutputColumn} list. Slice 4 lifts the 19741 * simple-object-name / single-source restriction: any expression with at 19742 * least one column reference is accepted, and the column is marked 19743 * {@link OutputColumn#isDerived()} when the expression is anything 19744 * other than a direct column reference. Slice 61 also admits 19745 * canonical constant-only projections (zero column refs) outside 19746 * scalar-subquery bodies, using alias-or-expression text naming. 19747 */ 19748 private static List<OutputColumn> buildOutputColumns(TSelectSqlStatement select, 19749 NameBindingProvider provider, 19750 boolean allowScalarProjectionSubqueries, 19751 boolean allowWindowProjection, 19752 boolean isPredicateBody, 19753 String stmtName) { 19754 TResultColumnList rcl = select.getResultColumnList(); 19755 if (rcl == null || rcl.size() == 0) { 19756 throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS, "SELECT has no projected columns", select)); 19757 } 19758 // Slice 23/24/27: predicate-body short-circuit. The preflight 19759 // (§4.4 / slice-24 §4.1.1 / slice-27 §4.1) already validated that 19760 // the inner SELECT projects exactly one column, of an admitted 19761 // shape — constant (slice 23), simple column ref (slice 24), or 19762 // expression / function call / CASE / aggregate over inner 19763 // columns (slice 27). Discriminate on the shape: 19764 // 19765 // - Constant: bypass the regular result-column loop (which would 19766 // reject empty-source non-aggregate projections via the 19767 // "no column refs" guard at line ~4397) and emit one synthetic 19768 // OutputColumn with empty sources. The synthesised name 19769 // `<predicate_subquery_<i>>_const_0` guarantees no collision 19770 // with real column names. 19771 // 19772 // - Slice-24 column ref (simple_object_name_t with name): fall 19773 // through to the normal loop; effectiveOutputName(rc) returns 19774 // the column name. OutputColumn carries name, derived=false, 19775 // aggregate=false, sources=[ColumnRef(...)]. 19776 // 19777 // - Slice-27 expression / function / CASE / aggregate without 19778 // alias: synthesise the OutputColumn here. The normal loop's 19779 // {@link #effectiveOutputName} would throw on rc with neither 19780 // alias nor column name (a slice-6 invariant for OUTER 19781 // projections); for predicate bodies the OutputColumn name is 19782 // internal scaffolding only — no consumer references it 19783 // externally — so a synthetic name is sound. For aggregate-over- 19784 // constants (COUNT(*), SUM(1)) sources is empty and aggregate=true 19785 // matches the line-4397 guard's intent. The slice-24 projector 19786 // pass walks OutputColumn.sources to base-column terminals and 19787 // emits JOIN canonical edges (zero terminals → zero edges, 19788 // multi-source → multiple edges). 19789 if (isPredicateBody) { 19790 TResultColumn rc0 = rcl.getResultColumn(0); 19791 if (rc0.getExpr() != null && isConstantExpression(rc0.getExpr())) { 19792 String synthName = (stmtName != null ? stmtName : "<predicate_subquery_?>") 19793 + "_const_0"; 19794 return Collections.singletonList(new OutputColumn( 19795 synthName, /*derived=*/ true, /*aggregate=*/ false, 19796 Collections.<ColumnRef>emptyList(), /*windowSpec=*/ null)); 19797 } 19798 // Slice 27 + Slice 32: synthesise the OutputColumn for any 19799 // slice-27/31-admitted predicate-body projection EXCEPT the 19800 // slice-24 simple_object_name_t shape. Slice 27 fired this 19801 // branch only when both alias AND columnNameOnly were absent 19802 // (missingName=true); slice 32 widens it to also fire when 19803 // alias is present, so aliased Oracle / MSSQL plain 19804 // {@code LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY ...) AS lst} 19805 // is admitted (the slice-31 boundary lifted by slice 32). 19806 // 19807 // The simple_object_name_t exclusion is intentional. That 19808 // shape MUST keep falling through to the normal loop, where 19809 // {@link #effectiveOutputName} returns the column name (or 19810 // alias if present), {@code derived=false}, and 19811 // {@code sources=[ColumnRef(...)]} — the slice-24 baseline. 19812 // Per {@code TResultColumn.getColumnNameOnly()}, only 19813 // {@code simple_object_name_t}, {@code typecast_t}, and 19814 // {@code sqlserver_proprietary_column_alias_t} populate 19815 // columnNameOnly; function_t / case_t / pure-binary all 19816 // return empty, so the cascade below is alias > _proj_0 19817 // (no columnNameOnly intermediate). 19818 if (rc0.getExpr() != null 19819 && rc0.getExpr().getExpressionType() != EExpressionType.simple_object_name_t) { 19820 String alias = rc0.getColumnAlias(); 19821 String name; 19822 if (alias != null && !alias.isEmpty()) { 19823 // Slice 32 widening: aliased projection. Use the 19824 // alias as the OutputColumn name. 19825 name = alias; 19826 } else { 19827 // Slice 27 carryover: unaliased non-column-ref 19828 // projection. Synthesise a stable name. The synth 19829 // name is used internally by 19830 // {@link gudusoft.gsqlparser.ir.semantic.diff.SemanticIRProjector} 19831 // (line ~161 — BFS start key keyed by 19832 // {@code stmtOutputKey(idx, out.getName())}) to walk 19833 // predicate-body lineage to base columns; uniqueness 19834 // within the single-column predicate body is 19835 // sufficient for that walk. {@code _proj_0} is also 19836 // exposed by the JSON exporter but is not externally 19837 // referenced by callers — only the inner JOIN 19838 // canonical edges (target.column omitted; role=JOIN) 19839 // are visible to consumers. 19840 name = (stmtName != null ? stmtName : "<predicate_subquery_?>") 19841 + "_proj_0"; 19842 } 19843 boolean aggregate = isAggregateFunction(rc0.getExpr()); 19844 // Slice 43 / 44: PG (slice 43) and Snowflake (slice 44) 19845 // hypothetical-set ordered-set aggregates ({@code rank} / 19846 // {@code dense_rank} / {@code percent_rank} / 19847 // {@code cume_dist}) via direct {@code fn.getWithinGroup()} 19848 // attachment do not satisfy 19849 // {@link #isHypotheticalSetWithinGroupCall} (which requires 19850 // a non-null windowDef) and are not in the regular 19851 // {@link #AGGREGATE_FUNCTION_NAMES} whitelist. Inside the 19852 // predicate-body branch they are admitted as aggregates 19853 // when the slice-43 / 44 vendor-gated shape predicate 19854 // fires — contained here (NOT folded into 19855 // {@code isAggregateFunction}) so the carve-out cannot 19856 // accidentally lift the top-level PG / Snowflake case 19857 // (whose dlineage XML is structurally identical to the 19858 // OVER form — see Slice43Test / Slice44Test javadoc). 19859 if (!aggregate 19860 && rc0.getExpr().getExpressionType() == EExpressionType.function_t 19861 && isDirectAttachmentHypotheticalSetCall( 19862 rc0.getExpr().getFunctionCall(), select.dbvendor)) { 19863 aggregate = true; 19864 } 19865 // Slice 28: FILTER-aware collector excludes column refs inside 19866 // FILTER (WHERE ...) subtrees so OutputColumn.sources matches 19867 // dlineage's lineage-relationship view (FILTER predicate refs 19868 // absent from fdd / fdr). 19869 // Slice 31: also excludes column refs inside Oracle / MSSQL 19870 // {@code fn.windowDef.withinGroup} (the WITHIN GROUP ORDER BY) 19871 // so plain {@code LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY x.region)} 19872 // emits sources=[x.id] only — matching dlineage's omission of the 19873 // WITHIN GROUP ORDER BY ref from {@code fdr clause="on"} sources 19874 // (probe Q1 in {@code /tmp/probe31}). Slice 32 reuses the 19875 // same collector unchanged. 19876 List<ColumnRef> sources = collectColumnRefsExcludingFilterAndWithinGroupClauses(rc0, provider); 19877 if (sources.isEmpty() && !aggregate) { 19878 // Non-aggregate with no inner column refs: should be 19879 // covered by the constant short-circuit above. If we 19880 // reach here, fall through to the normal loop's 19881 // line-4397 guard for a conservative tuned message. 19882 } else { 19883 return Collections.singletonList(new OutputColumn( 19884 name, /*derived=*/ true, aggregate, 19885 sources, /*windowSpec=*/ null)); 19886 } 19887 } 19888 // simple_object_name_t falls through (slice-24 carryover): 19889 // the normal loop produces derived=false / 19890 // sources=[ColumnRef(...)] using effectiveOutputName. 19891 } 19892 // Slice 19 (alias-bound PARTITION BY discriminator): the resolver 19893 // synthesises EXACT_MATCH bindings for PARTITION BY <name> when no 19894 // schema metadata is available (TableNamespace.resolveColumn 19895 // inferred_from_usage fallback), even when <name> is a SELECT-list 19896 // alias on a calculated expression. The discriminator is exposed 19897 // by NameBindingProvider#isCalculatedProjectionAliasFallback and 19898 // consulted in buildWindowPartitionRefs / buildWindowOrderRefs; 19899 // see Slice13Test#partitionByExpressionAliasIsRejectedAsAliasBound 19900 // and the shadowing-with-metadata companion. Slice 19 prefers 19901 // conservative rejection in the no-metadata case; with TSQLEnv 19902 // declaring the shadowed column, ColumnSource#hasDefiniteEvidence 19903 // returns true and the discriminator falls through. 19904 List<OutputColumn> out = new ArrayList<>(rcl.size()); 19905 for (int i = 0; i < rcl.size(); i++) { 19906 TResultColumn rc = rcl.getResultColumn(i); 19907 if (rc.getExpr() == null) { 19908 throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NULL_EXPRESSION, "result column " + rc + " has null expression", rc)); 19909 } 19910 EExpressionType type = rc.getExpr().getExpressionType(); 19911 // Slice 58: catalog-backed star expansion for a single base 19912 // table. Star projections were rejected by slices 1-57 with 19913 // "SELECT * / list expansions are deferred"; slice 58 lifts 19914 // the single-base-table case when a catalog is available via 19915 // NameBindingProvider#getRelationColumnNames(TTable). Bare 19916 // `*` and qualified `t.*` both arrive here as 19917 // simple_object_name_t with rc.getColumnNameOnly() == "*" 19918 // (probed; see slice-58 plan); the prior EExpressionType.list_t 19919 // branch is dead defense for stars in practice but stays in 19920 // case a future grammar variant routes them differently. 19921 String colNameOnly = rc.getColumnNameOnly(); 19922 if ("*".equals(colNameOnly) || type == EExpressionType.list_t) { 19923 StarExpansionResult exp = tryExpandStar(rc, select, provider, 19924 isPredicateBody, stmtName); 19925 switch (exp.kind) { 19926 case EXPANDED: 19927 out.addAll(exp.columns); 19928 continue; 19929 case PREDICATE_BODY_GUARD: 19930 // Defensive; preflightExistsInnerShape at line ~3880 19931 // rejects SELECT * in EXISTS earlier with a tuned 19932 // message. This branch only fires if a future call 19933 // site enters buildOutputColumns with isPredicateBody 19934 // and a star still present. 19935 throw new SemanticIRBuildException( 19936 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_PREDICATE_BODY, 19937 "result column " + rc + " is a star expansion (SELECT *) " 19938 + "inside a predicate body; not supported yet", rc)); 19939 case SYNTHETIC_BODY_CONTEXT: 19940 // Slice 59: star expansion is rejected inside a 19941 // synthetic body (scalar-subquery / set-op-branch / 19942 // predicate-subquery). Multi-column expansion would 19943 // violate the body's shape contract; the slice-58 19944 // path could silently produce this for 19945 // catalog-equipped builds. 19946 throw new SemanticIRBuildException( 19947 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_SYNTHETIC_BODY, 19948 "result column " + rc + " is a star expansion (SELECT *); " 19949 + (exp.detail != null ? exp.detail 19950 : "star expansion is not supported inside a synthetic body"), rc)); 19951 case MULTI_RELATION_FROM: { 19952 // GSP R6 degrade (star-expansion-multi-relation-from- 19953 // degrade.md): tryExpandStar returns this kind when a 19954 // FROM operand is a parenthesized / nested <joined_table> 19955 // (TJoin.getTable() / TJoinItem.getTable() is null), so 19956 // the single-relation expander cannot pick one relation. 19957 // The parenthesized-join recursion (buildRelations / 19958 // buildJoinGraph) already builds a complete multi-relation 19959 // join graph for exactly this FROM shape, so failing here 19960 // would discard a fully-analyzable program. DEGRADE just 19961 // like the NO_CATALOG_OR_UNKNOWN_TABLE arm below: record a 19962 // non-fatal warning, emit the star as an UNEXPANDED MARKER 19963 // output column (no sources — never fabricate names), and 19964 // continue. Synthetic bodies are caught earlier as 19965 // SYNTHETIC_BODY_CONTEXT (set-op parity unaffected); 19966 // catalog-present flat expansion still returns EXPANDED. 19967 // 19968 // Scope the degrade to the genuinely-analyzable nested-join 19969 // shape: a null FROM table caused by a parenthesized 19970 // <joined_table> operand, for which buildRelations / 19971 // buildJoinGraph already built the graph. A 19972 // MULTI_RELATION_FROM with NO nested operand (the 19973 // defensive topJoin==null path, or a null FROM table with 19974 // no nested join) is a broken invariant with no graph to 19975 // preserve — keep it fatal rather than fabricating a 19976 // valid-looking program with a sourceless marker. 19977 if (!hasNestedJoinOperand(select)) { 19978 throw new SemanticIRBuildException( 19979 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_MULTI_RELATION_FROM, 19980 "result column " + rc + " is a star expansion (SELECT *); " 19981 + "FROM source could not be determined", rc)); 19982 } 19983 recordBuildWarning(Diagnostic.warn( 19984 DiagnosticCode.STAR_EXPANSION_MULTI_RELATION_FROM, 19985 "result column " + rc + " is a star expansion (SELECT *) " 19986 + "over a parenthesized / nested join — left " 19987 + "unexpanded (catalog-less degrade)", rc)); 19988 String mrStarQualifier = null; 19989 if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) { 19990 mrStarQualifier = rc.getExpr().getObjectOperand().getTableString(); 19991 } 19992 String mrStarName = (mrStarQualifier != null && !mrStarQualifier.isEmpty()) 19993 ? mrStarQualifier + ".*" : "*"; 19994 out.add(new OutputColumn(mrStarName, /*derived=*/ false, 19995 /*aggregate=*/ false, 19996 Collections.<ColumnRef>emptyList(), 19997 /*windowSpec=*/ null)); 19998 continue; 19999 } 20000 case NON_BASE_TABLE_RELATION: 20001 throw new SemanticIRBuildException( 20002 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_NON_BASE_TABLE, 20003 "result column " + rc + " is a star expansion (SELECT *); " 20004 + (exp.detail != null ? exp.detail 20005 : "FROM source must be a base table"), rc)); 20006 case QUALIFIER_NOT_FOUND: 20007 throw new SemanticIRBuildException( 20008 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_QUALIFIER_NOT_FOUND, 20009 "result column " + rc + " (qualified star " 20010 + (exp.qualifier == null ? "?" : exp.qualifier) 20011 + ".*) does not match any FROM-clause relation", rc)); 20012 case QUALIFIER_AMBIGUOUS: 20013 // Slice 59: 2+ FROM relations have the same 20014 // effective alias. Real SQL never reaches this 20015 // unless rejectDuplicateAliases:~5621 (case- 20016 // sensitive) allowed a case-only collision. 20017 throw new SemanticIRBuildException( 20018 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_QUALIFIER_AMBIGUOUS, 20019 "result column " + rc + " (qualified star " 20020 + (exp.qualifier == null ? "?" : exp.qualifier) 20021 + ".*) is ambiguous: " 20022 + (exp.detail != null ? exp.detail 20023 : "multiple FROM-clause relations match"), rc)); 20024 case NO_CATALOG_OR_UNKNOWN_TABLE: { 20025 // GSP R6 degrade (star-expansion-no-catalog-degrade.md): 20026 // a catalog-less SELECT * / t.* cannot be expanded to 20027 // real column names, but the join structure is fixed by 20028 // the FROM / ON clauses and does NOT depend on star 20029 // expansion. Rather than discarding the whole statement 20030 // graph (relations + join graph + scope), DEGRADE: 20031 // record a non-fatal warning and emit the star as an 20032 // UNEXPANDED MARKER output column (no sources — never 20033 // fabricate column names), then continue the build. 20034 // 20035 // Catalog-present expansion is unaffected (it returns 20036 // EXPANDED above). Synthetic bodies (scalar / set-op 20037 // branch / predicate) are caught earlier as 20038 // SYNTHETIC_BODY_CONTEXT and stay fatal, so set-op 20039 // column-count parity is never relaxed by this degrade. 20040 // Mirrors the NATURAL (NATURAL_CATALOG_REQUIRED) and 20041 // USING (COLUMN_BINDING_NON_EXACT) catalog-less degrades. 20042 String relationLabel; 20043 if (exp.qualifier != null && !exp.qualifier.isEmpty()) { 20044 relationLabel = exp.qualifier; 20045 } else if (exp.detail != null && !exp.detail.isEmpty()) { 20046 relationLabel = exp.detail; 20047 } else { 20048 relationLabel = "the FROM relation"; 20049 } 20050 recordBuildWarning(Diagnostic.warn( 20051 DiagnosticCode.STAR_EXPANSION_NO_CATALOG, 20052 "result column " + rc + " is a star expansion (SELECT *); " 20053 + "requires catalog with column declarations for " 20054 + relationLabel 20055 + " — left unexpanded (catalog-less degrade)", rc)); 20056 // Unexpanded-star marker: the star's own text (`*` or 20057 // `t.*`), no sources. Derived=false so the empty-source 20058 // RESULT_COLUMN_NO_COLUMN_REFS guard does not apply 20059 // (that guard targets derived no-ref expressions). 20060 String starQualifier = null; 20061 if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) { 20062 starQualifier = rc.getExpr().getObjectOperand().getTableString(); 20063 } 20064 String starName = (starQualifier != null && !starQualifier.isEmpty()) 20065 ? starQualifier + ".*" : "*"; 20066 out.add(new OutputColumn(starName, /*derived=*/ false, 20067 /*aggregate=*/ false, 20068 Collections.<ColumnRef>emptyList(), 20069 /*windowSpec=*/ null)); 20070 continue; 20071 } 20072 case EXPLICIT_CTE_COLUMN_LIST_DEFERRED: 20073 // Slice 103 lifted the explicit-CTE-column-list 20074 // deferral: the SELECT-side CTE walker now runs 20075 // the slice-102 rename helper, so the in-scope 20076 // map publishes the renamed columns and 20077 // expandSingleRelation returns EXPANDED instead 20078 // of falling into this arm. The case is kept 20079 // declared-but-unreached for API stability and 20080 // exhaustive-switch coverage (slice 71/72/82/86 20081 // /95/96/97/98/99/100/101/102 precedent). If a 20082 // future call path re-introduces the kind, the 20083 // throw still fires with a faithful diagnostic. 20084 throw new SemanticIRBuildException( 20085 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_EXPLICIT_CTE_COLUMN_LIST, 20086 "result column " + rc + " is a star expansion (SELECT *); " 20087 + (exp.detail != null ? exp.detail 20088 : "star expansion through an explicit CTE column list is deferred to a future slice"), rc)); 20089 case NO_INSCOPE_RELATION_COLUMNS: 20090 // Slice 60: builder invariant failure — a CTE 20091 // or FROM-subquery body was not registered in 20092 // the provider's in-scope-relation-columns map 20093 // before this consumer ran. User SQL cannot 20094 // reach this kind under normal build() 20095 // execution; reaching it indicates a missing 20096 // call site is not narrowing the provider 20097 // before invoking buildOutputColumns. 20098 throw new SemanticIRBuildException( 20099 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_NO_INSCOPE_RELATION_COLUMNS, 20100 "result column " + rc + " is a star expansion (SELECT *); " 20101 + (exp.detail != null ? exp.detail 20102 : "in-scope CTE/subquery column map is empty for this relation (builder invariant failure)"), rc)); 20103 // No `default`: switch is intentionally exhaustive 20104 // over StarExpansionKind. The post-switch throw 20105 // below is the actual runtime guard if a future 20106 // enum value is added without updating this 20107 // switch. 20108 } 20109 throw new SemanticIRBuildException( 20110 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_UNHANDLED_KIND, 20111 "result column " + rc + " is a star expansion (SELECT *); " 20112 + "unhandled StarExpansionKind=" + exp.kind, rc)); 20113 } 20114 // Top-level scalar subquery in projection (slice 11). When the 20115 // caller permits it (allowScalarProjectionSubqueries=true), the 20116 // outer caller has already extracted the inner SELECT as its 20117 // own statement via extractScalarSubqueriesAsStatements; here 20118 // we just construct the OutputColumn shell with empty sources 20119 // and let emitLineageForStatement wire the 20120 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge. 20121 if (type == EExpressionType.subquery_t) { 20122 if (!allowScalarProjectionSubqueries) { 20123 throw new SemanticIRBuildException( 20124 Diagnostic.error(DiagnosticCode.NESTED_SCALAR_SUBQUERY_IN_PROJECTION, 20125 "nested scalar subquery in projection (inside another " 20126 + "scalar subquery body or FROM-clause subquery body) " 20127 + "is not supported yet", rc)); 20128 } 20129 String alias = rc.getColumnAlias(); 20130 if (alias == null || alias.isEmpty()) { 20131 throw new SemanticIRBuildException( 20132 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_ALIAS_REQUIRED, 20133 "scalar subquery projection must have an alias", rc)); 20134 } 20135 out.add(new OutputColumn(alias, /*derived=*/ true, 20136 /*aggregate=*/ false, 20137 Collections.<ColumnRef>emptyList(), 20138 /*windowSpec=*/ null)); 20139 continue; 20140 } 20141 // Slice 13: detect top-level window function before deep scans 20142 // so the embedded-window rejecter can identity-skip the 20143 // legitimate top-level window function call. 20144 boolean topLevelWindow = isTopLevelWindowProjection(rc.getExpr()); 20145 // Slice 33: detect Oracle / MSSQL plain WITHIN-GROUP-only 20146 // aggregate at the projection root. When admitted, the root 20147 // function carries fn.windowDef!=null but is the legitimate 20148 // top-level form — the slice-13 invariant rejecters 20149 // (isTopLevelWindowProjection / rejectWindowFunctions / 20150 // rejectEmbeddedWindowFunction) keep their strict wd!=null 20151 // check unchanged; this local boolean is what discriminates 20152 // them. The admission helper combines: 20153 // - isWithinGroupOnlyWindowDef (no OVER, no KEEP DENSE_RANK) 20154 // - explicit EDbVendor gate (Oracle / MSSQL only — mirrors 20155 // the slice-31 predicate-body gate at line ~3860) 20156 // - function name in AGGREGATE_FUNCTION_NAMES whitelist 20157 // PG / Snowflake / DB2 / SparkSQL produce direct fn.withinGroup 20158 // (windowDef=null) and never reach this admission helper; their 20159 // top-level WG already builds today via the normal aggregate 20160 // path (with pre-existing AGGREGATION_MISMATCH divergence on 20161 // the dlineage projector side that slice 33 deliberately does 20162 // not address — see the slice-30 rationale on 20163 // ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES for why a name-only 20164 // projector override is unsafe across the dual-form aggregates 20165 // SUM / MIN / MAX / LISTAGG that have OVER (PARTITION BY) 20166 // forms on Oracle). 20167 TFunctionCall slice33RootFn = rc.getExpr().getExpressionType() == EExpressionType.function_t 20168 ? rc.getExpr().getFunctionCall() 20169 : null; 20170 boolean slice33TopLevelWG = isAdmittedTopLevelWithinGroupAggregate( 20171 slice33RootFn, select.dbvendor); 20172 boolean slice35TopLevelDirectWG = isAdmittedTopLevelDirectWithinGroupAggregate( 20173 slice33RootFn, select.dbvendor); 20174 // Reject scalar subqueries embedded inside larger projection 20175 // expressions (slice 11 + codex round-2 MUST 7). Catches both 20176 // top-level subquery_t hidden under a wrapping expression 20177 // (e.g. UPPER((SELECT ...)) — though the parser sometimes 20178 // strips the wrap) AND predicate subqueries that don't surface 20179 // as subquery_t (EXISTS in projection, IN-projection). 20180 // Slice 9/10 deep-scan pattern. 20181 rejectEmbeddedSubqueryInProjection(rc.getExpr(), rc); 20182 // Slice 13: reject window functions embedded inside larger 20183 // projection expressions (e.g. `ROW_NUMBER() OVER (...) + 1`, 20184 // `UPPER(LAG(...) OVER (...))`). The helper identity-skips 20185 // the legitimate top-level window function call when 20186 // `topLevelWindow=true`. 20187 // 20188 // Slice 33: also identity-skip the top-level WITHIN-GROUP-only 20189 // aggregate root. TFunctionCall.acceptChildren preVisits the 20190 // root function (TFunctionCall.java:1528), so without 20191 // skipTopLevel=true the visitor would catch the slice-33- 20192 // admitted root (fn.windowDef!=null). Embedded WG inside 20193 // UPPER / CASE still rejects because the visitor finds a 20194 // non-root function whose windowDef!=null — the inner 20195 // function is not == identity to the root, so the skip 20196 // doesn't apply. 20197 rejectEmbeddedWindowFunction(rc.getExpr(), rc, topLevelWindow || slice33TopLevelWG); 20198 // Slice 33/35 fast path: WITHIN-GROUP-only aggregate — fall 20199 // through to the normal aggregate path. Oracle / MSSQL use the 20200 // windowDef attachment (slice 33); PostgreSQL direct attachment 20201 // is already on the normal aggregate path but shares the 20202 // unaliased expression-text fallback below (slice 35). 20203 if (slice33TopLevelWG || slice35TopLevelDirectWG) { 20204 // No special branch — fall through to the plain aggregate 20205 // / expression / column path below. 20206 } else if (topLevelWindow) { 20207 if (!allowWindowProjection) { 20208 throw new SemanticIRBuildException( 20209 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_AS_PROJECTION_NOT_SUPPORTED, 20210 "result column " + rc + " is a window function; not supported " 20211 + "inside this body (e.g. scalar-subquery body)", rc)); 20212 } 20213 out.add(buildWindowOutputColumn(rc, select, provider)); 20214 continue; 20215 } 20216 // Plain aggregate / expression / column path. The 20217 // rejectWindowFunctions call below is now defensive — the 20218 // top-level-window fast path above intercepts legitimate 20219 // windows, and rejectEmbeddedWindowFunction caught any 20220 // descendant window functions. 20221 // 20222 // Slice 33: skip rejectWindowFunctions for the slice-33- 20223 // admitted shape. The root function has windowDef!=null but 20224 // is the legitimate top-level form; calling 20225 // rejectWindowFunctions here would reject it via the 20226 // strict-wd!=null check (kept unchanged per slice-31 20227 // invariant). 20228 if (!slice33TopLevelWG && !slice35TopLevelDirectWG) { 20229 rejectWindowFunctions(rc.getExpr(), rc); 20230 } 20231 boolean derived = (type != EExpressionType.simple_object_name_t); 20232 boolean aggregate = isAggregateFunction(rc.getExpr()); 20233 // Slice 28: FILTER-aware collector excludes column refs inside 20234 // FILTER (WHERE ...) subtrees so OutputColumn.sources matches 20235 // dlineage's lineage-relationship view (FILTER predicate refs 20236 // absent from fdd / fdr). 20237 // Slice 31: also excludes column refs inside Oracle / MSSQL 20238 // {@code fn.windowDef.withinGroup} so plain WITHIN GROUP 20239 // aggregates emit sources from function args only. Defense- 20240 // in-depth here: the slice-31 lift only admits Oracle / MSSQL 20241 // plain WITHIN GROUP at the unaliased predicate-body 20242 // short-circuit (line ~5216) — the strict 20243 // {@link #rejectWindowFunctions} call above keeps top-level 20244 // windowDef-bearing projections rejected outside the 20245 // predicate-body context, so this collector reduces to the 20246 // slice-28 FILTER-only variant in practice today. 20247 List<ColumnRef> sources = collectColumnRefsExcludingFilterAndWithinGroupClauses(rc, provider); 20248 if (sources.isEmpty() && !aggregate) { 20249 boolean canonicalConstant = isConstantExpression(rc.getExpr()); 20250 boolean inScalarBody = isScalarSyntheticName(stmtName); 20251 if (canonicalConstant && !inScalarBody) { 20252 // Slice 61: constant-only projection lift. Predicate 20253 // bodies still use the earlier slice-23 short-circuit, 20254 // while scalar-subquery bodies intentionally keep the 20255 // slice-11/20 invariant that scalar body projections 20256 // must have a column source. 20257 String alias = rc.getColumnAlias(); 20258 String name = (alias != null && !alias.isEmpty()) 20259 ? alias 20260 : rc.getExpr().toString(); 20261 out.add(new OutputColumn(name, /*derived=*/ true, 20262 /*aggregate=*/ false, 20263 Collections.<ColumnRef>emptyList(), 20264 /*windowSpec=*/ null)); 20265 continue; 20266 } 20267 // Join-structure degrade (GSP R6): a plain unqualified column 20268 // that could not be bound under a fully-built join graph (its 20269 // COLUMN_BINDING_NON_EXACT was downgraded to a warning by 20270 // rejectNonExactBindings) is emitted with no sources rather 20271 // than aborting the whole analysis. The join structure is 20272 // intact; only this column's source side is unknown without a 20273 // catalog (honest: not fabricated). Both anchors qualify: 20274 // - a JOIN ... USING merged-key scope, OR 20275 // - an explicit ON / CROSS / comma join graph 20276 // (hasJoinStructureAnchor()). 20277 // A qualified miss never reaches here — it already aborted 20278 // fatally in the column collector (rejectNonExactBindings with 20279 // allRejectsUnqualified=false). Derived expressions 20280 // (e.g. UPPER('lit')) keep the !derived guard and stay fatal, 20281 // as do scalar bodies (they must carry a column source). 20282 if (!derived && !inScalarBody 20283 && (provider.getUsingScope().hasMergedKeys() 20284 || provider.hasJoinStructureAnchor())) { 20285 String alias = rc.getColumnAlias(); 20286 String name = (alias != null && !alias.isEmpty()) 20287 ? alias 20288 : rc.getExpr().toString(); 20289 out.add(new OutputColumn(name, /*derived=*/ false, 20290 /*aggregate=*/ false, 20291 Collections.<ColumnRef>emptyList(), 20292 /*windowSpec=*/ null)); 20293 continue; 20294 } 20295 throw new SemanticIRBuildException( 20296 Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NO_COLUMN_REFS, 20297 "result column " + rc + " has no column references " 20298 + "and is not a constant or aggregate expression " 20299 + "(e.g. UPPER('literal') / CAST / current_date - not supported yet)", rc)); 20300 } 20301 // Slice 34: when the slice-33-admitted top-level Oracle / MSSQL 20302 // WITHIN-GROUP-only aggregate has no alias, fall back to the 20303 // parser's expression text. {@code effectiveOutputName} would 20304 // throw "neither alias nor column name" because 20305 // {@code function_t} returns "" from getColumnNameOnly(). 20306 // Probe-verified that {@code rc.getExpr().toString()} byte- 20307 // matches dlineage's <select_list> column name attribute on 20308 // Oracle / MSSQL for this shape, so canonical SELECT-edge 20309 // outputName remains in parity with no projector change. 20310 // Gated tightly on slice33TopLevelWG so unrelated unaliased 20311 // shapes (function calls / CASE / expressions outside the 20312 // slice-33 admit set) keep failing loudly via 20313 // effectiveOutputName until each is probed and admitted 20314 // explicitly. See Slice34Test. 20315 String name; 20316 if (slice33TopLevelWG || slice35TopLevelDirectWG) { 20317 String alias = rc.getColumnAlias(); 20318 name = (alias != null && !alias.isEmpty()) 20319 ? alias 20320 : rc.getExpr().toString(); 20321 } else { 20322 name = effectiveOutputName(rc); 20323 } 20324 out.add(new OutputColumn(name, derived, aggregate, sources, /*windowSpec=*/ null)); 20325 } 20326 return out; 20327 } 20328 20329 /** 20330 * Reject scalar subqueries embedded inside larger projection 20331 * expressions (slice 11). Catches: 20332 * 20333 * <ul> 20334 * <li>{@code SELECT UPPER((SELECT MAX(salary) AS m FROM employees)) 20335 * AS x FROM ...} — scalar nested inside a function call.</li> 20336 * <li>{@code SELECT EXISTS (SELECT 1 FROM employees) AS has_emp 20337 * FROM ...} — EXISTS doesn't surface as 20338 * {@link EExpressionType#subquery_t} but carries 20339 * {@code getSubQuery() != null} (slice-9 round-3 lesson).</li> 20340 * <li>Other in-expression subqueries that 20341 * {@link #collectColumnRefs} would otherwise descend into.</li> 20342 * </ul> 20343 * 20344 * <p>Only top-level {@code subquery_t} projections are extracted as 20345 * separate statements (handled in 20346 * {@link #extractScalarSubqueriesAsStatements}); embedded subqueries 20347 * remain rejected because the IR doesn't yet model the "expression 20348 * over subquery result" shape. 20349 */ 20350 private static void rejectEmbeddedSubqueryInProjection(TExpression expr, TResultColumn rc) { 20351 if (expr == null) return; 20352 final boolean[] found = {false}; 20353 expr.acceptChildren(new TParseTreeVisitor() { 20354 @Override 20355 public void preVisit(TExpression e) { 20356 if (found[0]) return; 20357 if (e.getExpressionType() == EExpressionType.subquery_t 20358 || e.getSubQuery() != null) { 20359 found[0] = true; 20360 } 20361 } 20362 }); 20363 if (!found[0]) { 20364 // Top-level expression itself may carry a subquery (e.g. EXISTS 20365 // at the projection root, where rc.getExpr() is exists_t with 20366 // non-null getSubQuery() but is NOT subquery_t — so the 20367 // top-level subquery_t branch above didn't extract it). 20368 if (expr.getExpressionType() != EExpressionType.subquery_t 20369 && expr.getSubQuery() != null) { 20370 found[0] = true; 20371 } 20372 } 20373 if (found[0]) { 20374 throw new SemanticIRBuildException( 20375 Diagnostic.error(DiagnosticCode.RESULT_COLUMN_SCALAR_SUBQUERY_EMBEDDED, 20376 "result column " + rc + " contains a scalar subquery embedded " 20377 + "in a larger projection expression; not supported yet " 20378 + "(only top-level scalar subquery projections are extracted)", rc)); 20379 } 20380 } 20381 20382 /** 20383 * Detect whether an expression contains an aggregate function call 20384 * anywhere in its subtree. Slice 6 uses a name whitelist via 20385 * {@link #AGGREGATE_FUNCTION_NAMES}. Walking recursively means 20386 * {@code SUM(salary) + 1} and {@code COUNT(*) + 1} are both classified 20387 * as aggregate (and thus permitted with empty sources via 20388 * {@link #buildOutputColumns}). Wrapped in a helper so slice 7+ can 20389 * swap in deeper detection (e.g. vendor-specific function classification 20390 * on TFunctionCall) without touching call sites. 20391 * 20392 * <p>Note on aggregate literals like {@code COUNT(1)} or {@code SUM(1)}: 20393 * the visitor finds no column refs, so {@code sources=[]}. Slice 6 20394 * permits these as aggregates with no lineage edges; consumers must 20395 * read {@link OutputColumn#isAggregate()} to know the value is 20396 * row-collapsing without column lineage. 20397 */ 20398 private static boolean isAggregateFunction(TExpression expr) { 20399 if (expr == null) return false; 20400 // Slice 13: short-circuit for top-level window function. The 20401 // upstream `rejectEmbeddedWindowFunction` has already rejected any 20402 // embedded window functions, but this short-circuit ensures 20403 // `AVG(salary) OVER (...)` is never classified as an aggregate 20404 // even if it somehow slips past the upstream guard. 20405 // 20406 // Slice 31: discriminate WITHIN-GROUP-only windowDef (Oracle / 20407 // MSSQL plain WITHIN GROUP attachment without OVER) so 20408 // `LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY x.region)` stays 20409 // classified as an aggregate. Uses {@link #isWindowDefBearingFunction} 20410 // — only this check and {@link #containsWindowFunction} are 20411 // lifted; every other slice-13 invariant rejecter is unchanged. 20412 if (expr.getExpressionType() == EExpressionType.function_t) { 20413 TFunctionCall rootFn = expr.getFunctionCall(); 20414 // Slice 42: hypothetical-set ordered-set aggregate root 20415 // (Oracle / MSSQL {@code RANK(100) WITHIN GROUP (ORDER BY x)}) 20416 // — short-circuit aggregate=true. The shape predicate 20417 // {@link #isHypotheticalSetWithinGroupCall} requires WITHIN- 20418 // GROUP-only windowDef AND a name in 20419 // {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES}, so PG direct 20420 // attachment ({@code fn.getWindowDef()==null}) and OVER- 20421 // bearing forms cannot fire it. 20422 if (isHypotheticalSetWithinGroupCall(rootFn)) { 20423 return true; 20424 } 20425 if (isWindowDefBearingFunction(rootFn)) { 20426 return false; 20427 } 20428 } 20429 final boolean[] found = {false}; 20430 expr.acceptChildren(new TParseTreeVisitor() { 20431 @Override 20432 public void preVisit(TFunctionCall fn) { 20433 if (found[0]) return; 20434 // Slice 13 codex round-2 SHOULD 3: skip windowed function 20435 // calls inside the visitor too, defensively. Upstream 20436 // rejection should already have fired, but this removes 20437 // overlap risk for `sum/count/avg`. 20438 // 20439 // Slice 31: same WITHIN-GROUP-only carve-out as the root 20440 // short-circuit above so an Oracle / MSSQL plain WITHIN 20441 // GROUP aggregate nested inside CASE/UPPER (slice-27 20442 // admit) is still picked up as aggregate. 20443 // 20444 // Slice 42: hypothetical-set ordered-set aggregate carve- 20445 // out — descendants matching the shape predicate count 20446 // as aggregate (defense-in-depth; the slice-13 embedded- 20447 // window rejecter already fires on inner WG-bearing 20448 // calls, so this branch is mostly unreachable today). 20449 if (isHypotheticalSetWithinGroupCall(fn)) { 20450 found[0] = true; 20451 return; 20452 } 20453 if (isWindowDefBearingFunction(fn)) return; 20454 if (fn.getFunctionName() == null) return; 20455 String name = fn.getFunctionName().toString(); 20456 if (name == null || name.isEmpty()) return; 20457 if (AGGREGATE_FUNCTION_NAMES.contains(name.toLowerCase(Locale.ROOT))) { 20458 found[0] = true; 20459 } 20460 } 20461 }); 20462 // The root expression itself is not visited by acceptChildren — only 20463 // its children. If the root is the function call (the common case 20464 // for `SUM(salary)` with no enclosing arithmetic), check it too. 20465 if (!found[0] && expr.getExpressionType() == EExpressionType.function_t) { 20466 TFunctionCall fn = expr.getFunctionCall(); 20467 if (fn != null && fn.getFunctionName() != null) { 20468 String name = fn.getFunctionName().toString(); 20469 if (name != null && !name.isEmpty() 20470 && AGGREGATE_FUNCTION_NAMES.contains(name.toLowerCase(Locale.ROOT))) { 20471 found[0] = true; 20472 } 20473 } 20474 } 20475 return found[0]; 20476 } 20477 20478 /** 20479 * Reject window-function projections like {@code AVG(salary) OVER (...)}. 20480 * In the GSP AST these still parse as {@code function_t} with a 20481 * non-null {@code TFunctionCall.getWindowDef()}, but their semantics 20482 * are row-preserving (analytic), not row-collapsing (aggregate). Slice 20483 * 6 owns plain GROUP BY aggregation only; window functions deserve 20484 * their own slice. 20485 */ 20486 private static void rejectWindowFunctions(TExpression expr, TResultColumn rc) { 20487 if (expr == null) return; 20488 final boolean[] found = {false}; 20489 expr.acceptChildren(new TParseTreeVisitor() { 20490 @Override 20491 public void preVisit(TFunctionCall fn) { 20492 if (found[0]) return; 20493 if (fn.getWindowDef() != null) found[0] = true; 20494 } 20495 }); 20496 if (!found[0] && expr.getExpressionType() == EExpressionType.function_t) { 20497 TFunctionCall fn = expr.getFunctionCall(); 20498 if (fn != null && fn.getWindowDef() != null) found[0] = true; 20499 } 20500 if (found[0]) { 20501 throw new SemanticIRBuildException( 20502 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_USED_NOT_SUPPORTED, 20503 "result column " + rc + " uses a window function (OVER (...)); not supported yet", rc)); 20504 } 20505 } 20506 20507 /** 20508 * Slice 13: detect whether the projection root is a top-level 20509 * window-function call. Returns {@code true} iff 20510 * {@code expr.getExpressionType() == function_t} AND the function 20511 * call carries a non-null {@code TWindowDef}. The result drives 20512 * three things in {@link #buildOutputColumns}: 20513 * 20514 * <ul> 20515 * <li>The {@code skipTopLevel} arg to 20516 * {@link #rejectEmbeddedWindowFunction} so the legitimate 20517 * top-level window call is identity-skipped during embedded 20518 * detection.</li> 20519 * <li>The fast-path dispatch into 20520 * {@link #buildWindowOutputColumn} when window projections 20521 * are allowed.</li> 20522 * <li>The scalar-body / future-context rejection when window 20523 * projections are forbidden in the surrounding context 20524 * (slice-13 {@code allowWindowProjection=false}).</li> 20525 * </ul> 20526 */ 20527 private static boolean isTopLevelWindowProjection(TExpression expr) { 20528 if (expr == null) return false; 20529 if (expr.getExpressionType() != EExpressionType.function_t) return false; 20530 TFunctionCall fn = expr.getFunctionCall(); 20531 return fn != null && fn.getWindowDef() != null; 20532 } 20533 20534 /** 20535 * Slice 13: reject window functions embedded inside a larger 20536 * projection expression (mirrors slice 11's 20537 * {@link #rejectEmbeddedSubqueryInProjection}). The {@code skipTopLevel} 20538 * flag is set when the caller has identified 20539 * {@code expr} as a legitimate top-level window-function projection 20540 * — without identity-skipping that exact {@code TFunctionCall} the 20541 * visitor would reject every valid top-level window. 20542 * 20543 * <p>Visitor-only (no post-visitor fallback): unlike 20544 * {@code subquery_t} which can be wrapped in expression types that 20545 * do not surface as {@code subquery_t}, a window function is always 20546 * reachable through {@code TExpression.acceptChildren} → 20547 * {@code TFunctionCall.preVisit}. Codex round-3 MUST 1. 20548 */ 20549 private static void rejectEmbeddedWindowFunction(TExpression expr, 20550 TResultColumn rc, 20551 boolean skipTopLevel) { 20552 if (expr == null) return; 20553 final TFunctionCall topLevelFn = 20554 skipTopLevel 20555 && expr.getExpressionType() == EExpressionType.function_t 20556 ? expr.getFunctionCall() 20557 : null; 20558 final boolean[] found = {false}; 20559 expr.acceptChildren(new TParseTreeVisitor() { 20560 @Override 20561 public void preVisit(TFunctionCall fn) { 20562 if (found[0]) return; 20563 if (fn == topLevelFn) return; // identity-skip 20564 if (fn.getWindowDef() != null) found[0] = true; 20565 } 20566 }); 20567 if (found[0]) { 20568 throw new SemanticIRBuildException( 20569 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_EMBEDDED_NOT_SUPPORTED, 20570 "result column " + rc + " contains a window function embedded " 20571 + "in a larger projection expression; not supported yet " 20572 + "(only top-level window-function projections are supported)", rc)); 20573 } 20574 } 20575 20576 /** 20577 * Lower-cased function names accepted as window functions in slice 13. 20578 * Includes every name in {@link #AGGREGATE_FUNCTION_NAMES} (aggregates 20579 * can be windowed: {@code SUM(...) OVER (...)}, {@code AVG(...) OVER (...)}, 20580 * etc.) plus the analytic-only names. New analytic functions must be 20581 * added here explicitly to avoid silent acceptance of an unfamiliar 20582 * window function whose semantics the slice does not yet model. 20583 * 20584 * <p>Slice 30 exception: {@code mode} is added to 20585 * {@link #AGGREGATE_FUNCTION_NAMES} for the WITHIN GROUP path but 20586 * REMOVED from this allowlist via {@code s.remove("mode")} below — 20587 * {@code mode()} has no documented window form in any GSP-supported 20588 * vendor and the explicit removal keeps {@code mode() OVER (...)} 20589 * (which the PostgreSQL parser accepts) rejected by 20590 * {@code buildWindowOutputColumn}. 20591 */ 20592 private static final Set<String> WINDOW_FUNCTION_NAMES; 20593 static { 20594 Set<String> s = new HashSet<>(); 20595 // Aggregate names that can be windowed. 20596 s.addAll(AGGREGATE_FUNCTION_NAMES); 20597 // Slice 30: mode is an ordered-set-only aggregate; remove from the 20598 // window allowlist (it was added to AGGREGATE_FUNCTION_NAMES for the 20599 // WITHIN GROUP path but never appears as a real window function in 20600 // any GSP-supported vendor — see Slice30Test.pgModeOverStillRejected 20601 // AtOuterProjection for the lock-in). 20602 s.remove("mode"); 20603 // Analytic-only window functions. 20604 s.add("row_number"); 20605 s.add("rank"); 20606 s.add("dense_rank"); 20607 s.add("lag"); 20608 s.add("lead"); 20609 s.add("ntile"); 20610 s.add("first_value"); 20611 s.add("last_value"); 20612 s.add("percent_rank"); 20613 s.add("cume_dist"); 20614 s.add("nth_value"); 20615 WINDOW_FUNCTION_NAMES = Collections.unmodifiableSet(s); 20616 } 20617 20618 /** 20619 * Slice 13: build an {@link OutputColumn} for a top-level 20620 * window-function projection. Caller must have already verified 20621 * {@link #isTopLevelWindowProjection(TExpression)}, run the 20622 * {@link #rejectEmbeddedSubqueryInProjection} and 20623 * {@link #rejectEmbeddedWindowFunction} guards, and confirmed the 20624 * surrounding body permits window projections (i.e., the 20625 * {@code !allowWindowProjection} fast-path in 20626 * {@link #buildOutputColumns} did not fire). 20627 * 20628 * <p>The constructed {@link OutputColumn} carries: 20629 * <ul> 20630 * <li>{@code derived = true} (window functions are computed)</li> 20631 * <li>{@code aggregate = false} (window functions are 20632 * row-preserving — see slice-13 §14)</li> 20633 * <li>{@code sources} = column refs from the function args only 20634 * (PARTITION BY / OVER ORDER BY refs are excluded so that 20635 * canonical SELECT lineage matches dlineage's 20636 * function-arg-only SELECT BFS)</li> 20637 * <li>{@code windowSpec = WindowSpec(partitionRefs, orderRefs, frame)} 20638 * (slice 22 — frame may be null when the SQL has no 20639 * {@code ROWS}/{@code RANGE}/{@code GROUPS BETWEEN ...} clause)</li> 20640 * </ul> 20641 */ 20642 private static OutputColumn buildWindowOutputColumn(TResultColumn rc, 20643 TSelectSqlStatement enclosingSelect, 20644 NameBindingProvider provider) { 20645 TFunctionCall fn = rc.getExpr().getFunctionCall(); 20646 TWindowDef wd = fn.getWindowDef(); 20647 20648 // 1. Function-name allowlist (codex round-1 MUST 3). 20649 String fnName = fn.getFunctionName() == null ? null : fn.getFunctionName().toString(); 20650 if (fnName == null || !WINDOW_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) { 20651 throw new SemanticIRBuildException( 20652 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_UNSUPPORTED, 20653 "result column " + rc + " uses unsupported window function '" 20654 + fnName + "'; supported names are " + WINDOW_FUNCTION_NAMES, rc)); 20655 } 20656 20657 // 2. Reject vendor-specific function-level surfaces (codex round-1 MUST 4). 20658 if (fn.getFilterClause() != null) { 20659 throw new SemanticIRBuildException( 20660 Diagnostic.error(DiagnosticCode.WINDOW_FILTER_NOT_SUPPORTED, 20661 "result column " + rc + " uses FILTER (WHERE ...) on a " 20662 + "window function; not supported yet", rc)); 20663 } 20664 if (fn.getWithinGroup() != null) { 20665 throw new SemanticIRBuildException( 20666 Diagnostic.error(DiagnosticCode.WINDOW_WITHIN_GROUP_NOT_SUPPORTED, 20667 "result column " + rc + " uses WITHIN GROUP on a " 20668 + "window function; not supported yet", rc)); 20669 } 20670 if (fn.getOrderByList() != null && fn.getOrderByList().size() > 0) { 20671 throw new SemanticIRBuildException( 20672 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_LEVEL_ORDER_BY_NOT_SUPPORTED, 20673 "result column " + rc + " uses function-level ORDER BY " 20674 + "(LISTAGG-style); not supported yet", rc)); 20675 } 20676 if (fn.getSortClause() != null) { 20677 throw new SemanticIRBuildException( 20678 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_LEVEL_SORT_NOT_SUPPORTED, 20679 "result column " + rc + " uses function-level SORT clause; " 20680 + "not supported yet", rc)); 20681 } 20682 20683 // 3. Reject vendor-specific window-def surfaces (codex round-1 MUSTs 5, 7). 20684 if (wd.getName() != null) { 20685 throw new SemanticIRBuildException( 20686 Diagnostic.error(DiagnosticCode.WINDOW_NAMED_WINDOW_DECLARATION_NOT_SUPPORTED, 20687 "result column " + rc + " declares a named window " 20688 + "(WINDOW name AS); not supported yet", rc)); 20689 } 20690 if (wd.getReferenceName() != null) { 20691 throw new SemanticIRBuildException( 20692 Diagnostic.error(DiagnosticCode.WINDOW_NAMED_WINDOW_REFERENCE_NOT_SUPPORTED, 20693 "result column " + rc + " references a named window via " 20694 + "OVER name; not supported yet", rc)); 20695 } 20696 if (wd.getWithinGroup() != null) { 20697 throw new SemanticIRBuildException( 20698 Diagnostic.error(DiagnosticCode.WINDOW_WITHIN_GROUP_INSIDE_PROJECTION_NOT_SUPPORTED, 20699 "result column " + rc + " uses WITHIN GROUP inside the " 20700 + "OVER clause; not supported yet", rc)); 20701 } 20702 if (wd.getKeepDenseRankClause() != null) { 20703 throw new SemanticIRBuildException( 20704 Diagnostic.error(DiagnosticCode.WINDOW_KEEP_DENSE_RANK_NOT_SUPPORTED, 20705 "result column " + rc + " uses KEEP DENSE_RANK FIRST/LAST; " 20706 + "not supported yet", rc)); 20707 } 20708 if (wd.getDistributeBy() != null) { 20709 throw new SemanticIRBuildException( 20710 Diagnostic.error(DiagnosticCode.WINDOW_DISTRIBUTE_BY_NOT_SUPPORTED, 20711 "result column " + rc + " uses Hive DISTRIBUTE BY in window; " 20712 + "not supported yet", rc)); 20713 } 20714 if (wd.getClusterBy() != null) { 20715 throw new SemanticIRBuildException( 20716 Diagnostic.error(DiagnosticCode.WINDOW_CLUSTER_BY_NOT_SUPPORTED, 20717 "result column " + rc + " uses Hive CLUSTER BY in window; " 20718 + "not supported yet", rc)); 20719 } 20720 if (wd.getSortBy() != null) { 20721 throw new SemanticIRBuildException( 20722 Diagnostic.error(DiagnosticCode.WINDOW_SORT_BY_NOT_SUPPORTED, 20723 "result column " + rc + " uses Hive SORT BY in window; " 20724 + "not supported yet", rc)); 20725 } 20726 // Slice 22: frame clauses are now built into WindowSpec.frame; the 20727 // slice-13 wholesale rejection is gone. Frame build happens AFTER 20728 // empty-OVER reject below so a frame-only OVER (...) fails on 20729 // empty-OVER first (the more user-tuned error message). 20730 20731 // 4. Reject empty OVER () (slice-13 boundary; dlineage parity — 20732 // empty OVER () is byte-identical to a plain aggregate in the XML). 20733 TPartitionClause pc = wd.getPartitionClause(); 20734 TOrderBy ob = wd.getOrderBy(); 20735 boolean hasPartitionBy = pc != null 20736 && pc.getExpressionList() != null 20737 && pc.getExpressionList().size() > 0; 20738 boolean hasOverOrderBy = ob != null 20739 && ob.getItems() != null 20740 && ob.getItems().size() > 0; 20741 if (!hasPartitionBy && !hasOverOrderBy) { 20742 throw new SemanticIRBuildException( 20743 Diagnostic.error(DiagnosticCode.WINDOW_EMPTY_OVER_NOT_SUPPORTED, 20744 "result column " + rc + " uses empty OVER (); not supported yet " 20745 + "(dlineage XML cannot discriminate from a plain aggregate)", rc)); 20746 } 20747 20748 // 5. Reject Hive PARTITION BY ... SORT (...). 20749 if (pc != null && pc.getSortedColumns() != null && pc.getSortedColumns().size() > 0) { 20750 throw new SemanticIRBuildException( 20751 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_SORT_NOT_SUPPORTED, 20752 "result column " + rc + " uses Hive PARTITION BY ... SORT (...); " 20753 + "not supported yet", rc)); 20754 } 20755 20756 // 6. Build PARTITION BY refs. 20757 List<ColumnRef> partitionRefs = hasPartitionBy 20758 ? buildWindowPartitionRefs(pc, rc, enclosingSelect, provider) 20759 : new ArrayList<ColumnRef>(); 20760 20761 // 7. Build OVER ORDER BY refs. 20762 List<ColumnRef> orderRefs = hasOverOrderBy 20763 ? buildWindowOrderRefs(ob, rc, enclosingSelect, provider) 20764 : new ArrayList<ColumnRef>(); 20765 20766 // 8. Build frame (slice 22). Null when the SQL has no ROWS/RANGE/ 20767 // GROUPS clause inside OVER (...). 20768 WindowFrame frame = wd.getWindowFrame() == null 20769 ? null 20770 : buildWindowFrame(wd.getWindowFrame(), rc); 20771 20772 // 9. Build sources from args only — PARTITION BY / OVER ORDER BY 20773 // refs must NOT leak into OutputColumn.sources because canonical 20774 // SELECT lineage on the dlineage side only walks fdd edges 20775 // (function args), not fdr edges (PARTITION BY / OVER ORDER BY). 20776 List<ColumnRef> sources = (fn.getArgs() == null || fn.getArgs().size() == 0) 20777 ? new ArrayList<ColumnRef>() 20778 : collectColumnRefs(fn.getArgs(), provider); 20779 20780 // 10. Construct OutputColumn. aggregate=false ALWAYS for window 20781 // functions (row-preserving). The OutputColumn ctor enforces 20782 // the windowSpec!=null AND aggregate=false invariant. 20783 String name = effectiveOutputName(rc); 20784 return new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ false, 20785 sources, new WindowSpec(partitionRefs, orderRefs, frame)); 20786 } 20787 20788 /** 20789 * Slice 22: build a {@link WindowFrame} from a parser 20790 * {@link TWindowFrame}. Frame information is presentation-only 20791 * (dlineage XML harvests no frame data — see 20792 * {@code DataFlowAnalyzer.java:20558-20575}); this helper captures 20793 * the surface shape into the IR for governance consumers without 20794 * touching the canonical lineage model. 20795 * 20796 * <p>Direct field access via {@link TWindowFrame#getStartBoundary()} / 20797 * {@link TWindowFrame#getEndBoundary()}; visitors are NOT used because 20798 * {@code TWindowFrame.acceptChildren()} doesn't recurse into the 20799 * boundaries (codex round-1 SHOULD 3). 20800 * 20801 * <p>Order of guards (codex round-2 SHOULD 2): EXCLUDE first so the 20802 * error message is tuned to the actual surface; then null-guard the 20803 * boundary type (defensive — current parsers always pass it); then 20804 * map the {@code EBoundaryType} via an exhaustive switch 20805 * (slice-14 process lesson #17 — no catch-all); then check the 20806 * {@code boundaryNumber} expression type and reject non-constant 20807 * offsets (codex round-1 SHOULD 1 — PG {@code simple_object_name_t} 20808 * and ANSI {@code parenthesis_t} are reachable). 20809 * 20810 * <p>Null guards on the frame's {@link ELimitRowType} and 20811 * {@code startBoundary} fields are defensive / forward-compat: every 20812 * vendor grammar surveyed (codex round-4 NOTE 1) passes these 20813 * arguments together when constructing a {@code TWindowFrame}, so the 20814 * guards are unexercised by current parsers but protect against 20815 * future parser drift. 20816 */ 20817 private static WindowFrame buildWindowFrame(TWindowFrame wf, TResultColumn rc) { 20818 // Defensive null guards (codex round-2 MUST 2; codex round-4 20819 // SHOULD 1 — labelled DEFENSIVE / FORWARD-COMPAT). 20820 if (wf.getLimitRowType() == null) { 20821 throw new SemanticIRBuildException( 20822 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_LIMIT_ROW_TYPE, 20823 "result column " + rc + " has a frame with null limitRowType " 20824 + "(forward-compat / unexpected parser shape); not supported", rc)); 20825 } 20826 if (wf.getStartBoundary() == null) { 20827 throw new SemanticIRBuildException( 20828 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_START_BOUNDARY, 20829 "result column " + rc + " has a frame with null start boundary " 20830 + "(forward-compat / unexpected parser shape); not supported", rc)); 20831 } 20832 WindowFrame.Unit unit = mapFrameUnit(wf.getLimitRowType()); 20833 FrameBound start = buildFrameBound(wf.getStartBoundary(), rc, /*end=*/ false); 20834 FrameBound end = wf.getEndBoundary() == null 20835 ? null 20836 : buildFrameBound(wf.getEndBoundary(), rc, /*end=*/ true); 20837 return new WindowFrame(unit, start, end); 20838 } 20839 20840 /** 20841 * Slice 22: map the parser's {@link ELimitRowType} to the IR's 20842 * {@link WindowFrame.Unit}. Exhaustive switch (slice-14 process 20843 * lesson #17 — no catch-all); a future enum addition fails closed. 20844 */ 20845 private static WindowFrame.Unit mapFrameUnit(ELimitRowType type) { 20846 switch (type) { 20847 case Rows: 20848 return WindowFrame.Unit.ROWS; 20849 case Range: 20850 return WindowFrame.Unit.RANGE; 20851 case Groups: 20852 return WindowFrame.Unit.GROUPS; 20853 default: 20854 throw new SemanticIRBuildException( 20855 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNSUPPORTED_LIMIT_ROW_TYPE, 20856 "unsupported window frame limitRowType: " + type, null)); 20857 } 20858 } 20859 20860 /** 20861 * Slice 22: build a {@link FrameBound} from a parser 20862 * {@link TWindowFrameBoundary}. The {@code end} parameter is for 20863 * error messages only (start vs end disambiguation). 20864 * 20865 * <p>Per-bound check order: EXCLUDE → boundaryType-null → kind switch 20866 * → boundaryNumber shape (codex round-2 SHOULD 2 + slice-22 invariant). 20867 */ 20868 private static FrameBound buildFrameBound(TWindowFrameBoundary boundary, 20869 TResultColumn rc, 20870 boolean end) { 20871 String which = end ? "end" : "start"; 20872 20873 // (a) EXCLUDE first (codex round-1 MUST 2 + Netezza probe). 20874 // Netezza populates getExclusionClause() on the END boundary for 20875 // EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS; rejecting here 20876 // surfaces the unsupported clause with a tuned message rather 20877 // than letting the offset-shape check fire on an unrelated 20878 // surface. 20879 if (boundary.getExclusionClause() != null) { 20880 throw new SemanticIRBuildException( 20881 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_EXCLUDE_NOT_SUPPORTED, 20882 "result column " + rc + " has a frame " + which 20883 + " boundary with EXCLUDE clause " 20884 + "(EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS); " 20885 + "not supported yet", rc)); 20886 } 20887 20888 // (b) Null-guard the boundary type (defensive). 20889 if (boundary.getBoundaryType() == null) { 20890 throw new SemanticIRBuildException( 20891 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_BOUNDARY_TYPE, 20892 "result column " + rc + " has a frame " + which 20893 + " boundary with null boundaryType " 20894 + "(forward-compat / unexpected parser shape); not supported", rc)); 20895 } 20896 20897 // (c) Map the kind via exhaustive switch. 20898 FrameBound.Kind kind = mapBoundaryKind(boundary.getBoundaryType()); 20899 20900 // (d) Capture the optional offset literal. Reject non-constant 20901 // offsets (codex round-1 SHOULD 1 + slice-22 PG/ANSI probe — PG 20902 // accepts simple_object_name_t (column ROWS BETWEEN x PRECEDING ...), 20903 // ANSI accepts parenthesis_t ((x+1))). 20904 String offsetLiteral = null; 20905 TExpression offsetExpr = boundary.getBoundaryNumber(); 20906 if (offsetExpr != null) { 20907 // Slice-22 codex impl-review SHOULD 1: when the kind forbids 20908 // an offset (UNBOUNDED_*/CURRENT_ROW), reject with 20909 // SemanticIRBuildException so the failure stays inside the 20910 // builder's error contract — without this guard, a parser 20911 // surfacing a stray boundary number on CURRENT_ROW would 20912 // escape as IllegalArgumentException from 20913 // FrameBound's ctor. 20914 boolean offsetAllowed = (kind == FrameBound.Kind.PRECEDING 20915 || kind == FrameBound.Kind.FOLLOWING); 20916 if (!offsetAllowed) { 20917 throw new SemanticIRBuildException( 20918 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNEXPECTED_OFFSET, 20919 "result column " + rc + " has a frame " + which 20920 + " boundary of kind " + kind 20921 + " carrying an unexpected offset '" 20922 + offsetExpr + "' (forward-compat / " 20923 + "unexpected parser shape); not supported", rc)); 20924 } 20925 EExpressionType offsetType = offsetExpr.getExpressionType(); 20926 if (offsetType != EExpressionType.simple_constant_t) { 20927 throw new SemanticIRBuildException( 20928 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_OFFSET_NON_CONSTANT, 20929 "result column " + rc + " has a frame " + which 20930 + " offset that is not a simple constant " 20931 + "(got " + offsetType + " '" + offsetExpr + "'); " 20932 + "not supported yet", rc)); 20933 } 20934 offsetLiteral = offsetExpr.toString(); 20935 } 20936 return new FrameBound(kind, offsetLiteral); 20937 } 20938 20939 /** 20940 * Slice 22: map the parser's {@link EBoundaryType} to the IR's 20941 * {@link FrameBound.Kind}. Exhaustive switch (slice-14 process 20942 * lesson #17). 20943 */ 20944 private static FrameBound.Kind mapBoundaryKind(EBoundaryType type) { 20945 switch (type) { 20946 case ebtUnboundedPreceding: 20947 return FrameBound.Kind.UNBOUNDED_PRECEDING; 20948 case ebtUnboundedFollowing: 20949 return FrameBound.Kind.UNBOUNDED_FOLLOWING; 20950 case ebtCurrentRow: 20951 return FrameBound.Kind.CURRENT_ROW; 20952 case ebtPreceding: 20953 return FrameBound.Kind.PRECEDING; 20954 case ebtFollowing: 20955 return FrameBound.Kind.FOLLOWING; 20956 default: 20957 throw new SemanticIRBuildException( 20958 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNSUPPORTED_BOUNDARY_TYPE, 20959 "unsupported frame boundary type: " + type, null)); 20960 } 20961 } 20962 20963 /** 20964 * Slice 13: build the PARTITION BY ref list. Every item must be a 20965 * physical column reference ({@code simple_object_name_t} resolving 20966 * via the provider to {@code EXACT_MATCH}). Other shapes are 20967 * rejected with a tuned message — slice-9 / slice-13 20968 * rejection-over-silent-loss. 20969 */ 20970 private static List<ColumnRef> buildWindowPartitionRefs(TPartitionClause pc, 20971 TResultColumn rc, 20972 TSelectSqlStatement enclosingSelect, 20973 NameBindingProvider provider) { 20974 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 20975 TExpressionList list = pc.getExpressionList(); 20976 for (int i = 0; i < list.size(); i++) { 20977 TExpression item = list.getExpression(i); 20978 EExpressionType t = item.getExpressionType(); 20979 if (t == EExpressionType.simple_constant_t) { 20980 throw new SemanticIRBuildException( 20981 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_LITERAL, 20982 "result column " + rc + " has PARTITION BY literal '" 20983 + item + "'; not supported yet", rc)); 20984 } 20985 if (t == EExpressionType.subquery_t || item.getSubQuery() != null) { 20986 throw new SemanticIRBuildException( 20987 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_SUBQUERY, 20988 "result column " + rc + " has PARTITION BY containing " 20989 + "a subquery; not supported yet", rc)); 20990 } 20991 if (t == EExpressionType.function_t) { 20992 throw new SemanticIRBuildException( 20993 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_AGGREGATE, 20994 "result column " + rc + " has PARTITION BY containing " 20995 + "a function call '" + item + "'; not supported yet", rc)); 20996 } 20997 if (t != EExpressionType.simple_object_name_t) { 20998 throw new SemanticIRBuildException( 20999 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_UNKNOWN_REFERENCE, 21000 "result column " + rc + " has PARTITION BY using an " 21001 + "unsupported expression shape (" + t + "): " + item, rc)); 21002 } 21003 // Defensive: reject if the parser/resolver has retyped this 21004 // item as a projection alias. Current Oracle parsers leave 21005 // PARTITION BY <alias> as dbType=column even for projection 21006 // aliases; this guard fires for vendors that may behave 21007 // differently. The slice-19 discriminator below catches the 21008 // Oracle case where dbType stays "column" but the binding 21009 // came from the schema-less inferred-from-usage fallback. 21010 TObjectName on = item.getObjectOperand(); 21011 if (on != null && on.getDbObjectType() == EDbObjectType.column_alias) { 21012 throw new SemanticIRBuildException( 21013 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_PROJECTION_ALIAS, 21014 "result column " + rc + " has PARTITION BY referencing " 21015 + "a projection alias '" + item + "'; not supported yet", rc)); 21016 } 21017 // Slice 19: alias-bound discriminator. Reject when the 21018 // resolver's binding lacks definite FROM-scope evidence and 21019 // the name matches a calculated SELECT-list alias of the 21020 // enclosing SELECT. Without schema metadata the resolver 21021 // cannot tell alias from real column; rejection-over-silent- 21022 // guess matches the slice-9/-10/-13 invariant. 21023 if (on != null && provider.isCalculatedProjectionAliasFallback(on, enclosingSelect)) { 21024 throw new SemanticIRBuildException( 21025 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_CALCULATED_ALIAS, 21026 "result column " + rc + " has PARTITION BY referencing a " 21027 + "SELECT-list alias on a calculated expression ('" + item 21028 + "'); not supported yet — requires schema metadata to " 21029 + "discriminate alias from base column", rc)); 21030 } 21031 // Resolve the column ref through the provider. EXACT_MATCH is 21032 // required (slice-1 fail-fast invariant); collectColumnRefs 21033 // does the heavy lifting and rejects anything else. 21034 List<ColumnRef> built = collectColumnRefs(item, provider); 21035 if (built.isEmpty()) { 21036 throw new SemanticIRBuildException( 21037 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_ITEM_UNUSABLE, 21038 "result column " + rc + " has PARTITION BY item '" 21039 + item + "' with no resolvable column refs", rc)); 21040 } 21041 refs.addAll(built); 21042 } 21043 return new ArrayList<>(refs); 21044 } 21045 21046 /** 21047 * Slice 13: build the OVER ORDER BY ref list. Every sort key must 21048 * be a physical column reference (mirrors slice-9 outer ORDER BY 21049 * rejection set). Ordinals, projection aliases, expressions, 21050 * subqueries, window functions, and SIBLINGS / RESET WHEN are 21051 * rejected with tuned messages. 21052 */ 21053 private static List<ColumnRef> buildWindowOrderRefs(TOrderBy ob, 21054 TResultColumn rc, 21055 TSelectSqlStatement enclosingSelect, 21056 NameBindingProvider provider) { 21057 // Slice-13 codex impl-review MUST 2: defense in depth, mirror outer 21058 // ORDER BY's slice-9 SIBLINGS / RESET WHEN guards. 21059 if (ob.isSiblings()) { 21060 throw new SemanticIRBuildException( 21061 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_SIBLINGS_NOT_SUPPORTED, 21062 "result column " + rc + " has OVER ORDER BY SIBLINGS; not supported yet " 21063 + "(Oracle hierarchical-query syntax in window OVER clause)", rc)); 21064 } 21065 if (ob.getResetWhenCondition() != null) { 21066 throw new SemanticIRBuildException( 21067 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_RESET_WHEN_NOT_SUPPORTED, 21068 "result column " + rc + " has OVER ORDER BY ... RESET WHEN; not supported yet " 21069 + "(Teradata window-style restart)", rc)); 21070 } 21071 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 21072 TOrderByItemList items = ob.getItems(); 21073 for (int i = 0; i < items.size(); i++) { 21074 TOrderByItem item = items.getOrderByItem(i); 21075 TExpression key = item.getSortKey(); 21076 if (key == null) { 21077 throw new SemanticIRBuildException( 21078 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_NULL_SORT_KEY, 21079 "result column " + rc + " has OVER ORDER BY item with " 21080 + "null sort key", rc)); 21081 } 21082 EExpressionType t = key.getExpressionType(); 21083 if (t == EExpressionType.simple_constant_t) { 21084 // Catches both ordinal sort keys and string-literal sort keys. 21085 throw new SemanticIRBuildException( 21086 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_LITERAL, 21087 "result column " + rc + " has OVER ORDER BY literal/ordinal '" 21088 + key + "'; not supported yet", rc)); 21089 } 21090 if (t == EExpressionType.subquery_t || key.getSubQuery() != null) { 21091 throw new SemanticIRBuildException( 21092 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_SUBQUERY, 21093 "result column " + rc + " has OVER ORDER BY containing a " 21094 + "subquery; not supported yet", rc)); 21095 } 21096 if (t == EExpressionType.function_t) { 21097 TFunctionCall innerFn = key.getFunctionCall(); 21098 if (innerFn != null && innerFn.getWindowDef() != null) { 21099 throw new SemanticIRBuildException( 21100 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_WINDOW_FUNCTION, 21101 "result column " + rc + " has OVER ORDER BY containing a " 21102 + "window function; not supported yet", rc)); 21103 } 21104 throw new SemanticIRBuildException( 21105 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_AGGREGATE, 21106 "result column " + rc + " has OVER ORDER BY containing a " 21107 + "function call '" + key + "'; not supported yet", rc)); 21108 } 21109 if (t != EExpressionType.simple_object_name_t) { 21110 throw new SemanticIRBuildException( 21111 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_UNKNOWN_REFERENCE, 21112 "result column " + rc + " has OVER ORDER BY using an " 21113 + "unsupported expression shape (" + t + "): " + key, rc)); 21114 } 21115 // NOTE: Oracle's parser DOES retype OVER ORDER BY refs to 21116 // column_alias when they match a SELECT alias (mirrors 21117 // slice-9 outer ORDER BY behaviour). The defensive 21118 // column_alias guard from PARTITION BY is intentionally 21119 // omitted here — `collectColumnRefs` already skips 21120 // column_alias-typed nodes, and the empty-refs guard below 21121 // catches the resulting unresolvable item with a clear 21122 // message. Outer ORDER BY aliases use the same path. 21123 // 21124 // Slice 19: defensive symmetry with PARTITION BY. A future 21125 // vendor whose parser does NOT retype OVER ORDER BY refs to 21126 // column_alias would land here as `simple_object_name_t` 21127 // with an inferred-from-usage resolution; the discriminator 21128 // catches that case before collectColumnRefs descends. As of 21129 // slice 19, every supported vendor retypes (probe in 21130 // §14.21), so this branch is unreachable in current tests 21131 // — kept for forward-compat. 21132 TObjectName on = key.getObjectOperand(); 21133 if (on != null && provider.isCalculatedProjectionAliasFallback(on, enclosingSelect)) { 21134 throw new SemanticIRBuildException( 21135 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_CALCULATED_ALIAS, 21136 "result column " + rc + " has OVER ORDER BY referencing a " 21137 + "SELECT-list alias on a calculated expression ('" + key 21138 + "'); not supported yet — requires schema metadata to " 21139 + "discriminate alias from base column", rc)); 21140 } 21141 List<ColumnRef> built = collectColumnRefs(key, provider); 21142 if (built.isEmpty()) { 21143 throw new SemanticIRBuildException( 21144 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_ITEM_UNUSABLE, 21145 "result column " + rc + " has OVER ORDER BY item '" 21146 + key + "' with no resolvable column refs", rc)); 21147 } 21148 refs.addAll(built); 21149 } 21150 return new ArrayList<>(refs); 21151 } 21152 21153 /** 21154 * Slice 13: reject any window function ({@code FUNC(...) OVER (...)}) 21155 * appearing in a {@link TParseTreeNode} subtree. Used by the 21156 * WHERE / GROUP BY / JOIN ON guards before the visitor would 21157 * otherwise descend into the OVER clause and leak PARTITION BY / 21158 * OVER ORDER BY refs into the wrong column-ref bucket. Mirrors 21159 * {@link #rejectHavingWindowFunction} (slice 10) and 21160 * {@link #rejectOrderByWindowFunction} (slice 9). 21161 */ 21162 /** 21163 * Slice 85 — admit RETURNING (PG / Oracle) and OUTPUT (SQL Server) 21164 * projections on INSERT / UPDATE / DELETE statements. Returns the 21165 * list of {@link OutputColumn}s for the {@code returningColumns} 21166 * slot on the DML's {@link StatementGraph}, and appends one 21167 * {@link LineageEdge} per source column ref to {@code lineage}: 21168 * <pre> 21169 * from = LineageRef.statementOutput(dmlIdx, returningColumns[i].name) 21170 * to = LineageRef.tableColumn(targetQName, sourceColumnName) 21171 * </pre> 21172 * (consumer ← producer direction; mirrors slice-78 INSERT's 21173 * {@code target ← source} convention but with the DML's own output 21174 * as the consumer and the target table's column as the producer.) 21175 * 21176 * <p>At most one of {@code ret} and {@code out} is non-null. When 21177 * both are null (no RETURNING / OUTPUT clause), returns an empty 21178 * list and emits no edges. 21179 * 21180 * <p>Reject ordering (codex round-3 Q2 BLOCKING fix — two-pass): 21181 * <ol> 21182 * <li>Pass 1, statement-level: empty projection list → 21183 * {@link DiagnosticCode#RETURNING_EMPTY_PROJECTION}.</li> 21184 * <li>Pass 1.5, OUTPUT-only DML-kind / pseudo-table mismatch scan: 21185 * INSERT with any {@code DELETED.col} → 21186 * {@link DiagnosticCode#OUTPUT_DELETED_ON_INSERT_NOT_SUPPORTED}; 21187 * DELETE with any {@code INSERTED.col} → 21188 * {@link DiagnosticCode#OUTPUT_INSERTED_ON_DELETE_NOT_SUPPORTED}. 21189 * Fires on the first matching column regardless of position.</li> 21190 * <li>Pass 2, per-column (in SQL declaration order): 21191 * <ul> 21192 * <li>{@code *} → {@link DiagnosticCode#RETURNING_STAR_NOT_SUPPORTED}</li> 21193 * <li>any subquery → 21194 * {@link DiagnosticCode#RETURNING_HAS_SUBQUERY_NOT_SUPPORTED}</li> 21195 * <li>any window function over a base ref → reuses 21196 * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} via 21197 * {@link #rejectWindowFunctionInScope}</li> 21198 * <li>any aggregate function over a base ref → 21199 * {@link DiagnosticCode#RETURNING_HAS_AGGREGATE_NOT_SUPPORTED} 21200 * (aggregates are not legal in DML RETURNING / OUTPUT per 21201 * spec — fires defensively when parser admits them)</li> 21202 * </ul> 21203 * </li> 21204 * </ol> 21205 * 21206 * <p>OUTPUT_INTO_NOT_SUPPORTED is rejected at the caller (before 21207 * any FROM walk / SET / WHERE processing) so multi-violation shapes 21208 * route through the cheaper structural code first. 21209 * 21210 * @param ret RETURNING clause; null when this DML uses OUTPUT or 21211 * no projection at all 21212 * @param out OUTPUT clause; null when this DML uses RETURNING or 21213 * no projection at all 21214 * @param dmlKind "INSERT" / "UPDATE" / "DELETE" — only relevant for 21215 * the pseudo-table mismatch scan (UPDATE admits both 21216 * INSERTED and DELETED; INSERT admits only INSERTED; 21217 * DELETE admits only DELETED) 21218 * @param targetQName the target table's qualified name; used as the 21219 * {@code to} endpoint of every emitted LineageEdge 21220 * @param fromSideAliasToStmtIndex slice-123 alias→statement-index map 21221 * keyed lowercase for SUBQUERY-kind FROM-side relations 21222 * that own a producing {@link StatementGraph} 21223 * (slice-84 USING-(SELECT) sub / slice-106 USING-CTE on 21224 * DELETE; slice-82/83/105 FROM-subquery / CTE-as-relation 21225 * on UPDATE). When a RETURNING/OUTPUT source ref resolves 21226 * to such a relation, the emitted edge's {@code to} 21227 * endpoint is the producer's 21228 * {@code STATEMENT_OUTPUT(idx, col)} rather than the 21229 * fictitious {@code TABLE_COLUMN(<alias>, col)}. Pass an 21230 * empty map to keep the slice-85 TABLE_COLUMN behaviour 21231 * (INSERT, MERGE) 21232 * @param provider name-binding provider; same instance used for 21233 * SET RHS / WHERE / JOIN ON ref collection so 21234 * FROM-side relation refs (slice-82 joined UPDATE, 21235 * slice-84 joined DELETE) resolve correctly 21236 * @param dmlIdx the DML statement's position in 21237 * {@link SemanticProgram#getStatements()}; used as the 21238 * {@code statementIndex} on the {@code from} endpoint 21239 * @param lineage in/out: collected edges are appended here 21240 * @param anchor parse-tree anchor for diagnostics 21241 */ 21242 private static List<OutputColumn> buildReturningColumns( 21243 TReturningClause ret, 21244 TOutputClause out, 21245 String dmlKind, 21246 String targetQName, 21247 String targetAlias, 21248 TTable targetTable, 21249 List<RelationSource> fromSideRelations, 21250 Map<String, Integer> fromSideAliasToStmtIndex, 21251 NameBindingProvider provider, 21252 int dmlIdx, 21253 List<LineageEdge> lineage, 21254 TParseTreeNode anchor) { 21255 if (ret == null && out == null) { 21256 return Collections.emptyList(); 21257 } 21258 // Oracle host-variable form: `RETURNING col INTO :v` — AST shape 21259 // is columnValueList + variableList populated, resultExprList null. 21260 // Slice 88 admits it: extract column exprs from columnValueList, 21261 // discard variableList (bind sinks have no semantic IR relevance). 21262 // The still-unsupported degenerate case (resultExprList=null AND 21263 // columnValueList=null) keeps RETURNING_INTO_NOT_SUPPORTED so the 21264 // code stays declared-not-unreachable per the slice-71/72/82 precedent. 21265 boolean isOracleInto = (ret != null && ret.getResultExprList() == null 21266 && ret.getColumnValueList() != null); 21267 if (ret != null && ret.getResultExprList() == null && !isOracleInto) { 21268 throw new SemanticIRBuildException(Diagnostic.error( 21269 DiagnosticCode.RETURNING_INTO_NOT_SUPPORTED, 21270 "Oracle `RETURNING col INTO :host_var` with no column list " 21271 + "is not supported; admits the standard INTO form only", 21272 anchor)); 21273 } 21274 // Extract the source column list. 21275 TResultColumnList items = null; 21276 TExpressionList intoExprs = null; 21277 if (isOracleInto) { 21278 intoExprs = ret.getColumnValueList(); 21279 } else if (ret != null) { 21280 items = ret.getResultExprList(); 21281 } else { 21282 items = out.getSelectItemList(); 21283 } 21284 int colCount = isOracleInto 21285 ? (intoExprs == null ? 0 : intoExprs.size()) 21286 : (items == null ? 0 : items.size()); 21287 // Pass 1: empty projection list (defensive — the parser usually 21288 // refuses to produce an empty list, but a malformed AST should 21289 // surface a clean diagnostic). 21290 if (colCount == 0) { 21291 throw new SemanticIRBuildException(Diagnostic.error( 21292 DiagnosticCode.RETURNING_EMPTY_PROJECTION, 21293 dmlKind + (ret != null ? " RETURNING" : " OUTPUT") 21294 + " clause has no projection columns", 21295 anchor)); 21296 } 21297 // Pass 1.5: OUTPUT-only DML-kind / pseudo-table mismatch scan 21298 // (codex round-1 Q4 BLOCKING — deep-walk all TObjectName leaves 21299 // so compound exprs like `OUTPUT INSERTED.a + DELETED.b` also 21300 // reject deterministically). The parser sets pseudoTableType 21301 // on the fieldAttr for SIMPLE column references but leaves 21302 // it null on the leaf TObjectNames inside compound expressions; 21303 // we detect those by checking the objectToken spelling against 21304 // "INSERTED" / "DELETED". 21305 // The Oracle INTO path skips this scan (no INSERTED/DELETED pseudo-tables). 21306 if (out != null && !isOracleInto) { 21307 final String targetAliasFinal = targetAlias; 21308 final String targetQNameFinal = targetQName; 21309 final List<RelationSource> relsFinal = fromSideRelations; 21310 for (int i = 0; i < items.size(); i++) { 21311 TResultColumn rc = items.getResultColumn(i); 21312 final String dmlKindFinal = dmlKind; 21313 final TResultColumn rcFinal = rc; 21314 scanOutputPseudoTableLeaves(rc.getExpr(), 21315 new TParseTreeVisitor() { 21316 @Override 21317 public void preVisit(TObjectName n) { 21318 EPseudoTableType pt = detectPseudoTable( 21319 n, rcFinal, targetAliasFinal, 21320 targetQNameFinal, relsFinal); 21321 if (pt == EPseudoTableType.deleted 21322 && "INSERT".equals(dmlKindFinal)) { 21323 throw new SemanticIRBuildException(Diagnostic.error( 21324 DiagnosticCode.OUTPUT_DELETED_ON_INSERT_NOT_SUPPORTED, 21325 "INSERT OUTPUT references DELETED." 21326 + bareColumnNameOf(n) 21327 + " but there is no deleted-row " 21328 + "image on INSERT; use INSERTED.* instead", 21329 rcFinal)); 21330 } 21331 if (pt == EPseudoTableType.inserted 21332 && "DELETE".equals(dmlKindFinal)) { 21333 throw new SemanticIRBuildException(Diagnostic.error( 21334 DiagnosticCode.OUTPUT_INSERTED_ON_DELETE_NOT_SUPPORTED, 21335 "DELETE OUTPUT references INSERTED." 21336 + bareColumnNameOf(n) 21337 + " but there is no inserted-row " 21338 + "image on DELETE; use DELETED.* instead", 21339 rcFinal)); 21340 } 21341 } 21342 }); 21343 } 21344 } 21345 // Pass 2: per-column. Build OutputColumns, emit edges. 21346 List<OutputColumn> outputs = new ArrayList<>(colCount); 21347 for (int i = 0; i < colCount; i++) { 21348 // For the Oracle INTO path rc is null — the INTO column list 21349 // carries bare expressions, not TResultColumn wrappers. 21350 TResultColumn rc = isOracleInto ? null 21351 : items.getResultColumn(i); 21352 TExpression expr = isOracleInto 21353 ? intoExprs.getExpression(i) 21354 : (rc == null ? null : rc.getExpr()); 21355 if (expr == null) { 21356 throw new SemanticIRBuildException(Diagnostic.error( 21357 DiagnosticCode.RESULT_COLUMN_NULL_EXPRESSION, 21358 dmlKind + (ret != null ? " RETURNING" : " OUTPUT") 21359 + " column #" + (i + 1) + " has no expression", 21360 rc != null ? rc : anchor)); 21361 } 21362 // Slice 98 — MSSQL MERGE OUTPUT `$action` pseudo-column. 21363 // Returns the merge action string per output row ('INSERT' / 21364 // 'UPDATE' / 'DELETE') — it has no underlying base column. 21365 // Detected case-insensitively because parser tokens come out 21366 // as `$action` regardless of how the user wrote it; bracketed 21367 // `[$action]` is a delimited identifier and is NOT treated 21368 // as the pseudo-column (codex Q1 confirmed YES — slice-98 21369 // detection is literal text equality on the un-bracketed 21370 // spelling). The check is gated on dmlKind="MERGE" so 21371 // INSERT/UPDATE/DELETE OUTPUT (slice 85) are unaffected. 21372 if ("MERGE".equals(dmlKind) 21373 && isMergeActionPseudoColumn(expr)) { 21374 String actionName = (rc != null && rc.getColumnAlias() != null 21375 && !rc.getColumnAlias().toString().isEmpty()) 21376 ? rc.getColumnAlias().toString() 21377 : expr.toString(); 21378 outputs.add(new OutputColumn(actionName, 21379 /*derived=*/ true, 21380 /*aggregate=*/ false, 21381 Collections.<ColumnRef>emptyList())); 21382 // No LineageEdge — $action has no producer column. 21383 continue; 21384 } 21385 // STAR check — bare `RETURNING *` parses as 21386 // simple_object_name_t with toString="*"; qualified star 21387 // forms like `RETURNING t.*` / `OUTPUT inserted.*` / 21388 // `OUTPUT deleted.*` parse as simple_object_name_t with 21389 // partToken (and getColumnNameOnly()) equal to "*" 21390 // (codex round-4 BLOCKING fix). 21391 // Slice 90: standard RETURNING star attempts catalog-backed expansion. 21392 // Slice 99: MSSQL MERGE OUTPUT INSERTED.* / DELETED.* 21393 // attempts catalog-backed expansion against the target table. 21394 // Oracle INTO star and non-MERGE OUTPUT star (and bare / 21395 // target-alias / source-alias MERGE OUTPUT star) remain rejected. 21396 if (isStarReference(expr)) { 21397 if (isOracleInto) { 21398 // Oracle INTO star: keep existing reject. 21399 throw new SemanticIRBuildException(Diagnostic.error( 21400 DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED, 21401 dmlKind + " RETURNING INTO * star expansion " 21402 + "is not yet supported; use explicit column names", 21403 rc != null ? rc : expr)); 21404 } 21405 if (out != null) { 21406 // Slice 99 / Slice 100 — MSSQL pseudo-table 21407 // OUTPUT INSERTED.* / DELETED.* routes to catalog- 21408 // backed expansion against the target table. The 21409 // pseudo-table discriminator is the parser-set 21410 // EPseudoTableType.inserted / .deleted flag on the 21411 // star qualifier (slice-85 primary discriminator). 21412 // Slice 99 lifted the reject for dmlKind="MERGE"; 21413 // slice 100 generalises to all DML kinds (INSERT / 21414 // UPDATE / DELETE) — the parser sets pseudoTableType 21415 // identically on non-MERGE OUTPUT stars, and Pass 21416 // 1.5 has already rejected cross-direction 21417 // mismatches (INSERT OUTPUT DELETED.* / 21418 // DELETE OUTPUT INSERTED.*) before this branch. 21419 // OUTPUT *, t.*, s.* (no pseudo-table marker) still 21420 // reject — they're either ambiguous (bare *) or 21421 // refer to non-pseudo relations. 21422 EPseudoTableType pseudo = EPseudoTableType.none; 21423 TObjectName starObj = expr.getObjectOperand(); 21424 if (starObj != null 21425 && starObj.getPseudoTableType() != null) { 21426 pseudo = starObj.getPseudoTableType(); 21427 } 21428 if (pseudo == EPseudoTableType.inserted 21429 || pseudo == EPseudoTableType.deleted) { 21430 expandOutputPseudoTableStarColumns( 21431 expr, rc, pseudo, dmlKind, 21432 targetTable, targetQName, 21433 provider, dmlIdx, lineage, anchor, outputs); 21434 continue; 21435 } 21436 // Non-pseudo OUTPUT star (bare *, target-alias *, 21437 // source-alias *): keep existing reject. 21438 throw new SemanticIRBuildException(Diagnostic.error( 21439 DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED, 21440 dmlKind + " OUTPUT * star expansion is not " 21441 + "yet supported; use explicit column names", 21442 rc != null ? rc : expr)); 21443 } 21444 // Standard RETURNING star: attempt catalog-backed expansion. 21445 // On success, the helper adds to `outputs` and `lineage` in place 21446 // and we `continue` past the normal single-column build below. 21447 expandReturningStarColumns( 21448 expr, rc, dmlKind, targetTable, targetAlias, targetQName, 21449 fromSideRelations, provider, dmlIdx, lineage, anchor, outputs); 21450 continue; 21451 } 21452 // Subquery / aggregate / window — guarded by !isOracleInto 21453 // because Oracle's INTO column list forbids nested queries and 21454 // aggregates at the grammar level; skip the checks to avoid 21455 // false rejects on unusual AST shapes. 21456 if (!isOracleInto) { 21457 if (containsAnySubqueryExpression(expr)) { 21458 throw new SemanticIRBuildException(Diagnostic.error( 21459 DiagnosticCode.RETURNING_HAS_SUBQUERY_NOT_SUPPORTED, 21460 dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT") 21461 + " column #" + (i + 1) + " contains a subquery; " 21462 + "slice 85 admits scalar expressions over base columns only", 21463 rc)); 21464 } 21465 rejectWindowFunctionInScope(expr, 21466 dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT")); 21467 if (isAggregateFunction(expr)) { 21468 throw new SemanticIRBuildException(Diagnostic.error( 21469 DiagnosticCode.RETURNING_HAS_AGGREGATE_NOT_SUPPORTED, 21470 dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT") 21471 + " column #" + (i + 1) + " contains an aggregate " 21472 + "function; aggregates are not legal in DML " 21473 + "RETURNING / OUTPUT projection per SQL spec", 21474 rc)); 21475 } 21476 } 21477 // Name extraction. 21478 // INTO path: no alias possible, use expr.toString() directly. 21479 // Normal path: use the projection-side helper which strips 21480 // INSERTED./DELETED. qualifiers on OUTPUT pseudo-table refs. 21481 String outName = isOracleInto 21482 ? expr.toString() 21483 : returningOutputName(rc, expr, dmlKind, ret != null); 21484 if (outName == null || outName.isEmpty()) { 21485 throw new SemanticIRBuildException(Diagnostic.error( 21486 DiagnosticCode.RESULT_COLUMN_NO_NAME, 21487 dmlKind + " RETURNING INTO column #" + (i + 1) 21488 + " has no resolvable name", 21489 anchor)); 21490 } 21491 // Source collection via manual walker (slice-89 fix registers 21492 // RETURNING refs in Resolver2 allColumnReferences for DELETE/UPDATE; 21493 // INSERT RETURNING lacks an InsertScope so Resolver2 path is partial). 21494 // rc=null is safe for the INTO path: synthRefForReturningLeaf 21495 // only dereferences rc inside the `if (isOutput)` guard, 21496 // which is false for all RETURNING (non-OUTPUT) paths. 21497 List<ColumnRef> sources = collectReturningSourceRefs( 21498 expr, rc, out != null && !isOracleInto, 21499 targetAlias, targetQName, fromSideRelations); 21500 boolean derived = expr.getExpressionType() 21501 != EExpressionType.simple_object_name_t; 21502 outputs.add(new OutputColumn(outName, derived, 21503 /*aggregate=*/ false, sources)); 21504 // Emit one LineageEdge per source column ref. 21505 // Edge direction (consumer ← producer; slice-85 convention 21506 // documented on getReturningColumns()): 21507 // from = STATEMENT_OUTPUT(dmlIdx, returningName) 21508 // to = TABLE_COLUMN(<producer-qualified-name>, <colName>) 21509 // The producer qualified-name is: 21510 // - target table qname when the source ref's relationAlias 21511 // is INSERTED / DELETED (MSSQL OUTPUT pseudo-tables both 21512 // ultimately reference the physical target row) 21513 // - target table qname when the source ref's relationAlias 21514 // matches the target alias 21515 // - FROM-side relation's binding qualifiedName when the 21516 // ref's relationAlias matches a FROM-side relation 21517 // - the relationAlias verbatim otherwise (defensive) 21518 for (ColumnRef src : sources) { 21519 String srcCol = src.getColumnName(); 21520 if (srcCol == null || srcCol.isEmpty()) continue; 21521 String alias = src.getRelationAlias(); 21522 // Slice 123 — when the source ref binds to a SUBQUERY-kind 21523 // FROM-side relation that owns its own producing 21524 // StatementGraph (slice-84 USING-(SELECT) sub / slice-106 21525 // USING-CTE on DELETE; slice-82/83/105 FROM-subquery or 21526 // CTE-as-relation on UPDATE), emit the canonical cross-stmt 21527 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge into the producer 21528 // rather than the fictitious TABLE_COLUMN(<alias>, col). 21529 // Base TABLE-kind FROM-side relations (slice-82/84 joined 21530 // RETURNING, pinned by Slice85Test §B/§C) and target / 21531 // INSERTED / DELETED refs fall through to the slice-85 21532 // TABLE_COLUMN path via resolveReturningEdgeTarget. 21533 Integer subIdx = returningSubquerySourceIndex(alias, 21534 targetAlias, targetQName, fromSideRelations, 21535 fromSideAliasToStmtIndex); 21536 if (subIdx != null) { 21537 lineage.add(new LineageEdge( 21538 LineageRef.statementOutput(dmlIdx, outName), 21539 LineageRef.statementOutput(subIdx, srcCol))); 21540 } else { 21541 String to = resolveReturningEdgeTarget(alias, 21542 targetAlias, targetQName, fromSideRelations); 21543 lineage.add(new LineageEdge( 21544 LineageRef.statementOutput(dmlIdx, outName), 21545 LineageRef.tableColumn(to, srcCol))); 21546 } 21547 } 21548 } 21549 return outputs; 21550 } 21551 21552 /** 21553 * Slice 98 helper — true when an expression is the MSSQL MERGE 21554 * OUTPUT {@code $action} pseudo-column. 21555 * 21556 * <p>Detection rule: the expression is a 21557 * {@code simple_object_name_t} and its {@code toString()} matches 21558 * {@code "$action"} case-insensitively. Bracketed delimited 21559 * identifiers like {@code [$action]} parse with the brackets in 21560 * the token string, so they are NOT matched here — a column 21561 * actually named {@code $action} (delimited) is treated as a 21562 * normal target column, not the pseudo-column (codex Q1 confirmed). 21563 * 21564 * <p>Caller gates the check on {@code dmlKind == "MERGE"} so the 21565 * slice-85 INSERT/UPDATE/DELETE OUTPUT path is unaffected. 21566 */ 21567 private static boolean isMergeActionPseudoColumn(TExpression expr) { 21568 if (expr == null) return false; 21569 if (expr.getExpressionType() != EExpressionType.simple_object_name_t) { 21570 return false; 21571 } 21572 String text = expr.toString(); 21573 return text != null && "$action".equalsIgnoreCase(text); 21574 } 21575 21576 /** 21577 * Slice 85 helper — true when an expression is a star reference 21578 * (bare {@code *} or qualified {@code t.*} / {@code INSERTED.*} / 21579 * {@code DELETED.*}). Covers both forms: bare star has 21580 * {@code expr.toString()=="*"}; qualified star is a 21581 * simple_object_name_t whose leaf TObjectName has 21582 * {@code partToken=="*"} (codex round-4 BLOCKING fix — 21583 * qualified stars were previously slipping past the bare-only 21584 * check and producing bogus ColumnRefs). 21585 */ 21586 private static boolean isStarReference(TExpression expr) { 21587 if (expr == null) return false; 21588 if (expr.getExpressionType() != EExpressionType.simple_object_name_t) { 21589 return false; 21590 } 21591 if ("*".equals(expr.toString())) return true; 21592 TObjectName n = expr.getObjectOperand(); 21593 if (n == null) return false; 21594 if (n.getPartToken() != null && "*".equals(n.getPartToken().toString())) { 21595 return true; 21596 } 21597 String colOnly = n.getColumnNameOnly(); 21598 return colOnly != null && "*".equals(colOnly); 21599 } 21600 21601 /** 21602 * Slice 90 helper — expand a standard {@code RETURNING *} / 21603 * {@code RETURNING t.*} star into per-column 21604 * {@link OutputColumn} entries using catalog metadata from 21605 * {@link #lookupRelationColumnNames(TTable, NameBindingProvider)}. 21606 * 21607 * <p>Called only for standard PG/Oracle RETURNING (not MSSQL OUTPUT, 21608 * not Oracle INTO). Adds expanded columns to {@code outputs} and 21609 * matching {@link LineageEdge}s to {@code lineage} in place. 21610 * 21611 * <p>Qualifier matching mirrors Slice 59 SELECT star semantics: 21612 * alias-only — the qualifier must equal the target's effective alias, 21613 * not the schema-qualified name. For INSERT without alias the effective 21614 * alias is the bare table name, so {@code RETURNING employees.*} matches 21615 * {@code INSERT INTO schema.employees}. 21616 * 21617 * <p>Throws {@link SemanticIRBuildException} on any failure: 21618 * <ul> 21619 * <li>{@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED} — no 21620 * catalog metadata available for the target relation;</li> 21621 * <li>{@link DiagnosticCode#RETURNING_STAR_NOT_SUPPORTED} — the 21622 * qualifier matches a FROM-side relation alias but FROM-side 21623 * star expansion is deferred to a future slice;</li> 21624 * <li>{@link DiagnosticCode#RETURNING_STAR_QUALIFIER_UNKNOWN} — the 21625 * qualifier does not match the target alias or any FROM-side 21626 * relation alias.</li> 21627 * </ul> 21628 */ 21629 private static void expandReturningStarColumns( 21630 TExpression expr, 21631 TResultColumn rc, 21632 String dmlKind, 21633 TTable targetTable, 21634 String targetAlias, 21635 String targetQName, 21636 List<RelationSource> fromSideRelations, 21637 NameBindingProvider provider, 21638 int dmlIdx, 21639 List<LineageEdge> lineage, 21640 TParseTreeNode anchor, 21641 List<OutputColumn> outputs) { 21642 // Extract qualifier: empty for bare `*`, table/alias name for `t.*`. 21643 String qualifier = ""; 21644 TObjectName n = (expr != null) ? expr.getObjectOperand() : null; 21645 if (n != null) { 21646 String q = n.getTableString(); 21647 if (q != null && !q.isEmpty()) qualifier = q; 21648 } 21649 // Rendered star form for use in diagnostic messages. 21650 String starForm = qualifier.isEmpty() ? "*" : qualifier + ".*"; 21651 // Determine which relation to expand. 21652 // Rule (Slice 90, mirrors Slice 59 correlation-name semantics): 21653 // effective alias only — bare name without alias counts as alias. 21654 boolean matchesTarget = qualifier.isEmpty() 21655 || qualifier.equalsIgnoreCase(targetAlias); 21656 if (matchesTarget) { 21657 // Attempt catalog-backed expansion of the target table. 21658 List<String> cols = lookupRelationColumnNames(targetTable, provider); 21659 if (cols == null || cols.isEmpty()) { 21660 throw new SemanticIRBuildException(Diagnostic.error( 21661 DiagnosticCode.RETURNING_STAR_CATALOG_REQUIRED, 21662 dmlKind + " RETURNING " + starForm 21663 + " requires catalog metadata for target '" 21664 + targetQName + "' to expand; supply a Catalog via " 21665 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", 21666 rc != null ? rc : anchor)); 21667 } 21668 for (String colName : cols) { 21669 ColumnRef ref = new ColumnRef(targetAlias, colName); 21670 outputs.add(new OutputColumn(colName, /*derived=*/ false, 21671 /*aggregate=*/ false, Collections.singletonList(ref))); 21672 lineage.add(new LineageEdge( 21673 LineageRef.statementOutput(dmlIdx, colName), 21674 LineageRef.tableColumn(targetQName, colName))); 21675 } 21676 return; 21677 } 21678 // Qualifier doesn't match target. Check FROM-side relations. 21679 for (RelationSource rs : fromSideRelations) { 21680 if (qualifier.equalsIgnoreCase(rs.getAlias())) { 21681 // Known FROM-side relation, but expansion is deferred. 21682 throw new SemanticIRBuildException(Diagnostic.error( 21683 DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED, 21684 dmlKind + " RETURNING " + starForm + " — " 21685 + "star expansion for FROM-side/USING relations " 21686 + "is deferred to a future slice; use explicit " 21687 + "column names for FROM-side RETURNING refs", 21688 rc != null ? rc : anchor)); 21689 } 21690 } 21691 // Qualifier is truly unknown (doesn't match target or any FROM-side relation). 21692 throw new SemanticIRBuildException(Diagnostic.error( 21693 DiagnosticCode.RETURNING_STAR_QUALIFIER_UNKNOWN, 21694 dmlKind + " RETURNING " + starForm + " — qualifier '" 21695 + qualifier + "' does not match the DML target alias '" 21696 + targetAlias + "' or any FROM-side relation; " 21697 + "use the target's effective alias for RETURNING star expansion", 21698 rc != null ? rc : anchor)); 21699 } 21700 21701 /** 21702 * Slice 99 / Slice 100 helper — expand MSSQL pseudo-table 21703 * {@code OUTPUT INSERTED.*} / {@code OUTPUT DELETED.*} into 21704 * per-column {@link OutputColumn} entries using catalog metadata 21705 * from {@link #lookupRelationColumnNames(TTable, NameBindingProvider)}. 21706 * 21707 * <p>Slice 99 originally introduced this helper for MSSQL MERGE 21708 * OUTPUT. Slice 100 generalised it to all DML kinds (INSERT / UPDATE 21709 * / DELETE / MERGE): the parser sets {@code pseudoTableType=inserted/deleted} 21710 * on the star qualifier identically for non-MERGE DML, so the 21711 * expansion is mechanically the same — only the catalog-missing 21712 * message text varies by {@code dmlKind} per the slice-80 21713 * message-text-discrimination contract. 21714 * 21715 * <p>Mirrors the slice-90 standard-RETURNING star design with two 21716 * differences: 21717 * <ul> 21718 * <li>The pseudo-table qualifier ({@code INSERTED} / {@code DELETED}) 21719 * is normalized to UPPERCASE on 21720 * {@link ColumnRef#getRelationAlias()} regardless of the SQL 21721 * case, matching slice-85 21722 * {@code synthRefForReturningLeaf}'s 21723 * {@code new ColumnRef("INSERTED", ...)} convention.</li> 21724 * <li>The catalog lookup target is the DML target table (the 21725 * pseudo-table rows physically reference target rows), not a 21726 * FROM-side relation.</li> 21727 * </ul> 21728 * 21729 * <p>Catalog miss reuses {@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED} 21730 * (slice-90 code) with a discriminating message text formatted as 21731 * {@code "<dmlKind> OUTPUT <pseudoLabel>.* requires catalog metadata 21732 * for target '<qname>' ..."}. 21733 * 21734 * <p>Adds expanded columns to {@code outputs} and matching 21735 * {@link LineageEdge}s to {@code lineage} in place. 21736 * 21737 * @param dmlKind one of {@code "INSERT"}, {@code "UPDATE"}, 21738 * {@code "DELETE"}, {@code "MERGE"} — feeds the 21739 * catalog-missing message text only; expansion is 21740 * identical regardless of kind because the parser 21741 * sets {@code pseudoTableType} on the star qualifier 21742 * uniformly. 21743 */ 21744 private static void expandOutputPseudoTableStarColumns( 21745 TExpression expr, 21746 TResultColumn rc, 21747 EPseudoTableType pseudoTable, 21748 String dmlKind, 21749 TTable targetTable, 21750 String targetQName, 21751 NameBindingProvider provider, 21752 int dmlIdx, 21753 List<LineageEdge> lineage, 21754 TParseTreeNode anchor, 21755 List<OutputColumn> outputs) { 21756 // Slice-85 convention: relationAlias is normalized to UPPERCASE. 21757 String pseudoLabel = (pseudoTable == EPseudoTableType.inserted) 21758 ? "INSERTED" 21759 : "DELETED"; 21760 List<String> cols = lookupRelationColumnNames(targetTable, provider); 21761 if (cols == null || cols.isEmpty()) { 21762 throw new SemanticIRBuildException(Diagnostic.error( 21763 DiagnosticCode.RETURNING_STAR_CATALOG_REQUIRED, 21764 dmlKind + " OUTPUT " + pseudoLabel + ".* requires catalog " 21765 + "metadata for target '" + targetQName 21766 + "' to expand; supply a Catalog via " 21767 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", 21768 rc != null ? rc : anchor)); 21769 } 21770 for (String colName : cols) { 21771 ColumnRef ref = new ColumnRef(pseudoLabel, colName); 21772 outputs.add(new OutputColumn(colName, /*derived=*/ false, 21773 /*aggregate=*/ false, Collections.singletonList(ref))); 21774 // INSERTED / DELETED both physically reference the target 21775 // row image; lineage edge target is the target qname. 21776 lineage.add(new LineageEdge( 21777 LineageRef.statementOutput(dmlIdx, colName), 21778 LineageRef.tableColumn(targetQName, colName))); 21779 } 21780 } 21781 21782 /** 21783 * Slice 85 helper — pull the pseudo-table type for an OUTPUT 21784 * column (SQL Server). Returns {@link EPseudoTableType#none} for 21785 * RETURNING (PG / Oracle) columns and for OUTPUT columns whose 21786 * top-level expression doesn't carry an INSERTED / DELETED 21787 * qualifier. For compound expressions, the parser sets the 21788 * pseudo-table type on the fieldAttr only for SIMPLE column 21789 * references; compound shapes must be deep-walked separately 21790 * via {@link #scanOutputPseudoTableLeaves}. 21791 */ 21792 private static EPseudoTableType pseudoTableOf(TResultColumn rc) { 21793 if (rc == null) return EPseudoTableType.none; 21794 TObjectName fa = rc.getFieldAttr(); 21795 if (fa == null) return EPseudoTableType.none; 21796 EPseudoTableType pt = fa.getPseudoTableType(); 21797 return (pt == null) ? EPseudoTableType.none : pt; 21798 } 21799 21800 /** 21801 * Slice 85 helper — walk an OUTPUT projection expression and 21802 * invoke {@code visitor} on every TObjectName leaf for the 21803 * pseudo-table mismatch scan. Skips function-name TObjectNames 21804 * via the dbObjectType filter so {@code OUTPUT FUNC(inserted.x)} 21805 * still surfaces the leaf inserted.x ref. 21806 */ 21807 private static void scanOutputPseudoTableLeaves( 21808 TExpression expr, final TParseTreeVisitor visitor) { 21809 if (expr == null) return; 21810 // Collect function-name identities first (codex round-2 Q1 21811 // BLOCKING — dialect-portable structural filter). 21812 final java.util.Set<TObjectName> fnLeaves = 21813 collectFunctionNameLeaves(expr); 21814 // Fast path: leaf simple_object_name_t. 21815 if (expr.getExpressionType() == EExpressionType.simple_object_name_t) { 21816 TObjectName n = expr.getObjectOperand(); 21817 if (n != null && !isFunctionNameObjectName(n, fnLeaves)) { 21818 visitor.preVisit(n); 21819 } 21820 return; 21821 } 21822 // Compound expression — walk for TObjectName leaves. 21823 expr.acceptChildren(new TParseTreeVisitor() { 21824 int nestedSelectDepth = 0; 21825 @Override 21826 public void preVisit(TSelectSqlStatement s) { nestedSelectDepth++; } 21827 @Override 21828 public void postVisit(TSelectSqlStatement s) { nestedSelectDepth--; } 21829 @Override 21830 public void preVisit(TObjectName node) { 21831 if (nestedSelectDepth > 0) return; 21832 if (isFunctionNameObjectName(node, fnLeaves)) return; 21833 visitor.preVisit(node); 21834 } 21835 }); 21836 } 21837 21838 /** 21839 * Slice 85 helper — detect the pseudo-table type (INSERTED / 21840 * DELETED) for a TObjectName leaf in an OUTPUT projection, 21841 * honouring both the parser-set fieldAttr.pseudoTableType (for 21842 * simple leaf refs where the parser ran its qualifier swap) AND 21843 * the objectToken spelling (for compound expressions where the 21844 * parser left pseudoTableType=none on leaf TObjectNames). 21845 * 21846 * <p>Codex round-2 Q3 BLOCKING fix — MSSQL permits "INSERTED" / 21847 * "DELETED" as real identifiers via ColId, so the text-match 21848 * fallback fires ONLY when no FROM-side relation alias / 21849 * qualifiedName / bare-component matches the qualifier. With a 21850 * real table named "INSERTED" in scope, the text-match is 21851 * suppressed and the leaf surfaces as a normal column ref. 21852 */ 21853 private static EPseudoTableType detectPseudoTable(TObjectName n, 21854 TResultColumn rc, 21855 String targetAlias, 21856 String targetQName, 21857 List<RelationSource> fromSideRelations) { 21858 if (n == null) return EPseudoTableType.none; 21859 // Direct: parser-set pseudoTableType. 21860 if (n.getPseudoTableType() != null 21861 && n.getPseudoTableType() != EPseudoTableType.none) { 21862 return n.getPseudoTableType(); 21863 } 21864 // Indirect: if this leaf is the result column's fieldAttr, 21865 // read from the fieldAttr's pseudoTableType (slice-78 21866 // TOutputClause.doParse sets it there for simple refs). 21867 if (rc != null && rc.getFieldAttr() == n) { 21868 EPseudoTableType pt = pseudoTableOf(rc); 21869 if (pt != null && pt != EPseudoTableType.none) return pt; 21870 } 21871 // Compound expression leaf — the parser leaves 21872 // pseudoTableType=none on the leaf TObjectNames but the 21873 // objectToken spelling is preserved. Text-match 21874 // INSERTED / DELETED case-insensitively, but only when no 21875 // real FROM-side relation shadows the pseudo name (codex 21876 // round-2 Q3 BLOCKING). 21877 if (n.getObjectToken() != null && n.getPartToken() != null) { 21878 String obj = n.getObjectToken().toString(); 21879 if (obj == null) return EPseudoTableType.none; 21880 boolean shadowedByRealRelation = 21881 qualifierMatchesAnyRelation(obj, targetAlias, 21882 targetQName, fromSideRelations); 21883 if (shadowedByRealRelation) return EPseudoTableType.none; 21884 if ("INSERTED".equalsIgnoreCase(obj)) return EPseudoTableType.inserted; 21885 if ("DELETED".equalsIgnoreCase(obj)) return EPseudoTableType.deleted; 21886 } 21887 return EPseudoTableType.none; 21888 } 21889 21890 /** 21891 * Slice 85 helper — true when {@code qualifier} matches some 21892 * real relation in scope (target alias / qualified name / bare 21893 * component, or any FROM-side relation alias / qualified name / 21894 * bare component). Used by {@link #detectPseudoTable} to 21895 * suppress the INSERTED / DELETED text-match when a real table 21896 * by that name is in scope. 21897 */ 21898 private static boolean qualifierMatchesAnyRelation(String qualifier, 21899 String targetAlias, String targetQName, 21900 List<RelationSource> fromSideRelations) { 21901 if (qualifier == null || qualifier.isEmpty()) return false; 21902 if (targetAlias != null && targetAlias.equalsIgnoreCase(qualifier)) { 21903 return true; 21904 } 21905 if (targetQName != null 21906 && (targetQName.equalsIgnoreCase(qualifier) 21907 || bareLastDotComponent(targetQName) 21908 .equalsIgnoreCase(qualifier))) { 21909 return true; 21910 } 21911 if (fromSideRelations != null) { 21912 for (RelationSource rs : fromSideRelations) { 21913 String a = rs.getAlias(); 21914 if (a != null && a.equalsIgnoreCase(qualifier)) return true; 21915 String qn = rs.getBinding() == null ? null 21916 : rs.getBinding().getQualifiedName(); 21917 if (qn != null 21918 && (qn.equalsIgnoreCase(qualifier) 21919 || bareLastDotComponent(qn) 21920 .equalsIgnoreCase(qualifier))) { 21921 return true; 21922 } 21923 } 21924 } 21925 return false; 21926 } 21927 21928 /** 21929 * Slice 85 helper — collect identities of every TObjectName that 21930 * is a function-name in the given expression tree (codex round-2 21931 * Q1 BLOCKING — {@code EDbObjectType.function} is unreliable 21932 * across dialects: Oracle builtins surface as {@code constant}, 21933 * MSSQL XML methods as {@code method}). The walker uses 21934 * {@link IdentityHashMap}-style reference equality so the 21935 * column-ref walker can structurally skip function-name leaves 21936 * regardless of dbType. 21937 */ 21938 private static java.util.Set<TObjectName> collectFunctionNameLeaves( 21939 TExpression expr) { 21940 final java.util.Set<TObjectName> set = java.util.Collections.newSetFromMap( 21941 new IdentityHashMap<TObjectName, Boolean>()); 21942 if (expr == null) return set; 21943 expr.acceptChildren(new TParseTreeVisitor() { 21944 @Override 21945 public void preVisit(TFunctionCall fn) { 21946 TObjectName name = fn.getFunctionName(); 21947 if (name != null) set.add(name); 21948 } 21949 }); 21950 return set; 21951 } 21952 21953 /** 21954 * Slice 85 helper — true when this TObjectName is in the 21955 * function-name set collected for the current expression 21956 * (codex round-2 Q1 BLOCKING fix — structural identity rather 21957 * than dbType). {@code functionNameLeaves} may be null (empty 21958 * set semantics) when the caller doesn't have the set in hand. 21959 */ 21960 private static boolean isFunctionNameObjectName(TObjectName n, 21961 java.util.Set<TObjectName> functionNameLeaves) { 21962 if (n == null) return false; 21963 if (functionNameLeaves != null && functionNameLeaves.contains(n)) { 21964 return true; 21965 } 21966 // Best-effort fallback: dbType check catches the 21967 // single-leaf simple_object_name_t function case (rare — 21968 // those normally arrive as TFunctionCall). Kept defensively. 21969 EDbObjectType t = n.getDbObjectType(); 21970 return t == EDbObjectType.function; 21971 } 21972 21973 /** 21974 * Slice 85 helper — best-effort spelling of the bare column name 21975 * for an OUTPUT pseudo-table ref like INSERTED.foo. Used only in 21976 * diagnostic message text. 21977 */ 21978 private static String safePseudoColumn(TResultColumn rc) { 21979 if (rc == null) return "<unknown>"; 21980 TObjectName fa = rc.getFieldAttr(); 21981 if (fa == null) return "<unknown>"; 21982 if (fa.getPartToken() != null) return fa.getPartToken().toString(); 21983 if (fa.getPropertyToken() != null) return fa.getPropertyToken().toString(); 21984 return rc.toString(); 21985 } 21986 21987 /** 21988 * Slice 85 helper — derive the OutputColumn name for a RETURNING / 21989 * OUTPUT projection column. Uses the explicit alias when present, 21990 * else the bare column name (for OUTPUT INSERTED.col / DELETED.col 21991 * the bare partToken spelling, stripping the pseudo-table 21992 * qualifier). Falls back to {@code expr.toString()} for derived 21993 * expressions without alias (e.g. {@code RETURNING a + 1} → name 21994 * = "a + 1"). 21995 */ 21996 private static String returningOutputName(TResultColumn rc, 21997 TExpression expr, 21998 String dmlKind, 21999 boolean isReturning) { 22000 String alias = rc.getColumnAlias(); 22001 if (alias != null && !alias.isEmpty()) { 22002 return alias; 22003 } 22004 // OUTPUT pseudo-table ref without alias — strip the qualifier 22005 // so the OutputColumn.name carries the bare column spelling. 22006 if (!isReturning) { 22007 EPseudoTableType pt = pseudoTableOf(rc); 22008 if (pt != EPseudoTableType.none) { 22009 TObjectName fa = rc.getFieldAttr(); 22010 if (fa != null && fa.getPartToken() != null) { 22011 return fa.getPartToken().toString(); 22012 } 22013 } 22014 } 22015 // RETURNING bare column or derived expression — fall back to 22016 // the expression's toString(). For simple_object_name_t this is 22017 // the verbatim bare or qualified column spelling; for 22018 // arithmetic / function expressions it is the rendered text. 22019 String s = expr.toString(); 22020 if (s == null || s.isEmpty()) { 22021 throw new SemanticIRBuildException(Diagnostic.error( 22022 DiagnosticCode.RESULT_COLUMN_NO_NAME, 22023 dmlKind + (isReturning ? " RETURNING" : " OUTPUT") 22024 + " column has no resolvable name", 22025 rc)); 22026 } 22027 return s; 22028 } 22029 22030 /** 22031 * Slice 85 helper — collect ColumnRefs from a RETURNING / OUTPUT 22032 * projection expression. Slice 89 fixed TReturningClause.acceptChildren() 22033 * to descend into children so Resolver2 now registers RETURNING refs in 22034 * allColumnReferences for DELETE/UPDATE (INSERT RETURNING lacks InsertScope 22035 * so Resolver2 coverage there is partial). This walker remains the 22036 * authoritative source for Semantic IR because it maps qualifier tokens 22037 * directly onto the DML's known relation set: 22038 * 22039 * <ul> 22040 * <li>OUTPUT pseudo-table ref ({@code INSERTED.col} / 22041 * {@code DELETED.col}, detected via 22042 * {@code fieldAttr.pseudoTableType}) → ColumnRef with 22043 * uppercase {@code "INSERTED"} / {@code "DELETED"} as the 22044 * relationAlias, preserving temporal phase (codex round-2 22045 * Q2 BLOCKING).</li> 22046 * <li>Qualified ref matching a FROM-side relation's alias 22047 * (slice-82 joined UPDATE / slice-84 joined DELETE) 22048 * → ColumnRef with that alias.</li> 22049 * <li>Qualified ref matching the target table's effective alias 22050 * or its qualified name → ColumnRef with the target alias.</li> 22051 * <li>Unqualified ref → ColumnRef with the target alias 22052 * (default scope).</li> 22053 * <li>Qualified ref matching nothing known → ColumnRef with the 22054 * parser's qualifier verbatim. Lineage consumers can spot 22055 * the unresolved relation via the relationAlias they see.</li> 22056 * </ul> 22057 * 22058 * <p>Match policy: case-insensitive on alias / qualified name, 22059 * to match the slice-83 codex Q3 advisory and stay forgiving of 22060 * dialect-specific identifier casing. 22061 */ 22062 private static List<ColumnRef> collectReturningSourceRefs( 22063 TExpression expr, 22064 TResultColumn rc, 22065 boolean isOutput, 22066 String targetAlias, 22067 String targetQName, 22068 List<RelationSource> fromSideRelations) { 22069 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 22070 // Collect function-name identities first (codex round-2 Q1 22071 // BLOCKING — dialect-portable structural filter; see 22072 // {@link #collectFunctionNameLeaves} javadoc for the 22073 // dbObjectType unreliability rationale). 22074 final java.util.Set<TObjectName> fnLeaves = collectFunctionNameLeaves(expr); 22075 // Fast path: simple_object_name_t leaf — handle directly so 22076 // OUTPUT INSERTED.col / DELETED.col resolves through fieldAttr. 22077 if (expr.getExpressionType() == EExpressionType.simple_object_name_t) { 22078 ColumnRef r = synthRefForReturningLeaf( 22079 rc, expr.getObjectOperand(), isOutput, 22080 targetAlias, targetQName, fromSideRelations, fnLeaves); 22081 if (r != null) refs.add(r); 22082 return new ArrayList<>(refs); 22083 } 22084 // Compound expression — walk for TObjectName leaves. We don't 22085 // descend into nested subqueries (already rejected upstream) 22086 // and don't try to resolve fieldAttr for compound expressions 22087 // (pseudo-table qualifier inside arithmetic / function args is 22088 // a rare shape; slice 85 surfaces the bare column ref against 22089 // the target alias as a best-effort). 22090 expr.acceptChildren(new TParseTreeVisitor() { 22091 int nestedSelectDepth = 0; 22092 @Override 22093 public void preVisit(TSelectSqlStatement nested) { 22094 nestedSelectDepth++; 22095 } 22096 @Override 22097 public void postVisit(TSelectSqlStatement nested) { 22098 nestedSelectDepth--; 22099 } 22100 @Override 22101 public void preVisit(TObjectName node) { 22102 if (nestedSelectDepth > 0) return; 22103 // Codex round-1 Q1 / round-2 Q1 BLOCKING — skip 22104 // function-name leaves via structural identity. 22105 if (isFunctionNameObjectName(node, fnLeaves)) return; 22106 ColumnRef r = synthRefForReturningLeaf( 22107 rc, node, isOutput, 22108 targetAlias, targetQName, fromSideRelations, fnLeaves); 22109 if (r != null) refs.add(r); 22110 } 22111 }); 22112 return new ArrayList<>(refs); 22113 } 22114 22115 /** 22116 * Slice 85 helper — build one ColumnRef for a TObjectName leaf in 22117 * a RETURNING / OUTPUT projection. Returns null when the node is 22118 * not a column-name reference (e.g. a function name token). 22119 */ 22120 private static ColumnRef synthRefForReturningLeaf( 22121 TResultColumn rc, 22122 TObjectName node, 22123 boolean isOutput, 22124 String targetAlias, 22125 String targetQName, 22126 List<RelationSource> fromSideRelations, 22127 java.util.Set<TObjectName> functionNameLeaves) { 22128 if (node == null) return null; 22129 // Codex round-1 Q1 / round-2 Q1 BLOCKING — skip function-name 22130 // TObjectNames via structural identity (the "UPPER" leaf in 22131 // `RETURNING UPPER(name)`). 22132 if (isFunctionNameObjectName(node, functionNameLeaves)) return null; 22133 String colName = bareColumnNameOf(node); 22134 if (colName == null || colName.isEmpty()) return null; 22135 // OUTPUT pseudo-table ref (INSERTED / DELETED). Detect via 22136 // fieldAttr on the result column (parser surfaces it there 22137 // for simple refs) OR objectToken spelling (compound exprs; 22138 // codex round-1 Q4 BLOCKING — same deep detection as 22139 // detectPseudoTable). Both INSERTED and DELETED ultimately 22140 // reference the target table's row; only the temporal phase 22141 // surfaces on ColumnRef.relationAlias. 22142 if (isOutput) { 22143 EPseudoTableType pt = detectPseudoTable(node, rc, 22144 targetAlias, targetQName, fromSideRelations); 22145 if (pt == EPseudoTableType.inserted) { 22146 return new ColumnRef("INSERTED", partColumnNameOf(node, colName)); 22147 } 22148 if (pt == EPseudoTableType.deleted) { 22149 return new ColumnRef("DELETED", partColumnNameOf(node, colName)); 22150 } 22151 } 22152 // Qualifier resolution: bare or qualified. 22153 String qualifier = qualifierOf(node); 22154 if (qualifier == null || qualifier.isEmpty()) { 22155 return new ColumnRef(targetAlias, colName); 22156 } 22157 // Codex round-2 Q2 + round-3 BLOCKING fix — single-pass 22158 // count-all-candidates matcher. A relation "matches" the 22159 // qualifier if any of (alias, qualifiedName, bare-last-dot 22160 // component of qualifiedName) compares case-insensitive- 22161 // equal. Multiple matches (e.g. unaliased `FROM s1.t s2.t` 22162 // both have effectiveAlias "t" via TTable.getName() fallback, 22163 // both have bareComponent "t") are ambiguous and fall through 22164 // to the verbatim qualifier path so consumers see the 22165 // ambiguity rather than a silent order-dependent pick. 22166 int totalMatches = 0; 22167 // Single-match accumulators (one each — only valid when 22168 // totalMatches == 1): 22169 RelationSource matchedRelation = null; 22170 boolean matchedIsTarget = false; 22171 if (fromSideRelations != null) { 22172 for (RelationSource rs : fromSideRelations) { 22173 if (relationCandidateMatch(rs, qualifier)) { 22174 totalMatches++; 22175 matchedRelation = rs; 22176 } 22177 } 22178 } 22179 if (targetCandidateMatch(targetAlias, targetQName, qualifier)) { 22180 totalMatches++; 22181 matchedIsTarget = true; 22182 } 22183 if (totalMatches == 1) { 22184 if (matchedIsTarget) { 22185 return new ColumnRef( 22186 targetAlias != null ? targetAlias : targetQName, colName); 22187 } 22188 String a = matchedRelation.getAlias(); 22189 String qn = matchedRelation.getBinding() == null ? null 22190 : matchedRelation.getBinding().getQualifiedName(); 22191 return new ColumnRef((a != null && !a.isEmpty()) ? a : qn, colName); 22192 } 22193 // Zero matches or ambiguous — pass through verbatim. Lineage 22194 // consumers can spot the unresolved / ambiguous relation. 22195 return new ColumnRef(qualifier, colName); 22196 } 22197 22198 /** 22199 * Slice 85 helper — true when a FROM-side relation is a match 22200 * candidate for the qualifier under any of: alias, full 22201 * qualifiedName, or bare last-dot component of qualifiedName 22202 * (case-insensitive). Caller uses {@link #synthRefForReturningLeaf}'s 22203 * single-pass count-then-pick policy to disambiguate. 22204 */ 22205 private static boolean relationCandidateMatch(RelationSource rs, 22206 String qualifier) { 22207 if (rs == null || qualifier == null || qualifier.isEmpty()) return false; 22208 String a = rs.getAlias(); 22209 if (a != null && a.equalsIgnoreCase(qualifier)) return true; 22210 String qn = rs.getBinding() == null ? null 22211 : rs.getBinding().getQualifiedName(); 22212 if (qn == null) return false; 22213 return qn.equalsIgnoreCase(qualifier) 22214 || bareLastDotComponent(qn).equalsIgnoreCase(qualifier); 22215 } 22216 22217 /** 22218 * Slice 85 helper — true when the target table is a candidate 22219 * match for the qualifier (alias / qualifiedName / bare 22220 * component, case-insensitive). 22221 */ 22222 private static boolean targetCandidateMatch(String targetAlias, 22223 String targetQName, 22224 String qualifier) { 22225 if (qualifier == null || qualifier.isEmpty()) return false; 22226 if (targetAlias != null && targetAlias.equalsIgnoreCase(qualifier)) { 22227 return true; 22228 } 22229 if (targetQName != null 22230 && (targetQName.equalsIgnoreCase(qualifier) 22231 || bareLastDotComponent(targetQName) 22232 .equalsIgnoreCase(qualifier))) { 22233 return true; 22234 } 22235 return false; 22236 } 22237 22238 /** 22239 * Slice 85 helper — strip everything up to and including the 22240 * last dot in a qualified name. Returns the input unchanged when 22241 * no dot is present. 22242 */ 22243 private static String bareLastDotComponent(String qname) { 22244 if (qname == null) return ""; 22245 int dot = qname.lastIndexOf('.'); 22246 return (dot < 0) ? qname : qname.substring(dot + 1); 22247 } 22248 22249 /** 22250 * Slice 85 helper — map a ColumnRef.relationAlias to the qualified 22251 * table name used on the LineageEdge {@code to} endpoint. INSERTED 22252 * / DELETED both collapse to the target's qualified name; FROM-side 22253 * aliases route to their bound qualifiedName; target alias / qname 22254 * stays on the target; unknown aliases pass through verbatim. 22255 */ 22256 private static String resolveReturningEdgeTarget( 22257 String alias, String targetAlias, String targetQName, 22258 List<RelationSource> fromSideRelations) { 22259 if ("INSERTED".equals(alias) || "DELETED".equals(alias)) { 22260 return targetQName; 22261 } 22262 if (targetAlias != null && targetAlias.equalsIgnoreCase(alias)) { 22263 return targetQName; 22264 } 22265 if (targetQName != null && targetQName.equalsIgnoreCase(alias)) { 22266 return targetQName; 22267 } 22268 if (fromSideRelations != null) { 22269 for (RelationSource rs : fromSideRelations) { 22270 if (rs.getAlias() != null 22271 && rs.getAlias().equalsIgnoreCase(alias)) { 22272 String qn = rs.getBinding() == null ? null 22273 : rs.getBinding().getQualifiedName(); 22274 return (qn != null && !qn.isEmpty()) ? qn : alias; 22275 } 22276 } 22277 } 22278 return alias != null ? alias : targetQName; 22279 } 22280 22281 /** 22282 * Slice 123 — resolve a RETURNING/OUTPUT source ref's relation alias to 22283 * the statement index of a SUBQUERY-kind FROM-side relation that owns 22284 * its own producing {@link StatementGraph}, so the slice-85 walker can 22285 * emit a cross-stmt {@code STATEMENT_OUTPUT(dmlIdx, ret) → 22286 * STATEMENT_OUTPUT(subOrCteIdx, col)} edge instead of the fictitious 22287 * {@code TABLE_COLUMN(<alias>, col)} edge. 22288 * 22289 * <p>Returns the producing statement index iff ALL of: 22290 * <ul> 22291 * <li>{@code fromSideAliasToStmtIndex} is non-empty and {@code alias} 22292 * is non-empty;</li> 22293 * <li>{@code alias} is NOT an OUTPUT pseudo-table ({@code INSERTED} / 22294 * {@code DELETED}) and does NOT match the target alias or qname — 22295 * those stay on the slice-85 {@code TABLE_COLUMN(targetQName)} 22296 * path (mirrors {@link #resolveReturningEdgeTarget}'s priority);</li> 22297 * <li>a {@link RelationSource} in {@code fromSideRelations} matches the 22298 * alias (case-insensitive) AND is {@link RelationKind#SUBQUERY}-kind 22299 * (mirrors the slice-83 {@link #emitUpdateSubquerySourceEdges} 22300 * filter — base TABLE-kind FROM relations are never promoted);</li> 22301 * <li>the alias has an entry in {@code fromSideAliasToStmtIndex} 22302 * (keyed lowercase).</li> 22303 * </ul> 22304 * Otherwise returns {@code null} and the caller keeps the TABLE_COLUMN 22305 * edge. No producer-column publication check (consistent with 22306 * {@link #emitUpdateSubquerySourceEdges}). 22307 * 22308 * <p>Invariant — the {@link RelationKind#SUBQUERY} filter and the map are 22309 * consistent by construction, so the filter never spuriously suppresses a 22310 * real producer. Every alias in {@code fromSideAliasToStmtIndex} comes from 22311 * one of three sources, all of which add only SUBQUERY-kind relations for 22312 * the matching alias: 22313 * <ul> 22314 * <li>{@link #buildDeleteCombinedAliasToSubIdx} / 22315 * {@link #buildUpdateCombinedAliasToSubIdx} (DELETE / UPDATE), whose 22316 * two entry sources are (a) slice-84/83 FROM-subquery aliases — 22317 * always SUBQUERY-kind relations — and (b) slice-106/105 22318 * CTE-as-FROM-relation aliases, which {@code buildDeleteRelation} / 22319 * {@code buildUpdateRelation} record as SUBQUERY-kind (NOT 22320 * {@link RelationKind#CTE});</li> 22321 * <li>slice-124 MERGE {@code aliasToSubIdx}, whose entries are the 22322 * USING alias for the USING-(SELECT) subquery branch and for the 22323 * USING-as-CTE branch — both add a SUBQUERY-kind relation. The 22324 * USING-as-CTE branch also registers an inert bare-CTE-name key with 22325 * no matching relation in {@code fromSideRelations}, so it is never 22326 * reached by the per-alias relation match below.</li> 22327 * </ul> 22328 * The filter is kept as defense-in-depth (a stale TABLE-kind entry would 22329 * never be promoted) and for parity with the slice-83 22330 * {@code emitUpdateSubquerySourceEdges}, which carries the identical 22331 * SUBQUERY-kind guard. 22332 */ 22333 private static Integer returningSubquerySourceIndex( 22334 String alias, String targetAlias, String targetQName, 22335 List<RelationSource> fromSideRelations, 22336 Map<String, Integer> fromSideAliasToStmtIndex) { 22337 if (alias == null || alias.isEmpty()) return null; 22338 if (fromSideAliasToStmtIndex == null 22339 || fromSideAliasToStmtIndex.isEmpty()) { 22340 return null; 22341 } 22342 // Pseudo-tables and target self-refs are never cross-stmt sources. 22343 if ("INSERTED".equals(alias) || "DELETED".equals(alias)) return null; 22344 if (targetAlias != null && targetAlias.equalsIgnoreCase(alias)) { 22345 return null; 22346 } 22347 if (targetQName != null && targetQName.equalsIgnoreCase(alias)) { 22348 return null; 22349 } 22350 if (fromSideRelations == null) return null; 22351 for (RelationSource rs : fromSideRelations) { 22352 if (rs.getAlias() == null 22353 || !rs.getAlias().equalsIgnoreCase(alias)) { 22354 continue; 22355 } 22356 if (rs.getBinding() == null 22357 || rs.getBinding().getKind() != RelationKind.SUBQUERY) { 22358 return null; 22359 } 22360 return fromSideAliasToStmtIndex.get( 22361 alias.toLowerCase(Locale.ROOT)); 22362 } 22363 return null; 22364 } 22365 22366 /** 22367 * Slice 85 helper — extract the bare column name from a TObjectName 22368 * leaf, honouring partToken / propertyToken / objectToken in the 22369 * order set by the parser. Returns null when no column-name token 22370 * is present. 22371 */ 22372 private static String bareColumnNameOf(TObjectName node) { 22373 // partToken is the column name in `qualifier.col` form; 22374 // for bare `col`, the parser may put it on objectToken. 22375 if (node.getPartToken() != null) { 22376 return node.getPartToken().toString(); 22377 } 22378 if (node.getColumnNameOnly() != null 22379 && !node.getColumnNameOnly().isEmpty()) { 22380 return node.getColumnNameOnly(); 22381 } 22382 if (node.getObjectToken() != null) { 22383 return node.getObjectToken().toString(); 22384 } 22385 return null; 22386 } 22387 22388 /** 22389 * Slice 85 helper — qualifier (table alias or schema-table) of a 22390 * column-name reference. Returns null for bare references. 22391 */ 22392 private static String qualifierOf(TObjectName node) { 22393 // For `t.col`, parser populates objectToken=t, partToken=col. 22394 if (node.getPartToken() != null && node.getObjectToken() != null) { 22395 return node.getObjectToken().toString(); 22396 } 22397 return null; 22398 } 22399 22400 /** 22401 * Slice 85 helper — pseudo-table partToken column name for OUTPUT 22402 * INSERTED.col / DELETED.col. Falls back to the bare column name 22403 * when partToken is null (e.g. raw bare reference). 22404 */ 22405 private static String partColumnNameOf(TObjectName node, String fallbackColName) { 22406 if (node.getPartToken() != null) { 22407 return node.getPartToken().toString(); 22408 } 22409 return fallbackColName; 22410 } 22411 22412 private static void rejectWindowFunctionInScope(gudusoft.gsqlparser.nodes.TParseTreeNode root, 22413 String clauseLabel) { 22414 if (root == null) return; 22415 final boolean[] found = {false}; 22416 root.acceptChildren(new TParseTreeVisitor() { 22417 @Override 22418 public void preVisit(TFunctionCall fn) { 22419 if (found[0]) return; 22420 if (fn.getWindowDef() != null) found[0] = true; 22421 } 22422 }); 22423 if (found[0]) { 22424 throw new SemanticIRBuildException( 22425 Diagnostic.error(DiagnosticCode.CLAUSE_WINDOW_FUNCTION_LEAK, 22426 clauseLabel + " contains a window function (OVER (...)); " 22427 + "window functions are not allowed in " + clauseLabel 22428 + " per standard SQL", root)); 22429 } 22430 } 22431 22432 private static String effectiveOutputName(TResultColumn rc) { 22433 String alias = rc.getColumnAlias(); 22434 if (alias != null && !alias.isEmpty()) { 22435 return alias; 22436 } 22437 String colName = rc.getColumnNameOnly(); 22438 if (colName != null && !colName.isEmpty()) { 22439 return colName; 22440 } 22441 throw new SemanticIRBuildException( 22442 Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NO_NAME, 22443 "result column " + rc + " has neither alias nor column name", rc)); 22444 } 22445 22446 private static List<ColumnRef> buildFilterColumnRefs(TSelectSqlStatement select, 22447 NameBindingProvider provider, 22448 boolean allowPredicateSubqueries, 22449 List<StatementGraph> stmtsForExtraction, 22450 List<LineageEdge> lineageForExtraction, 22451 Map<String, Integer> cteMapForExtraction, 22452 PredicateClauseContext whereClauseContext, 22453 List<SemiJoinFact> semiFactsOut, 22454 Set<String> outerRelationAliases) { 22455 TWhereClause where = select.getWhereClause(); 22456 if (where == null || where.getCondition() == null) { 22457 return new ArrayList<>(); 22458 } 22459 Set<TExpression> extractedWhereRoots = 22460 Collections.<TExpression>emptySet(); 22461 if (containsAnySubquery(where)) { 22462 if (!allowPredicateSubqueries) { 22463 // Slice 112 — non-outer SELECTs (FROM-subquery, scalar 22464 // projection subquery body, predicate body) keep the 22465 // slice-80 blanket reject. The outermost SELECT path 22466 // (slice 112) and set-op branch path (slice 113) thread 22467 // {@code allowPredicateSubqueries=true} plus the live 22468 // extraction context so the slice-23+ walker can lift 22469 // uncorrelated predicate-subquery wrappers. Inner 22470 // contexts also have earlier preflight rejecters 22471 // ({@code rejectSubqueriesInFromSubqueryBodyClauses} for 22472 // FROM-subquery bodies, {@code rejectSubqueriesInPredicateBodyClauses} 22473 // for slice-23 predicate bodies); this remains the 22474 // fallback path for any unanticipated nested SELECTs 22475 // that bypass those preflights. 22476 throw new SemanticIRBuildException( 22477 Diagnostic.error(DiagnosticCode.WHERE_HAS_SUBQUERY_NOT_SUPPORTED, 22478 "WHERE clause contains a subquery; subqueries in WHERE " 22479 + "are not supported yet in nested SELECTs", 22480 select)); 22481 } 22482 // Slice 112 / 113 — outer SELECT WHERE and set-op branch 22483 // WHERE lift the slice-80 blanket subquery reject by 22484 // routing uncorrelated predicate-subquery wrappers 22485 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 22486 // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction 22487 // pipeline refactored by slice 110 to take a 22488 // PredicateClauseContext. Slice 112 added the SELECT_WHERE 22489 // constant for outer SELECT WHERE; slice 113 adds the 22490 // SET_OP_BRANCH_WHERE constant for nested set-op branch 22491 // WHERE — both reuse the same SELECT_WHERE_* DiagnosticCode 22492 // family (a branch IS a SELECT, only nested) and differ 22493 // only in the {@code clauseLabel} for diagnostic messages. 22494 // Each extracted wrapper lands as its own 22495 // <predicate_subquery_<i>> StatementGraph BEFORE the host 22496 // outer SELECT or set-op branch in {@code stmts} (selectIdx 22497 // = stmts.size() naturally accounts for them — slice-83 22498 // dynamic-index pattern, slice 110/111 precedent). 22499 // 22500 // Remaining non-subquery refs flow into filterColumnRefs 22501 // via collectColumnRefsSkipping. Window functions in 22502 // non-subquery subtrees still reject via 22503 // rejectWindowFunctionInScopeSkipping. The {@code provider} 22504 // already carries withCteContext / withInScopeRelationColumns 22505 // from the outer build chain (slice-65 withUsingScope 22506 // preserves both facets), so the predicate body's inner 22507 // FROM cte routes through RelationKind.CTE and the body's 22508 // own lineage edge becomes STATEMENT_OUTPUT(predicateIdx, 22509 // col) -> STATEMENT_OUTPUT(cteIdx, col) instead of 22510 // TABLE_COLUMN (slice 110/111 precedent). 22511 extractedWhereRoots = 22512 extractUncorrelatedPredicateSubqueriesFromClause( 22513 where.getCondition(), provider, 22514 stmtsForExtraction, lineageForExtraction, 22515 cteMapForExtraction, 22516 whereClauseContext, 22517 /*correlationScope=*/ null, 22518 semiFactsOut, 22519 outerRelationAliases); 22520 rejectAnyRemainingSubqueriesFromClause( 22521 where.getCondition(), extractedWhereRoots, 22522 whereClauseContext); 22523 } 22524 // Slice 13: reject window functions in WHERE before 22525 // collectColumnRefs descends into OVER (...) and leaks 22526 // PARTITION BY / OVER ORDER BY refs into filterColumnRefs. 22527 // Slice 112 — skip extracted predicate-subquery subtrees so 22528 // inner window functions do not leak into the outer reject 22529 // (mirrors the slice-110/111 UPDATE/DELETE WHERE behaviour). 22530 rejectWindowFunctionInScopeSkipping(where, "WHERE clause", 22531 extractedWhereRoots); 22532 return collectColumnRefsSkipping(where, provider, extractedWhereRoots); 22533 } 22534 22535 /** 22536 * Slice 65 — shared visitor body that emits either the merged-key 22537 * source list (when {@code node} is an unqualified reference to a 22538 * USING merged key in the current SELECT's 22539 * {@link UsingScope}) or the resolver2-bound {@link ColumnRef}. 22540 * 22541 * <p>Used by every visitor that walks expression subtrees and 22542 * collects column refs ({@link #collectColumnRefs}, 22543 * {@link #collectColumnRefsSkippingExtended}, the derived FILTER / 22544 * WITHIN-GROUP-excluding variants). Each visitor remains 22545 * responsible for its own skip-depth and nested-SELECT-depth 22546 * tracking; only the column-emit body is shared. 22547 * 22548 * <p>Behavior: 22549 * <ul> 22550 * <li>If the node is not a column, name is null/empty/star → no-op.</li> 22551 * <li>If the node is unqualified AND its name matches a USING 22552 * key in {@code provider.getUsingScope()} AND that scope 22553 * reports the reference as ambiguous (two disconnected 22554 * classes, or a catalog-proven out-of-class same-named 22555 * relation) → throw {@link SemanticIRBuildException}.</li> 22556 * <li>Otherwise if the unqualified name matches a USING key 22557 * unambiguously → emit each {@link ColumnRef} from the 22558 * merged source list (FROM-ordered, deduped per relation).</li> 22559 * <li>Otherwise → delegate to 22560 * {@link NameBindingProvider#bindColumn} and emit the bound 22561 * {@link ColumnRef}; any non-EXACT_MATCH binding records a 22562 * reject (caller throws after collecting all rejects).</li> 22563 * </ul> 22564 * 22565 * <p>The qualifier check is the SQL-written prefix 22566 * ({@link TObjectName#getTableString()}); when present the 22567 * merged-key path is skipped so {@code a.k} continues to resolve 22568 * to {@code (a, k)} regardless of {@code k}'s USING-key status. 22569 */ 22570 private static void appendMergedOrBoundColumnRef( 22571 TObjectName node, 22572 NameBindingProvider provider, 22573 LinkedHashSet<ColumnRef> refsOut, 22574 List<String> rejectsOut, 22575 boolean[] sawQualifiedRejectOut) { 22576 if (node.getDbObjectType() != EDbObjectType.column) return; 22577 String name = node.getColumnNameOnly(); 22578 if (name == null || "*".equals(name)) return; 22579 UsingScope scope = provider.getUsingScope(); 22580 String qualifier = node.getTableString(); 22581 if ((qualifier == null || qualifier.isEmpty()) && scope.has(name)) { 22582 if (scope.isAmbiguous(name)) { 22583 throw new SemanticIRBuildException( 22584 Diagnostic.error(DiagnosticCode.UNQUALIFIED_COLUMN_AMBIGUOUS, 22585 "unqualified reference to '" + name + "' is ambiguous: " 22586 + scope.ambiguityReason(name) 22587 + "; qualify with a table alias", null)); 22588 } 22589 for (ColumnRef ref : scope.mergedSourcesFor(name)) { 22590 refsOut.add(ref); 22591 } 22592 return; 22593 } 22594 ColumnBinding binding = provider.bindColumn(node); 22595 boolean unqualified = (qualifier == null || qualifier.isEmpty()); 22596 // Catalog-less NATURAL JOIN degrade (GSP R6): an unqualified 22597 // reference that fails to bind exactly, when the FROM clause carries 22598 // a catalog-less NATURAL join, is a reference to the merged NATURAL 22599 // key — unambiguous under NATURAL semantics. Resolve it to the 22600 // deterministic left-side relation instead of failing the whole 22601 // analysis. Only fires for NATURAL FROM clauses; plain-join 22602 // unqualified ambiguity still rejects below. 22603 if (unqualified 22604 && (binding == null || binding.getStatus() != ResolutionStatus.EXACT_MATCH) 22605 && scope.hasNaturalDegrade()) { 22606 refsOut.add(new ColumnRef(scope.naturalDegradeFallbackAlias(), name)); 22607 return; 22608 } 22609 if (binding == null) { 22610 rejectsOut.add(node + "[no binding]"); 22611 if (!unqualified && sawQualifiedRejectOut != null) { 22612 sawQualifiedRejectOut[0] = true; 22613 } 22614 return; 22615 } 22616 if (binding.getStatus() != ResolutionStatus.EXACT_MATCH) { 22617 rejectsOut.add(node + "[" + binding.getStatus() + "]"); 22618 if (!unqualified && sawQualifiedRejectOut != null) { 22619 sawQualifiedRejectOut[0] = true; 22620 } 22621 return; 22622 } 22623 // Slice 164 (S3): attach the source-text span of the originating 22624 // TObjectName. Slice 165 (S4): attach the GAP-5 resolution from the 22625 // binding's final table. Both are excluded from ColumnRef identity, 22626 // so the LinkedHashSet dedupe and emission order are unaffected. 22627 refsOut.add(new ColumnRef(binding.getRelationAlias(), binding.getColumnName(), 22628 null, SourceSpan.of(node), resolutionOf(binding))); 22629 } 22630 22631 /** 22632 * Slice 165 (S4) — map a {@link ColumnBinding}'s final table to an 22633 * explicit {@link ColumnResolution} (GAP 5). A non-null final-table 22634 * qualified name yields RESOLVED; a null one yields UNRESOLVED (the 22635 * resolver did not produce a base table — never fabricated). This path 22636 * only sees EXACT_MATCH bindings; non-exact / null bindings are 22637 * rejected upstream, so strict diagnostics are not loosened. 22638 */ 22639 static ColumnResolution resolutionOf(ColumnBinding binding) { 22640 String finalTableQn = binding.getFinalTableQualifiedName(); 22641 return (finalTableQn != null && !finalTableQn.isEmpty()) 22642 ? ColumnResolution.resolved(finalTableQn) 22643 : ColumnResolution.UNRESOLVED; 22644 } 22645 22646 /** 22647 * Visit every column-typed {@link TObjectName} reachable from the given 22648 * subtree, ask the provider to bind it, and return de-duplicated 22649 * {@link ColumnRef}s. Any non-EXACT_MATCH binding aborts the build. 22650 * 22651 * <p>Slice 65: when the provider carries a non-empty 22652 * {@link UsingScope}, unqualified references that match a USING 22653 * merged key are expanded to the merged source list (one ref per 22654 * relation in the equivalence class) before delegating to 22655 * {@link NameBindingProvider#bindColumn}. See 22656 * {@link #appendMergedOrBoundColumnRef}. 22657 */ 22658 private static List<ColumnRef> collectColumnRefs(gudusoft.gsqlparser.nodes.TParseTreeNode root, 22659 final NameBindingProvider provider) { 22660 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 22661 final List<String> rejects = new ArrayList<>(); 22662 // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at 22663 // the wrong side). Such a miss is a genuine error and must stay fatal; 22664 // only all-unqualified rejects are eligible for the join-graph degrade. 22665 final boolean[] sawQualifiedReject = {false}; 22666 root.acceptChildren(new TParseTreeVisitor() { 22667 int nestedSelectDepth = 0; 22668 22669 @Override 22670 public void preVisit(TSelectSqlStatement nested) { 22671 nestedSelectDepth++; 22672 } 22673 22674 @Override 22675 public void postVisit(TSelectSqlStatement nested) { 22676 nestedSelectDepth--; 22677 } 22678 22679 @Override 22680 public void preVisit(TObjectName node) { 22681 if (nestedSelectDepth > 0) return; 22682 appendMergedOrBoundColumnRef(node, provider, refs, rejects, 22683 sawQualifiedReject); 22684 } 22685 }); 22686 if (!rejects.isEmpty()) { 22687 rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]); 22688 } 22689 return new ArrayList<>(refs); 22690 } 22691 22692 /** 22693 * Tolerant variant of {@link #collectColumnRefs} for the MySQL 22694 * self-reference DELETE path (slice 92 Codex P1 fix). 22695 * 22696 * <p>The MySQL parser populates {@code stmt.tables} with 3 entries for 22697 * {@code DELETE T1 FROM T1 WHERE id = 1} (target + {@code joins[0]} + 22698 * {@code referenceJoins[0]}). Resolver2's {@code inferredCandidates} 22699 * then sees 3 candidates for any unqualified column ref and marks the 22700 * binding as NOT_FOUND, which {@link #collectColumnRefs} rejects as 22701 * {@code COLUMN_BINDING_NON_EXACT}. 22702 * 22703 * <p>This variant emits EXACT_MATCH bindings verbatim and falls back 22704 * to the SQL-written qualifier (or {@code null} for unqualified refs) 22705 * for non-exact bindings instead of throwing. Subquery children are 22706 * not descended into (matches the strict collector's behaviour). 22707 */ 22708 /** 22709 * @param fallbackRelationAlias used when the binding is non-exact and the 22710 * SQL-written qualifier is absent; for the MySQL self-reference path 22711 * this is {@code targetQName} so unqualified refs like 22712 * {@code WHERE id = 1} emit {@code ColumnRef(targetName, "id")} 22713 * instead of crashing on the non-null constraint on 22714 * {@link ColumnRef#ColumnRef(String, String)}. 22715 */ 22716 private static List<ColumnRef> collectColumnRefsTolerant( 22717 gudusoft.gsqlparser.nodes.TParseTreeNode root, 22718 final NameBindingProvider provider, 22719 final String fallbackRelationAlias) { 22720 return collectColumnRefsTolerant(root, provider, fallbackRelationAlias, 22721 Collections.<TExpression>emptySet()); 22722 } 22723 22724 /** 22725 * Slice 111 — variant of the slice-92 tolerant collector that also 22726 * skips any descendants of {@code skipRoots} (extracted predicate 22727 * subquery wrappers). Mirrors the 22728 * {@link #collectColumnRefsSkipping} skipping behavior so DELETE 22729 * WHERE-side IN-SELECT / EXISTS / scalar-comparison wrappers 22730 * extracted by 22731 * {@link #extractUncorrelatedPredicateSubqueriesFromClause} are 22732 * not double-collected as outer filter refs on the MySQL self-ref 22733 * DELETE path. For the non-self-ref DELETE path the 22734 * {@link #collectColumnRefsSkipping} helper handles the same job; 22735 * this helper exists only for the slice-92 path which needs the 22736 * tolerant binding behavior to survive Resolver2's 22737 * NOT_FOUND / NON_EXACT bindings on unqualified self-ref refs. 22738 */ 22739 private static List<ColumnRef> collectColumnRefsTolerant( 22740 gudusoft.gsqlparser.nodes.TParseTreeNode root, 22741 final NameBindingProvider provider, 22742 final String fallbackRelationAlias, 22743 final Set<TExpression> skipRoots) { 22744 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 22745 // Root fast path: if root IS a skipped TExpression subtree, return empty. 22746 if (root instanceof TExpression && skipRoots.contains(root)) { 22747 return new ArrayList<>(refs); 22748 } 22749 root.acceptChildren(new TParseTreeVisitor() { 22750 int nestedSelectDepth = 0; 22751 int skipDepth = 0; 22752 22753 @Override 22754 public void preVisit(TExpression e) { 22755 if (skipRoots.contains(e)) skipDepth++; 22756 } 22757 22758 @Override 22759 public void postVisit(TExpression e) { 22760 if (skipRoots.contains(e) && skipDepth > 0) skipDepth--; 22761 } 22762 22763 @Override 22764 public void preVisit(TSelectSqlStatement nested) { 22765 nestedSelectDepth++; 22766 } 22767 22768 @Override 22769 public void postVisit(TSelectSqlStatement nested) { 22770 nestedSelectDepth--; 22771 } 22772 22773 @Override 22774 public void preVisit(TObjectName node) { 22775 if (skipDepth > 0) return; 22776 if (nestedSelectDepth > 0) return; 22777 if (node.getDbObjectType() != EDbObjectType.column) return; 22778 String name = node.getColumnNameOnly(); 22779 if (name == null || "*".equals(name)) return; 22780 ColumnBinding binding = provider.bindColumn(node); 22781 if (binding != null 22782 && binding.getStatus() == ResolutionStatus.EXACT_MATCH) { 22783 refs.add(new ColumnRef( 22784 binding.getRelationAlias(), binding.getColumnName())); 22785 } else { 22786 // Non-exact or null binding: prefer SQL-written qualifier; 22787 // fall back to the single delete-target name so the 22788 // ColumnRef non-null constraint is satisfied. 22789 String qualifier = node.getTableString(); 22790 String alias = (qualifier != null && !qualifier.isEmpty()) 22791 ? qualifier : fallbackRelationAlias; 22792 refs.add(new ColumnRef(alias, name)); 22793 } 22794 } 22795 }); 22796 return new ArrayList<>(refs); 22797 } 22798 22799 /** 22800 * Thrown when the input falls outside current builder scope or a 22801 * binding fails. Slice 67 attached a {@link Diagnostic} to every 22802 * throw site so external callers can pattern-match on 22803 * {@link DiagnosticCode} rather than parsing message text. The 22804 * legacy {@code (String)} constructor was removed in slice 67; 22805 * use one of the {@link Diagnostic#error} factories and the 22806 * {@link #SemanticIRBuildException(Diagnostic)} constructor. 22807 */ 22808 public static final class SemanticIRBuildException extends RuntimeException { 22809 private final Diagnostic diagnostic; 22810 22811 public SemanticIRBuildException(Diagnostic diagnostic) { 22812 super(java.util.Objects.requireNonNull(diagnostic, "diagnostic").getMessage()); 22813 this.diagnostic = diagnostic; 22814 } 22815 22816 /** 22817 * @return the structured diagnostic for this rejection. Always 22818 * non-null after slice 67. 22819 */ 22820 public Diagnostic getDiagnostic() { 22821 return diagnostic; 22822 } 22823 } 22824}