001package gudusoft.gsqlparser.ir.semantic.builder; 002 003import gudusoft.gsqlparser.EBoundaryType; 004import gudusoft.gsqlparser.EDbObjectType; 005import gudusoft.gsqlparser.EDbVendor; 006import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 007import gudusoft.gsqlparser.util.SQLUtil; 008import gudusoft.gsqlparser.EExpressionType; 009import gudusoft.gsqlparser.EGroupingSetType; 010import gudusoft.gsqlparser.EJoinType; 011import gudusoft.gsqlparser.ELimitRowType; 012import gudusoft.gsqlparser.EPseudoTableType; 013import gudusoft.gsqlparser.EQuantifierType; 014import gudusoft.gsqlparser.ESetOperatorType; 015import gudusoft.gsqlparser.ETableSource; 016import gudusoft.gsqlparser.EUniqueRowFilterType; 017import gudusoft.gsqlparser.TSourceToken; 018import gudusoft.gsqlparser.ir.semantic.ColumnRef; 019import gudusoft.gsqlparser.ir.semantic.Diagnostic; 020import gudusoft.gsqlparser.ir.semantic.DiagnosticCode; 021import gudusoft.gsqlparser.ir.semantic.DiagnosticDescriptorCatalog; 022import gudusoft.gsqlparser.ir.semantic.Severity; 023import gudusoft.gsqlparser.ir.semantic.SourceSpan; 024import gudusoft.gsqlparser.ir.semantic.FrameBound; 025import gudusoft.gsqlparser.ir.semantic.GroupingElement; 026import gudusoft.gsqlparser.nodes.TParseTreeNode; 027import gudusoft.gsqlparser.ir.semantic.LineageEdge; 028import gudusoft.gsqlparser.ir.semantic.LineageRef; 029import gudusoft.gsqlparser.ir.semantic.OutputColumn; 030import gudusoft.gsqlparser.ir.semantic.RelationKind; 031import gudusoft.gsqlparser.ir.semantic.RelationSource; 032import gudusoft.gsqlparser.ir.semantic.RecoveryMetadata; 033import gudusoft.gsqlparser.ir.semantic.RelatedLocation; 034import gudusoft.gsqlparser.ir.semantic.RowLimit; 035import gudusoft.gsqlparser.ir.semantic.RowLimitKind; 036import gudusoft.gsqlparser.ir.semantic.SemanticBuildResult; 037import gudusoft.gsqlparser.ir.semantic.SemanticProgram; 038import gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer; 039import gudusoft.gsqlparser.ir.semantic.SetOperator; 040import gudusoft.gsqlparser.ir.semantic.StatementGraph; 041import gudusoft.gsqlparser.ir.semantic.TargetRelation; 042import gudusoft.gsqlparser.ir.semantic.WindowFrame; 043import gudusoft.gsqlparser.ir.semantic.WindowSpec; 044import gudusoft.gsqlparser.ir.semantic.binding.ColumnBinding; 045import gudusoft.gsqlparser.ir.semantic.joinanalysis.ColumnResolution; 046import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinAnalysisFacts; 047import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpoint; 048import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpointKind; 049import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEntity; 050import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinGraph; 051import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinSourceSyntax; 052import gudusoft.gsqlparser.ir.semantic.joinanalysis.Predicate; 053import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateKind; 054import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateOperand; 055import gudusoft.gsqlparser.ir.semantic.joinanalysis.QueryBlockScope; 056import gudusoft.gsqlparser.ir.semantic.joinanalysis.ScopeKind; 057import gudusoft.gsqlparser.ir.semantic.joinanalysis.SemanticJoinType; 058import gudusoft.gsqlparser.ir.semantic.validation.oracle.OracleLegacyOuterJoinValidationResult; 059import gudusoft.gsqlparser.ir.semantic.validation.oracle.OracleLegacyOuterJoinValidator; 060import gudusoft.gsqlparser.ir.semantic.validation.oracle.PredicateJoinIntent; 061import gudusoft.gsqlparser.ir.semantic.binding.FromSubqueryNaming; 062import gudusoft.gsqlparser.ir.semantic.binding.NameBindingProvider; 063import gudusoft.gsqlparser.ir.semantic.binding.RelationBinding; 064import gudusoft.gsqlparser.ir.semantic.binding.UsingScope; 065import gudusoft.gsqlparser.nodes.TCTE; 066import gudusoft.gsqlparser.nodes.TCTEList; 067import gudusoft.gsqlparser.nodes.TExpression; 068import gudusoft.gsqlparser.nodes.TExpressionList; 069import gudusoft.gsqlparser.nodes.TFetchFirstClause; 070import gudusoft.gsqlparser.nodes.TFunctionCall; 071import gudusoft.gsqlparser.nodes.TLimitClause; 072import gudusoft.gsqlparser.nodes.TOffsetClause; 073import gudusoft.gsqlparser.nodes.TTopClause; 074import gudusoft.gsqlparser.nodes.TGroupBy; 075import gudusoft.gsqlparser.nodes.TGroupByItem; 076import gudusoft.gsqlparser.nodes.TGroupByItemList; 077import gudusoft.gsqlparser.nodes.TGroupingExpressionItem; 078import gudusoft.gsqlparser.nodes.TGroupingSet; 079import gudusoft.gsqlparser.nodes.TGroupingSetItem; 080import gudusoft.gsqlparser.nodes.TGroupingSetItemList; 081import gudusoft.gsqlparser.nodes.TRollupCube; 082import gudusoft.gsqlparser.nodes.TJoin; 083import gudusoft.gsqlparser.nodes.TJoinItem; 084import gudusoft.gsqlparser.nodes.TJoinItemList; 085import gudusoft.gsqlparser.nodes.TJoinList; 086import gudusoft.gsqlparser.nodes.TObjectName; 087import gudusoft.gsqlparser.nodes.TObjectNameList; 088import gudusoft.gsqlparser.nodes.TOrderBy; 089import gudusoft.gsqlparser.nodes.TOutputClause; 090import gudusoft.gsqlparser.nodes.TReturningClause; 091import gudusoft.gsqlparser.nodes.TOrderByItem; 092import gudusoft.gsqlparser.nodes.TOrderByItemList; 093import gudusoft.gsqlparser.nodes.TParseTreeVisitor; 094import gudusoft.gsqlparser.nodes.TPartitionClause; 095import gudusoft.gsqlparser.nodes.TPivotClause; 096import gudusoft.gsqlparser.nodes.TPivotInClause; 097import gudusoft.gsqlparser.nodes.TPivotedTable; 098import gudusoft.gsqlparser.nodes.TUnpivotInClause; 099import gudusoft.gsqlparser.nodes.TUnpivotInClauseItem; 100import gudusoft.gsqlparser.nodes.TResultColumn; 101import gudusoft.gsqlparser.nodes.TResultColumnList; 102import gudusoft.gsqlparser.nodes.TSelectDistinct; 103import gudusoft.gsqlparser.nodes.TTable; 104import gudusoft.gsqlparser.nodes.TWhereClause; 105import gudusoft.gsqlparser.nodes.TWindowDef; 106import gudusoft.gsqlparser.nodes.TWithinGroup; 107import gudusoft.gsqlparser.nodes.TWindowFrame; 108import gudusoft.gsqlparser.nodes.TWindowFrameBoundary; 109import gudusoft.gsqlparser.resolver2.ResolutionStatus; 110import gudusoft.gsqlparser.EInsertSource; 111import gudusoft.gsqlparser.nodes.TColumnDefinition; 112import gudusoft.gsqlparser.nodes.TColumnDefinitionList; 113import gudusoft.gsqlparser.nodes.TViewAliasClause; 114import gudusoft.gsqlparser.nodes.TViewAliasItem; 115import gudusoft.gsqlparser.nodes.TViewAliasItemList; 116import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement; 117import gudusoft.gsqlparser.stmt.TCreateViewSqlStatement; 118import gudusoft.gsqlparser.stmt.TDeleteSqlStatement; 119import gudusoft.gsqlparser.stmt.TInsertSqlStatement; 120import gudusoft.gsqlparser.stmt.TMergeSqlStatement; 121import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 122import gudusoft.gsqlparser.stmt.TUpdateSqlStatement; 123import gudusoft.gsqlparser.nodes.TMergeWhenClause; 124import gudusoft.gsqlparser.nodes.TMergeUpdateClause; 125import gudusoft.gsqlparser.nodes.TMergeInsertClause; 126 127import java.util.ArrayDeque; 128import java.util.ArrayList; 129import java.util.Collections; 130import java.util.Deque; 131import java.util.EnumSet; 132import java.util.HashMap; 133import java.util.HashSet; 134import java.util.IdentityHashMap; 135import java.util.LinkedHashMap; 136import java.util.LinkedHashSet; 137import java.util.List; 138import java.util.Locale; 139import java.util.Map; 140import java.util.Set; 141 142/** 143 * Builds a {@link SemanticProgram} from a parsed and resolved 144 * {@link TSelectSqlStatement}. 145 * 146 * <p>Current scope (after slice 9): SELECT with one or more base-table or 147 * CTE sources, optional WHERE, optional JOIN of base tables with ON 148 * conditions, optional GROUP BY (slice 6), optional WITH clause including 149 * chained CTEs (each CTE sees the ones declared strictly before it), 150 * optional FROM-clause subquery (slice 5), optional row-deduplication via 151 * {@code SELECT DISTINCT} or Oracle's {@code SELECT UNIQUE} synonym 152 * (slice 8 — see {@link StatementGraph#isDistinct()}), optional ORDER BY 153 * over physical column references or column-bearing expressions 154 * (slice 9 — see {@link StatementGraph#getOrderByColumnRefs()}). 155 * Expression projections like {@code salary * 2 AS doubled} or 156 * {@code a.x + a.y} are accepted and marked 157 * {@link OutputColumn#isDerived()}; aggregate function calls (slice 6) 158 * are flagged via {@link OutputColumn#isAggregate()}. 159 * 160 * <p>Slice 9 lifts {@code ORDER BY} for sort keys that are physical 161 * column references or expressions over them. The collected references 162 * surface as {@link StatementGraph#getOrderByColumnRefs()}. Sort 163 * direction ({@code ASC}/{@code DESC}) and null placement 164 * ({@code NULLS FIRST}/{@code NULLS LAST}) are presentation metadata 165 * and are not modelled. Ordinal forms ({@code ORDER BY 1}) and 166 * projection-alias forms ({@code SELECT id AS x ... ORDER BY x}) are 167 * rejected so the dependency information is never silently lost; a 168 * later slice can model output-position references explicitly. The 169 * canonical lineage model (slice 7) deliberately ignores ORDER BY — 170 * sort order changes presentation, not column dependency or row-set 171 * membership. 172 * 173 * <p>Row-limit clauses ({@code LIMIT}, {@code TOP}, {@code OFFSET}, 174 * {@code FETCH FIRST}) are rejected statement-wide, including the 175 * SQL Server-style {@code ORDER BY ... OFFSET ... FETCH NEXT}. With 176 * a row-limit present, {@code ORDER BY} ceases to be presentation-only 177 * and starts deciding which rows survive — the canonical-model 178 * exclusion would no longer be sound, so the entire statement is out 179 * of scope until a future slice models row-limit semantics. 180 * 181 * <p>Slice 10 lifts {@code HAVING}: the predicate's column references 182 * are collected into {@link StatementGraph#getHavingColumnRefs()} via 183 * {@link #buildHavingColumnRefs}. The same visitor pattern as projection 184 * and ORDER BY rejects subqueries (scalar, EXISTS, IN-SELECT, ANY/ALL/ 185 * SOME) and window functions before {@link #collectColumnRefs} runs, so 186 * inner-scope refs never leak. HAVING without GROUP BY is supported (the 187 * parser still attaches a {@link TGroupBy} node with empty items). 188 * HAVING is row-influence semantically but does not contribute to the 189 * canonical lineage model — see 190 * {@link StatementGraph#getHavingColumnRefs()} for why. 191 * 192 * <p>Slice 11 lifted uncorrelated scalar subqueries in projection; 193 * scalar bodies are extracted as their own statements via 194 * {@link #extractScalarSubqueriesAsStatements} with the synthetic-name 195 * convention {@code <scalar_subquery_<index>>}. 196 * 197 * <p>Slice 12 lifts set operations (UNION / UNION ALL / INTERSECT / 198 * INTERSECT ALL / MINUS / MINUS ALL / EXCEPT / EXCEPT ALL) at the top 199 * level and as CTE bodies. Each branch becomes its own 200 * {@link StatementGraph} with synthetic name 201 * {@code <set_op_branch_<index>>}; the outer set-op statement carries 202 * empty {@code relations} and lineage edges fan out per-position to 203 * each branch. The flatten descends the left-leaning AST iteratively 204 * (per CLAUDE.md — no recursion on {@code leftStmt}/{@code rightStmt}). 205 * See {@link #buildSetOpProgram}. 206 * 207 * <p>Slice 22 lifts window-function frame clauses 208 * ({@code ROWS}/{@code RANGE}/{@code GROUPS BETWEEN ...}); the frame 209 * unit, start bound, and optional end bound are captured in 210 * {@link WindowFrame} hung off 211 * {@link WindowSpec#getFrame()}. Frame info is presentation-only 212 * (dlineage XML harvests no frame information) and does NOT contribute 213 * to the canonical lineage model — same status as slice-13's 214 * PARTITION BY / OVER ORDER BY refs. Per-bound EXCLUDE clauses 215 * (Netezza-reachable) and non-constant offsets (PG 216 * {@code simple_object_name_t}, ANSI {@code parenthesis_t}) are still 217 * rejected. 218 * 219 * <p>Still rejected: {@code WITH RECURSIVE}, {@code DISTINCT ON (...)} 220 * and other non-{@code DISTINCT}/{@code UNIQUE} row-filters, 221 * scalar-body constant-only projections (zero column refs), 222 * correlated scalar subqueries, scalar bodies with 223 * subqueries in WHERE/JOIN ON/GROUP BY, multi-column scalar inner, 224 * scalar subqueries embedded in larger projection expressions including 225 * EXISTS-in-projection, embedded window functions in larger projection 226 * expressions, window functions in scalar-subquery bodies, window 227 * functions in WHERE/JOIN ON/GROUP BY/HAVING/ORDER BY, empty 228 * {@code OVER ()}, frame clauses with non-constant offsets (PG 229 * {@code simple_object_name_t}, ANSI {@code parenthesis_t}), frame 230 * {@code EXCLUDE} clauses (Netezza-reachable), named windows, 231 * vendor-specific window extensions ({@code FILTER (WHERE ...)}, 232 * {@code WITHIN GROUP}, 233 * {@code KEEP DENSE_RANK}, Hive {@code DISTRIBUTE BY}/{@code CLUSTER BY}/ 234 * {@code SORT BY}/{@code PARTITION BY ... SORT (...)}), non-physical 235 * {@code PARTITION BY} / OVER {@code ORDER BY} refs (literals, 236 * subqueries, function calls, expressions, expression-alias references), 237 * window function names outside the slice-13 allowlist, 238 * (slice 63 lifts explicit {@code CROSS JOIN}, slice 64 lifts 239 * {@code JOIN ... USING (...)}, and slice 66 lifts {@code NATURAL JOIN} 240 * at outer / CTE-body / FROM-subquery-body call sites; all three stay 241 * rejected inside scalar / set-op-branch / set-op-CTE / predicate bodies; 242 * NATURAL additionally requires resolvable catalog metadata on both 243 * sides, with a side-specific reject otherwise), duplicate aliases, 244 * Oracle 245 * {@code ORDER SIBLINGS BY}, Teradata {@code ORDER BY ... RESET WHEN}, 246 * row-limit clauses, ORDER BY ordinals/aliases, Teradata {@code QUALIFY} 247 * clause, set operations nested in FROM-subquery / scalar bodies, 248 * mixed-operator and mixed-{@code _ALL} set-op chains, set-op outer 249 * ORDER BY / row-limit clauses, set-op internal-node modifiers, branch 250 * column-count mismatch, set-op branches with FROM-subquery / scalar 251 * projection / their own CTE list, nested WITH on set-op CTE body. The 252 * builder fails fast outside this scope so callers see the unsupported 253 * case immediately rather than receiving a half-built IR. 254 * 255 * <p><b>API status: advanced/preview.</b> This low-level builder requires the 256 * caller to own parsing, Resolver2 configuration, statement dispatch, 257 * catalog/provider consistency, diagnostics, and recovery. New production 258 * integrations should use 259 * {@link gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer}; advanced 260 * integrations should use the atomic {@code buildResult} methods rather than 261 * deprecated {@code build} compatibility adapters. 262 */ 263public final class SemanticIRBuilder { 264 265 /** 266 * Reserved name prefix for synthetic scalar-subquery body 267 * statements (slice 11). Names take the form 268 * {@code "<scalar_subquery_<index>>"}; the angle brackets ensure 269 * no collision with real CTE names or FROM-clause aliases. 270 * {@link #isScalarSyntheticName(String)} is the only authorised 271 * detector — both this builder and 272 * {@code SemanticIRProjector.BodyIndexes} use it so the convention 273 * lives in one place. 274 */ 275 public static final String SCALAR_BODY_PREFIX = "<scalar_subquery_"; 276 277 /** 278 * Strict regex for synthetic scalar-subquery-body names. Format is 279 * exactly {@code <scalar_subquery_<digits>>} — pinning the digits 280 * suffix and the closing angle bracket prevents a real (quoted) 281 * CTE alias that happens to begin with the prefix from being 282 * misclassified as a synthetic name and silently skipped by 283 * {@code BodyIndexes}. 284 */ 285 private static final java.util.regex.Pattern SCALAR_NAME_PATTERN = 286 java.util.regex.Pattern.compile("<scalar_subquery_\\d+>"); 287 288 /** 289 * True iff {@code name} is a synthetic scalar-subquery-body name 290 * created by this builder (slice 11). Used by 291 * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when 292 * building the CTE/FROM-subquery name lookup tables — scalar 293 * bodies are reached only via lineage edges, never via relations. 294 * 295 * <p>The match is strict: the name must be the full reserved 296 * pattern {@code <scalar_subquery_<digits>>}. A real CTE alias 297 * that happens to start with {@code <scalar_subquery_} but 298 * doesn't match the digits-and-closing-bracket suffix is NOT 299 * skipped (codex impl-review round-1 SHOULD 2). 300 */ 301 public static boolean isScalarSyntheticName(String name) { 302 return name != null && SCALAR_NAME_PATTERN.matcher(name).matches(); 303 } 304 305 /** 306 * Reserved name prefix for synthetic set-op-branch body statements 307 * (slice 12). Names take the form {@code "<set_op_branch_<index>>"}; 308 * the angle brackets ensure no collision with real CTE names or 309 * FROM-clause aliases. {@link #isSetOpBranchSyntheticName(String)} is 310 * the only authorised detector — both this builder and 311 * {@code SemanticIRProjector.BodyIndexes} use it so the convention 312 * lives in one place (slice-11 process lesson #10 generalised). 313 */ 314 public static final String SET_OP_BRANCH_PREFIX = "<set_op_branch_"; 315 316 /** 317 * Strict regex for synthetic set-op-branch-body names. Format is 318 * exactly {@code <set_op_branch_<digits>>} — pinning the digits 319 * suffix and the closing angle bracket prevents a real (quoted) CTE 320 * alias that happens to begin with the prefix from being 321 * misclassified as a synthetic name and silently skipped by 322 * {@code BodyIndexes}. 323 */ 324 private static final java.util.regex.Pattern SET_OP_BRANCH_NAME_PATTERN = 325 java.util.regex.Pattern.compile("^<set_op_branch_\\d+>$"); 326 327 /** 328 * True iff {@code name} is a synthetic set-op-branch-body name 329 * created by this builder (slice 12). Used by 330 * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when 331 * building the CTE/FROM-subquery name lookup tables — set-op 332 * branches are reached only via lineage edges, never via relations. 333 * 334 * <p>The match is strict: the name must be the full reserved 335 * pattern {@code <set_op_branch_<digits>>}. 336 */ 337 public static boolean isSetOpBranchSyntheticName(String name) { 338 return name != null && SET_OP_BRANCH_NAME_PATTERN.matcher(name).matches(); 339 } 340 341 /** 342 * Reserved name prefix for synthetic predicate-subquery body statements 343 * (slice 23 — uncorrelated EXISTS extracted from outer-SELECT JOIN ON). 344 * Names take the form {@code "<predicate_subquery_<index>>"}; the angle 345 * brackets ensure no collision with real CTE names or FROM-clause aliases. 346 * {@link #isPredicateSubquerySyntheticName(String)} is the only authorised 347 * detector — both this builder and {@code SemanticIRProjector.BodyIndexes} 348 * use it so the convention lives in one place. 349 */ 350 public static final String PREDICATE_BODY_PREFIX = "<predicate_subquery_"; 351 352 /** 353 * Strict regex for synthetic predicate-subquery-body names. Format is 354 * exactly {@code <predicate_subquery_<digits>>}; pinning the digit suffix 355 * and the closing angle bracket prevents a real (quoted) CTE alias that 356 * happens to begin with the prefix from being misclassified as a synthetic 357 * name and silently skipped by {@code BodyIndexes}. 358 */ 359 private static final java.util.regex.Pattern PREDICATE_BODY_NAME_PATTERN = 360 java.util.regex.Pattern.compile("<predicate_subquery_\\d+>"); 361 362 /** 363 * True iff {@code name} is a synthetic predicate-subquery-body name 364 * created by this builder (slice 23). Used by 365 * {@code SemanticIRProjector.BodyIndexes} to skip such bodies when 366 * building the CTE/FROM-subquery name lookup tables — predicate-subquery 367 * bodies are unreachable from outer (no relation edge, no lineage edge). 368 */ 369 public static boolean isPredicateSubquerySyntheticName(String name) { 370 return name != null && PREDICATE_BODY_NAME_PATTERN.matcher(name).matches(); 371 } 372 373 /** 374 * Aggregate function names recognized by the builder's per-output 375 * aggregate flag detection (slice 6 originated; slice 29 / slice 30 376 * extended). Treated as case-insensitive. Callers should go through 377 * {@link #isAggregateFunction(TExpression)} rather than reading this 378 * set directly. 379 * 380 * <p>Slice-29 extensions: dialect aggregates {@code listagg}, 381 * {@code string_agg}, {@code group_concat}, {@code array_agg}. 382 * Slice-30 extension: {@code mode} (PostgreSQL ordered-set aggregate; 383 * admitted via the slice-29 WITHIN GROUP path under 384 * {@code findUnsupportedWithinGroupFunctionName}). Slice 30 also 385 * removes {@code mode} from {@link #WINDOW_FUNCTION_NAMES} via an 386 * explicit {@code s.remove("mode")} so the slice-13 window allowlist 387 * isn't widened — see {@link #WINDOW_FUNCTION_NAMES} JavaDoc and 388 * {@code DlineageXmlProjector.ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} for 389 * the matching window-vs-aggregate discriminator override. 390 */ 391 private static final Set<String> AGGREGATE_FUNCTION_NAMES; 392 static { 393 Set<String> s = new HashSet<>(); 394 s.add("count"); 395 s.add("sum"); 396 s.add("avg"); 397 s.add("min"); 398 s.add("max"); 399 s.add("stddev"); 400 s.add("variance"); 401 s.add("var_samp"); 402 s.add("var_pop"); 403 s.add("stddev_samp"); 404 s.add("stddev_pop"); 405 // Common dialect-specific aggregates so the flag has fewer false negatives. 406 s.add("listagg"); // Oracle, PostgreSQL 16+ 407 s.add("string_agg"); // PostgreSQL, SQL Server 408 s.add("group_concat"); // MySQL 409 s.add("array_agg"); // PostgreSQL, Snowflake, BigQuery 410 // Slice 30: PostgreSQL ordered-set aggregate. Unlike percentile_cont / 411 // percentile_disc / rank-family, mode() has no documented window form 412 // in any GSP-supported vendor; admitting it lets the WITHIN GROUP path 413 // accept it in JOIN ON predicate subqueries (slice 29 lift extension) 414 // AND lets DlineageXmlProjector mark its output aggregate=true. 415 // Defensive: WINDOW_FUNCTION_NAMES below subtracts mode after 416 // s.addAll(AGGREGATE_FUNCTION_NAMES) so mode() OVER (...) stays 417 // rejected by the slice-13 window allowlist. 418 s.add("mode"); // PostgreSQL ordered-set aggregate (slice 30) 419 AGGREGATE_FUNCTION_NAMES = Collections.unmodifiableSet(s); 420 } 421 422 /** 423 * Slice 42: hypothetical-set ordered-set aggregate function names that 424 * are ALSO valid window functions. Unlike {@link #AGGREGATE_FUNCTION_NAMES} 425 * these names are admitted as aggregates ONLY when the call carries a 426 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} 427 * {@link TWindowDef} attachment (Oracle / SQL Server parser style — 428 * {@code RANK(100) WITHIN GROUP (ORDER BY x.id)} produces 429 * {@code fn.getWindowDef()!=null}, {@code wd.isIncludingOverClause()== 430 * false}, {@code wd.getWithinGroup()!=null}). Any other shape — direct 431 * {@code fn.getWithinGroup()} (PG / Snowflake style), 432 * {@code fn.getWindowDef()} with {@code OVER (...)}, or no attachment 433 * at all — keeps the existing window-function classification. 434 * 435 * <p>The set is intentionally NOT merged into 436 * {@link #AGGREGATE_FUNCTION_NAMES} because that would also lift the PG 437 * direct-attachment hypothetical-set form ({@code rank(0.5) WITHIN GROUP 438 * (ORDER BY x.salary)}). Pre-plan probe ({@code /tmp/probe42/Probe42.java}) 439 * confirmed PG dlineage XML for hypothetical-set is structurally 440 * indistinguishable from {@code rank() OVER (ORDER BY x)} (both emit 441 * {@code clauseType="orderby"} fdr) — admitting PG hypothetical-set 442 * would manufacture an {@code AGGREGATION_MISMATCH} divergence on the 443 * windowed form because the projector's 444 * {@code DlineageXmlProjector.isWindowFunctionResultset} cannot tell 445 * the two forms apart on PG. 446 * 447 * <p>The Oracle / MSSQL hypothetical-set form, by contrast, emits 448 * neither a {@code clauseType="orderby"} fdr nor a 449 * {@code clauseType="selectList"} fdr (probe-confirmed) — so the 450 * projector's slice-13 windowed-vs-aggregate discriminator returns 451 * {@code false} and the matching projector-side 452 * {@code AGGREGATE_FUNCTION_NAMES} entry marks the output aggregate. 453 * Their OVER form ({@code RANK() OVER (ORDER BY x.id)}) emits 454 * {@code clauseType="orderby"} as expected and stays correctly 455 * classified as windowed. 456 * 457 * <p>Vendor-gated to Oracle / MSSQL inside 458 * {@link #isAdmittedTopLevelWithinGroupAggregate} and 459 * {@link #findUnsupportedWithinGroupFunctionName}; the PG 460 * direct-attachment shape never satisfies the 461 * {@link #isWithinGroupOnlyWindowDef} predicate (because PG sets 462 * {@code fn.getWindowDef()==null}) so the carve-out cannot accidentally 463 * fire on PG. 464 */ 465 private static final Set<String> HYPOTHETICAL_SET_AGGREGATE_NAMES; 466 static { 467 Set<String> s = new HashSet<>(); 468 s.add("rank"); 469 s.add("dense_rank"); 470 s.add("percent_rank"); 471 s.add("cume_dist"); 472 HYPOTHETICAL_SET_AGGREGATE_NAMES = Collections.unmodifiableSet(s); 473 } 474 475 /** 476 * Slice 42: true iff {@code fn} is an Oracle / MSSQL hypothetical-set 477 * ordered-set aggregate call shape — {@code RANK} / {@code DENSE_RANK} / 478 * {@code PERCENT_RANK} / {@code CUME_DIST} with 479 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} 480 * {@link TWindowDef} attachment. Used both as a name-whitelist 481 * discriminator (so PG direct {@code fn.getWithinGroup()} cannot 482 * accidentally pass through, since PG sets {@code fn.getWindowDef()== 483 * null}) and as the {@link #isAggregateFunction} carve-out trigger. 484 */ 485 private static boolean isHypotheticalSetWithinGroupCall(TFunctionCall fn) { 486 if (fn == null) return false; 487 if (!isWithinGroupOnlyWindowDef(fn.getWindowDef())) return false; 488 if (fn.getFunctionName() == null) return false; 489 String name = fn.getFunctionName().toString(); 490 if (name == null || name.isEmpty()) return false; 491 return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains( 492 name.toLowerCase(Locale.ROOT)); 493 } 494 495 /** 496 * Predicate-bearing join types accepted by the current builder. 497 * Slice 64: each must carry either an ON condition or a USING 498 * clause; the per-key {@code joinColumnRefs} emission happens in 499 * {@link #buildRelations} for USING and via 500 * {@link #collectColumnRefs} for ON. NATURAL, semi/anti, 501 * vendor-specific joins, and nested-join sources stay rejected so 502 * the IR cannot quietly drop a row-set predicate. The unqualified 503 * output-naming case for USING merged keys is deferred to S65. 504 */ 505 private static final EnumSet<EJoinType> ALLOWED_PREDICATE_JOIN_TYPES = EnumSet.of( 506 EJoinType.inner, 507 EJoinType.left, 508 EJoinType.right, 509 EJoinType.full, 510 EJoinType.fullouter, 511 EJoinType.leftouter, 512 EJoinType.rightouter, 513 EJoinType.join, 514 // MySQL STRAIGHT_JOIN is an optimizer join-order hint with no 515 // effect on result semantics; it carries a normal ON/USING 516 // clause and mapSemanticJoinType() degrades it to INNER. Admit 517 // it on the predicate path so it emits a standard INNER join 518 // entity instead of aborting analysis with UNSUPPORTED_JOIN_TYPE. 519 EJoinType.straight 520 ); 521 522 /** 523 * Slice 63 — join types admitted by the builder but that must NOT 524 * carry an ON or USING clause. Currently just {@code CROSS}; the 525 * tier exists so that future ON-less shapes can join the same path 526 * with the same shape contract. Slice 66 added a separate 527 * {@link #NATURAL_JOIN_TYPES} tier because NATURAL has its own 528 * catalog-required reject path that CROSS does not. 529 */ 530 private static final EnumSet<EJoinType> ALLOWED_ON_LESS_JOIN_TYPES = EnumSet.of( 531 EJoinType.cross 532 ); 533 534 /** 535 * Slice 66 — NATURAL join types. Each MUST NOT carry an ON or USING 536 * clause. Each MUST have resolvable catalog metadata on BOTH sides; 537 * a missing-catalog reject fires inside {@link #buildRelations} 538 * with a side-specific diagnostic. The shared-column list is 539 * inferred from the running {@link LeftOutputState} ∩ right's 540 * catalog and feeds into {@link #emitMergedJoinRefs} the same way 541 * a syntactically-declared USING list does. 542 */ 543 private static final EnumSet<EJoinType> NATURAL_JOIN_TYPES = EnumSet.of( 544 EJoinType.natural, 545 EJoinType.natural_inner, 546 EJoinType.natural_left, 547 EJoinType.natural_right, 548 EJoinType.natural_leftouter, 549 EJoinType.natural_rightouter, 550 EJoinType.natural_full, 551 EJoinType.natural_fullouter 552 ); 553 554 private static boolean isNaturalJoinType(EJoinType jt) { 555 return jt != null && NATURAL_JOIN_TYPES.contains(jt); 556 } 557 558 /** 559 * SQL Server lateral joins: {@code CROSS APPLY} / {@code OUTER APPLY}. 560 * The right operand is a correlated derived table (subquery or 561 * table-valued function) whose correlation lives <em>inside</em> the 562 * right operand (its WHERE clause for the subquery form, its function 563 * arguments for the TVF form) rather than on an ON/USING clause. So 564 * APPLY is admitted on the ON-less tier (like {@code CROSS}); it MUST 565 * NOT carry ON/USING. {@code mapSemanticJoinType} degrades 566 * {@code crossapply -> INNER} and {@code outerapply -> LEFT}; the 567 * lateral nature is carried by {@link JoinEntity#isLateral()} (mirrors 568 * how NATURAL is carried by {@code isNatural()} rather than its own 569 * join type). The correlation is preserved by the normal child 570 * StatementGraph the right derived table already produces, so it is 571 * NOT lifted onto the join edge (the predicate references the inner 572 * relation and the outer scope, not the right endpoint). 573 */ 574 private static final EnumSet<EJoinType> LATERAL_JOIN_TYPES = EnumSet.of( 575 EJoinType.crossapply, 576 EJoinType.outerapply 577 ); 578 579 private static boolean isLateralJoinType(EJoinType jt) { 580 return jt != null && LATERAL_JOIN_TYPES.contains(jt); 581 } 582 583 /** 584 * True when a join operand is an ANSI / PostgreSQL {@code LATERAL} derived 585 * table or table-valued function. Unlike the SQL Server APPLY forms (which 586 * are encoded in the join <em>type</em> and matched by 587 * {@link #isLateralJoinType}), the {@code LATERAL} keyword sits on the right 588 * operand itself: the parser captures it as that {@link TTable}'s start 589 * token (covers {@code , LATERAL (...)}, {@code CROSS JOIN LATERAL (...)}, 590 * {@code JOIN LATERAL (...) ON ...}, and {@code LATERAL func(...)}). A plain 591 * derived table starts with {@code (} and a base relation with its name, so 592 * this never false-matches a non-lateral operand. 593 */ 594 private static boolean isLateralTable(TTable t) { 595 if (t == null) { 596 return false; 597 } 598 // LATERAL always precedes a derived table / table-valued function / 599 // xmltable, never a base relation. So a plain object-name table that 600 // happens to be named `lateral` (legal where LATERAL is unreserved, or 601 // when quoted) is NOT a lateral join even though its start token reads 602 // `lateral` — exclude the base-relation shape before the token check. 603 if (t.getTableType() == ETableSource.objectname) { 604 return false; 605 } 606 TSourceToken st = t.getStartToken(); 607 return st != null && "LATERAL".equalsIgnoreCase(st.toString()); 608 } 609 610 private SemanticIRBuilder() {} 611 612 /** 613 * Session owned by one atomic {@code build*Result} invocation. Deep static 614 * builder helpers route diagnostics to the active session, but the facts 615 * leave the builder only through {@link SemanticBuildResult}; consumers do 616 * not coordinate a second drain operation. 617 */ 618 private static final ThreadLocal<BuildSession> ACTIVE_BUILD_SESSION = 619 new ThreadLocal<>(); 620 621 /** 622 * Compatibility snapshot for legacy {@code build* + drain} callers. It is 623 * populated only after an atomic result is complete and is never consumed 624 * by {@link SqlSemanticAnalyzer} or another core pipeline stage. 625 */ 626 private static final ThreadLocal<List<Diagnostic>> LEGACY_BUILD_DIAGNOSTICS = 627 new ThreadLocal<>(); 628 629 private static void recordBuildDiagnostic(Diagnostic diagnostic) { 630 BuildSession session = ACTIVE_BUILD_SESSION.get(); 631 if (session == null) { 632 throw new IllegalStateException( 633 "build diagnostic emitted outside an active build session"); 634 } 635 session.report(diagnostic); 636 } 637 638 private static void recordBuildWarning(Diagnostic warning) { 639 if (warning.getSeverity() != Severity.WARN) { 640 throw new IllegalArgumentException( 641 "recordBuildWarning requires WARN severity"); 642 } 643 recordBuildDiagnostic(warning); 644 } 645 646 /** Record a recovered subject after its root diagnostic was reported. */ 647 private static void recordBuildRecovery(RecoveryMetadata.Entry entry) { 648 BuildSession session = ACTIVE_BUILD_SESSION.get(); 649 if (session == null) { 650 throw new IllegalStateException( 651 "build recovery emitted outside an active build session"); 652 } 653 session.recover(entry); 654 } 655 656 /** 657 * Clear the current thread's legacy compatibility snapshot. New callers 658 * should use a {@code build*Result} method and need no clear/drain calls. 659 */ 660 public static void clearBuildDiagnostics() { 661 LEGACY_BUILD_DIAGNOSTICS.remove(); 662 } 663 664 /** 665 * Return the current thread's legacy compatibility snapshot without 666 * clearing it. Each legacy top-level build replaces the snapshot, so an 667 * undrained prior invocation cannot contaminate the next one. 668 */ 669 public static List<Diagnostic> pendingBuildDiagnostics() { 670 List<Diagnostic> snapshot = LEGACY_BUILD_DIAGNOSTICS.get(); 671 if (snapshot == null || snapshot.isEmpty()) { 672 return Collections.emptyList(); 673 } 674 return new ArrayList<>(snapshot); 675 } 676 677 /** 678 * Read and clear the current thread's legacy compatibility snapshot. 679 * Production analysis consumes {@link SemanticBuildResult} directly. 680 */ 681 public static List<Diagnostic> drainBuildDiagnostics() { 682 List<Diagnostic> pending = pendingBuildDiagnostics(); 683 LEGACY_BUILD_DIAGNOSTICS.remove(); 684 return pending; 685 } 686 687 /** 688 * Catalog-less {@code NATURAL JOIN} degrade — record the 689 * {@link DiagnosticCode#NATURAL_CATALOG_REQUIRED} reject as a non-fatal 690 * {@link Severity#WARN} instead of throwing. The join still appears in 691 * the join graph as a bare NATURAL entity (empty conditions / USING 692 * columns, both endpoints populated); only the shared-column predicates 693 * are absent. Shared between the SELECT-side {@link #buildRelations} and 694 * the joined-UPDATE FROM path so the wording stays identical to the 695 * former fatal diagnostic. 696 */ 697 private static void recordNaturalDegradeWarning(NaturalKeyResult r, TJoinItem item) { 698 recordBuildWarning(Diagnostic.warn( 699 DiagnosticCode.NATURAL_CATALOG_REQUIRED, 700 formatNaturalCatalogReject(r), item)); 701 } 702 703 /** 704 * Terminate a clause column-collection pass that accumulated non-exact 705 * (unresolvable) column bindings. Normally fatal 706 * ({@link DiagnosticCode#COLUMN_BINDING_NON_EXACT}); but two structural 707 * anchors degrade it to a non-fatal warning (GSP R6) — record the warning 708 * and let the build continue (the unresolved refs are simply omitted, 709 * never fabricated) instead of discarding the whole statement graph: 710 * 711 * <ol> 712 * <li><b>USING join anchor</b> — the FROM clause carries a 713 * {@code JOIN ... USING} (the scope has merged keys). The join 714 * structure is fully known and only the unqualified non-key SELECT/ 715 * WHERE columns are unplaceable without a catalog.</li> 716 * <li><b>Join-graph anchor</b> — the FROM clause resolved a fully-built 717 * join graph (two or more endpoints from explicit ON / CROSS / comma 718 * predicates; {@link NameBindingProvider#hasJoinStructureAnchor()}). 719 * An explicit ON / CROSS join graph is just as fully determined as a 720 * USING join — the only thing missing without a catalog is 721 * <em>which side</em> an unqualified column belongs to, which is 722 * exactly the non-fatal case the USING branch already tolerates.</li> 723 * </ol> 724 * 725 * <p>BOTH anchors fire ONLY when every reject is an <em>unqualified</em> 726 * column miss ({@code allRejectsUnqualified}). A qualified reference that 727 * fails to bind (e.g. {@code b.id} pointing at the wrong side, or an 728 * unregistered catalog column under {@code JOIN ... USING}) is a genuine 729 * error, not a which-side ambiguity, and stays fatal under either anchor. 730 * 731 * <p>Catalog-less NATURAL joins are handled earlier by the 732 * {@code hasNaturalDegrade()} fallback. The fatal path remains for the 733 * genuinely structureless case (no resolvable join graph) and for any 734 * qualified-reference miss. 735 */ 736 private static void rejectNonExactBindings(List<String> rejects, 737 NameBindingProvider provider, 738 boolean allRejectsUnqualified) { 739 if (provider != null && allRejectsUnqualified 740 && provider.getUsingScope().hasMergedKeys()) { 741 recordBuildWarning(Diagnostic.warn(DiagnosticCode.COLUMN_BINDING_NON_EXACT, 742 "non-exact column bindings (USING join, no aliases): " + rejects, null)); 743 return; 744 } 745 if (provider != null && provider.hasJoinStructureAnchor() 746 && allRejectsUnqualified) { 747 recordBuildWarning(Diagnostic.warn(DiagnosticCode.COLUMN_BINDING_NON_EXACT, 748 "non-exact column bindings (join graph resolved, no aliases/catalog): " 749 + rejects, null)); 750 return; 751 } 752 throw new SemanticIRBuildException(Diagnostic.error( 753 DiagnosticCode.COLUMN_BINDING_NON_EXACT, 754 "non-exact column bindings: " + rejects, null)); 755 } 756 757 /** 758 * Options in force for the {@code build*} call currently running on this 759 * thread. Set by the three-argument {@code build*} overloads for exactly 760 * the duration of their delegate call and restored (not merely cleared) 761 * afterwards, so a nested build cannot leak its options to the caller and 762 * a two-argument call always sees {@link SemanticIRBuildOptions#defaults()}. 763 * 764 * <p>Thread-local rather than a threaded parameter because the deep 765 * recursion between the {@code build*} entry points and the nested-body 766 * extractors is many frames wide. It is restored at the same atomic build 767 * boundary as {@link #ACTIVE_BUILD_SESSION}. 768 */ 769 private static final ThreadLocal<SemanticIRBuildOptions> ACTIVE_OPTIONS = 770 new ThreadLocal<>(); 771 772 /** @return the options for the in-flight build; never null. */ 773 private static SemanticIRBuildOptions activeOptions() { 774 SemanticIRBuildOptions o = ACTIVE_OPTIONS.get(); 775 return o == null ? SemanticIRBuildOptions.defaults() : o; 776 } 777 778 /** 779 * Run {@code body} with {@code options} in force, restoring whatever was 780 * previously in force on this thread (including "nothing") on the way out, 781 * on both the normal and the exceptional path. 782 */ 783 private static SemanticProgram withOptions( 784 SemanticIRBuildOptions options, 785 java.util.function.Supplier<SemanticProgram> body) { 786 if (options == null) { 787 throw new IllegalArgumentException("options must not be null"); 788 } 789 SemanticIRBuildOptions previous = ACTIVE_OPTIONS.get(); 790 ACTIVE_OPTIONS.set(options); 791 try { 792 return body.get(); 793 } finally { 794 if (previous == null) { 795 ACTIVE_OPTIONS.remove(); 796 } else { 797 ACTIVE_OPTIONS.set(previous); 798 } 799 } 800 } 801 802 /** Execute one build and publish its program and diagnostics atomically. */ 803 private static SemanticBuildResult executeBuild( 804 SemanticIRBuildOptions options, 805 java.util.function.Supplier<SemanticProgram> body) { 806 if (options == null) { 807 throw new IllegalArgumentException("options must not be null"); 808 } 809 BuildSession previous = ACTIVE_BUILD_SESSION.get(); 810 BuildSession session = new BuildSession(); 811 ACTIVE_BUILD_SESSION.set(session); 812 try { 813 SemanticProgram program = withOptions(options, body); 814 return session.completed(program); 815 } catch (SemanticIRBuildException ex) { 816 session.reportMissing(ex.getDiagnostics()); 817 return session.rejected(); 818 } finally { 819 if (previous == null) { 820 ACTIVE_BUILD_SESSION.remove(); 821 } else { 822 ACTIVE_BUILD_SESSION.set(previous); 823 } 824 } 825 } 826 827 /** Adapt an atomic result to the legacy build-and-drain protocol. */ 828 private static SemanticProgram publishLegacyResult(SemanticBuildResult result) { 829 List<Diagnostic> diagnostics = result.getDiagnostics(); 830 // A legacy build can be invoked re-entrantly from a provider callback. 831 // Its caller receives only a SemanticProgram, so merge the completed 832 // atomic facts into the restored parent session; otherwise the inner 833 // diagnostics would survive only in the legacy snapshot that the 834 // outer atomic/analyzer pipeline deliberately never drains. 835 BuildSession parent = ACTIVE_BUILD_SESSION.get(); 836 // A rejected nested invocation has no semantic payload belonging to 837 // the parent. If its exception escapes, executeBuild records it on the 838 // normal exceptional path; if the callback handles it, it must remain 839 // isolated from the otherwise-successful parent result. 840 if (parent != null && result.getRecoveredProgram() != null) { 841 parent.absorb(result); 842 } 843 if (diagnostics.isEmpty()) { 844 LEGACY_BUILD_DIAGNOSTICS.remove(); 845 } else { 846 LEGACY_BUILD_DIAGNOSTICS.set(Collections.unmodifiableList( 847 new ArrayList<>(diagnostics))); 848 } 849 850 SemanticProgram program = result.getRecoveredProgram(); 851 if (program != null) { 852 return program; 853 } 854 throw SemanticIRBuildException.withDiagnostics(diagnostics); 855 } 856 857 private static final class BuildSession { 858 private final List<Diagnostic> diagnostics = new ArrayList<>(); 859 private final List<RecoveryMetadata.Entry> recoveryEntries = 860 new ArrayList<>(); 861 862 private void report(Diagnostic diagnostic) { 863 diagnostics.add(java.util.Objects.requireNonNull( 864 diagnostic, "diagnostic")); 865 } 866 867 private void reportMissing(List<Diagnostic> additional) { 868 for (Diagnostic diagnostic : additional) { 869 if (!containsIdentity(diagnostics, diagnostic)) { 870 report(diagnostic); 871 } 872 } 873 } 874 875 private void absorb(SemanticBuildResult nested) { 876 reportMissing(nested.getDiagnostics()); 877 for (RecoveryMetadata.Entry entry 878 : nested.getRecoveryMetadata().getEntries()) { 879 if (!recoveryEntries.contains(entry)) { 880 recover(entry); 881 } 882 } 883 } 884 885 private static boolean containsIdentity(List<Diagnostic> values, 886 Diagnostic candidate) { 887 for (Diagnostic value : values) { 888 if (value == candidate) { 889 return true; 890 } 891 } 892 return false; 893 } 894 895 private void recover(RecoveryMetadata.Entry entry) { 896 RecoveryMetadata.Entry nonNull = java.util.Objects.requireNonNull( 897 entry, "entry"); 898 if (!diagnostics.contains(nonNull.getRootDiagnostic())) { 899 throw new IllegalArgumentException( 900 "recovery root diagnostic must be reported first"); 901 } 902 recoveryEntries.add(nonNull); 903 } 904 905 private SemanticBuildResult completed(SemanticProgram program) { 906 if (recoveryEntries.isEmpty()) { 907 return SemanticBuildResult.completed(program, diagnostics); 908 } 909 return SemanticBuildResult.recovered(program, diagnostics, 910 RecoveryMetadata.of(recoveryEntries)); 911 } 912 913 private SemanticBuildResult rejected() { 914 return SemanticBuildResult.rejected(diagnostics); 915 } 916 } 917 918 /** 919 * Build a SELECT under caller-supplied {@code options}. 920 * 921 * <p>Identical to {@link #build(TSelectSqlStatement, NameBindingProvider)} 922 * except for the knobs in {@code options}; pass 923 * {@link SemanticIRBuildOptions#defaults()} for the historical behaviour. 924 * With 925 * {@link SemanticIRBuildOptions#withDegradeUnsupportedNestedBlocks(boolean) 926 * degrade mode} on, an unsupported predicate-subquery body no longer 927 * aborts the call: it becomes an 928 * {@link StatementGraph#KIND_UNANALYZED} placeholder plus a 929 * {@link DiagnosticCode#NESTED_BLOCK_UNANALYZED} warning, and the host 930 * block's join graph is returned intact. 931 */ 932 public static SemanticBuildResult buildResult( 933 TSelectSqlStatement select, NameBindingProvider provider, 934 SemanticIRBuildOptions options) { 935 return executeBuild(options, () -> buildImpl(select, provider)); 936 } 937 938 /** Build a SELECT and atomically return its program and diagnostics. */ 939 public static SemanticBuildResult buildResult( 940 TSelectSqlStatement select, NameBindingProvider provider) { 941 return buildResult(select, provider, SemanticIRBuildOptions.defaults()); 942 } 943 944 /** 945 * Legacy compatibility API. Prefer {@link #buildResult}; this method 946 * publishes the same diagnostics through {@link #drainBuildDiagnostics()}. 947 */ 948 @Deprecated 949 public static SemanticProgram build(TSelectSqlStatement select, 950 NameBindingProvider provider, 951 SemanticIRBuildOptions options) { 952 clearBuildDiagnostics(); 953 return publishLegacyResult(buildResult(select, provider, options)); 954 } 955 956 @Deprecated 957 public static SemanticProgram build(TSelectSqlStatement select, 958 NameBindingProvider provider) { 959 clearBuildDiagnostics(); 960 return publishLegacyResult(buildResult(select, provider)); 961 } 962 963 private static SemanticProgram buildImpl(TSelectSqlStatement select, 964 NameBindingProvider provider) { 965 if (select == null) { 966 throw new IllegalArgumentException("select must not be null"); 967 } 968 if (provider == null) { 969 throw new IllegalArgumentException("provider must not be null"); 970 } 971 List<StatementGraph> stmts = new ArrayList<>(); 972 List<LineageEdge> lineage = new ArrayList<>(); 973 Map<String, Integer> cteNameToStatementIndex = new HashMap<>(); 974 Map<String, List<String>> ctePublishedColumns = new HashMap<>(); 975 int outerIndex = buildSelectProgramInto(select, provider, stmts, lineage, 976 cteNameToStatementIndex, ctePublishedColumns); 977 // Slice 170 (S9): attach query-block scope metadata (GAP 4). 978 attachQueryBlockScopes(stmts, outerIndex, cteNameToStatementIndex); 979 return new SemanticProgram(stmts, lineage); 980 } 981 982 /** 983 * Slice 170 (S9) — populate {@link QueryBlockScope} on each emitted 984 * SELECT-program block (GAP 4): a stable {@code statementIndex}, the 985 * {@link ScopeKind}, the CTE/derived name when present, and a 986 * {@code parentStatementIndex} pointing at the <em>lexical owner</em>. 987 * 988 * <p>The flat list order is preserved (mutates entries in place via the 989 * immutable {@code withJoinAnalysisFacts} copier). The outer statement 990 * ({@code outerIndex}) is the {@link ScopeKind#MAIN} block with a null 991 * parent. CTE bodies (known via {@code cteNameToStatementIndex}) and 992 * other bodies emitted before the outer take the outer as their 993 * top-level lexical owner. 994 * 995 * <p><strong>V1 scope (plan Q-CTE):</strong> {@code parentStatementIndex} 996 * is the top-level owning statement. Precise multi-level nested-body 997 * parent edges (e.g. a subquery inside a CTE body) are a documented 998 * follow-up; for the common single-level case (CTEs / FROM-subqueries 999 * directly under one SELECT) this is exact. 1000 */ 1001 private static void attachQueryBlockScopes(List<StatementGraph> stmts, 1002 int outerIndex, 1003 Map<String, Integer> cteNameToStatementIndex) { 1004 if (stmts.isEmpty()) return; 1005 java.util.Set<Integer> cteIndices = new java.util.HashSet<Integer>( 1006 cteNameToStatementIndex.values()); 1007 for (int i = 0; i < stmts.size(); i++) { 1008 StatementGraph sg = stmts.get(i); 1009 ScopeKind kind; 1010 Integer parent; 1011 if (i == outerIndex) { 1012 kind = ScopeKind.MAIN; 1013 parent = null; 1014 } else if (cteIndices.contains(Integer.valueOf(i))) { 1015 kind = ScopeKind.CTE; 1016 parent = Integer.valueOf(outerIndex); 1017 } else { 1018 // A FROM/scalar/predicate body emitted before the outer. 1019 kind = ScopeKind.DERIVED_TABLE; 1020 parent = Integer.valueOf(outerIndex); 1021 } 1022 // Slice 179 (R5): the block's own source span, set at the SELECT 1023 // construction site, follows the existing 1-based/end-exclusive 1024 // SourceSpan convention. Null when not threaded (e.g. DML). 1025 QueryBlockScope scope = new QueryBlockScope( 1026 i, parent, kind, sg.getName(), sg.getSourceSpan()); 1027 stmts.set(i, sg.withJoinAnalysisFacts( 1028 sg.getJoinAnalysisFacts().withQueryBlockScope(scope))); 1029 } 1030 } 1031 1032 /** 1033 * Slice 122 — build a SELECT program into a CALLER-OWNED 1034 * {@code stmts}/{@code lineage} pair, seeded with a caller-supplied 1035 * outer CTE context. {@link #build} delegates here with fresh empty 1036 * maps (behaviour-preserving extraction of its former inline body). 1037 * 1038 * <p>The MERGE USING-subquery branch in {@link #buildMerge} delegates 1039 * here with a COPY of the outer MERGE's {@code cteNameToStatementIndex} 1040 * (and {@code ctePublishedColumns}) so a USING body that references an 1041 * outer MERGE CTE 1042 * ({@code WITH cte AS (...) MERGE INTO t USING (SELECT ... FROM cte) s}) 1043 * resolves the reference as CTE-kind and emits the cross-statement 1044 * {@code STATEMENT_OUTPUT(usingIdx, col) -> STATEMENT_OUTPUT(cteIdx, col)} 1045 * edge into the already-built CTE body. Building directly into the 1046 * shared lists means the inner statements land at their final absolute 1047 * indices with NO rebase — so edges to the pre-seeded outer CTE 1048 * reference the correct index (the pre-slice-122 1049 * {@code build()+rebaseLineageEdge} path could not, because the rebase 1050 * offset would wrongly shift an outer-CTE index too). This closes the 1051 * slice-110 documented parity gap. 1052 * 1053 * <p>{@code hasOuterCteListAlreadyProcessed} is derived from THIS 1054 * select's own CTE list only — the seeded outer CTEs are not declared 1055 * by {@code select.getCteList()}, so they must not trigger the 1056 * nested-WITH reject in {@code buildSelectStatementImpl}. 1057 * 1058 * <p>Slice 108 Phase 0 — the two helper calls (CTE walker + 1059 * outer-SELECT body) were extracted from {@link #build} into 1060 * {@link #buildSelectCteList} / {@link #buildSelectBodyAfterCteWalk}; 1061 * calling them with {@code allowShadowOverride=false} / 1062 * {@code additionalAllCteNames=null} reproduces the prior inline walker. 1063 * 1064 * @return the index, into the supplied {@code stmts} list, of the 1065 * program's final (outer / merged-set-op) statement — i.e. the 1066 * statement a consumer should treat as this SELECT's result. 1067 */ 1068 private static int buildSelectProgramInto( 1069 TSelectSqlStatement select, 1070 NameBindingProvider provider, 1071 List<StatementGraph> stmts, 1072 List<LineageEdge> lineage, 1073 Map<String, Integer> cteNameToStatementIndex, 1074 Map<String, List<String>> ctePublishedColumns) { 1075 TCTEList cteList = select.getCteList(); 1076 boolean hasOuterCteList = cteList != null && cteList.size() > 0; 1077 buildSelectCteList(cteList, provider, stmts, lineage, 1078 cteNameToStatementIndex, ctePublishedColumns, 1079 /*allowShadowOverride=*/ false, 1080 /*additionalAllCteNames=*/ null); 1081 buildSelectBodyAfterCteWalk(select, provider, stmts, lineage, 1082 cteNameToStatementIndex, ctePublishedColumns, 1083 /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteList); 1084 return stmts.size() - 1; 1085 } 1086 1087 /** 1088 * Slice 108 — walk a SELECT-side WITH clause and append each CTE body 1089 * to {@code stmts} as a preceding statement. Extracted from the inline 1090 * walker that previously lived in {@link #build} (lines ~516–663 pre- 1091 * slice-108). Mirrors the slice-101 {@link #buildMergeCteList}, 1092 * slice-105 {@link #buildUpdateCteList}, and slice-106 1093 * {@link #buildDeleteCteList} helpers. 1094 * 1095 * <p>Phase 0 (behaviour-preserving refactor): {@code allowShadowOverride 1096 * = false} and {@code additionalAllCteNames = null} reproduce the 1097 * pre-slice-108 inline walker byte-for-byte. 1098 * 1099 * <p>Phase 1 (shadow admit): {@code allowShadowOverride = true} enables 1100 * the mixed outer+inner WITH on INSERT shadow case (slice 108). When 1101 * called from {@link #buildInsert}, the OUTER pass runs with 1102 * {@code allowShadowOverride=false}, populating 1103 * {@code cteNameToStatementIndex} and {@code ctePublishedColumns} with 1104 * outer CTE bindings. The INNER pass then runs with 1105 * {@code allowShadowOverride=true} and 1106 * {@code additionalAllCteNames=outerAllNames}. The inner pass: 1107 * <ul> 1108 * <li>uses a fresh local {@code localVisibleSoFar} for intra-list 1109 * duplicate detection (so {@code DUPLICATE_CTE_NAME} still 1110 * fires for inner {@code x, x} even when outer also declares 1111 * {@code x});</li> 1112 * <li>snapshots {@code cteNameToStatementIndex.keySet()} at entry 1113 * into {@code outerKeysSnapshot}; the union 1114 * {@code outerKeysSnapshot ∪ localVisibleSoFar} drives BOTH 1115 * {@link #rejectForwardCteReferences} AND 1116 * {@link NameBindingProvider#withCteContext} (round-2 codex 1117 * BLOCKER 3 fix — keeps inner-y references to outer-x from 1118 * being falsely flagged as forward references);</li> 1119 * <li>on collision with an outer entry (after a successful body 1120 * build), {@link Map#put} overrides the 1121 * {@code cteNameToStatementIndex} and {@code ctePublishedColumns} 1122 * entries so the source SELECT sees the INNER body. The OUTER 1123 * body stays in {@code stmts[]} at its earlier position; its 1124 * cteMap entry is just no longer referenced by name (PG nested- 1125 * WITH inner-shadows-outer semantics).</li> 1126 * </ul> 1127 * 1128 * <p>{@code additionalAllCteNames} is unioned into the per-call 1129 * {@code allCteNames} that {@link #rejectForwardCteReferences} consults 1130 * (round-1 codex BLOCKER 2 fix — keeps each scope's forward-ref check 1131 * narrow so an outer CTE body referencing a base-table whose name 1132 * happens to coincide with an inner CTE name does NOT falsely flag). 1133 */ 1134 private static void buildSelectCteList( 1135 TCTEList cteList, 1136 NameBindingProvider provider, 1137 List<StatementGraph> stmts, 1138 List<LineageEdge> lineage, 1139 Map<String, Integer> cteNameToStatementIndex, 1140 Map<String, List<String>> ctePublishedColumns, 1141 boolean allowShadowOverride, 1142 Set<String> additionalAllCteNames) { 1143 if (cteList == null || cteList.size() == 0) { 1144 return; 1145 } 1146 rejectRecursiveCtes(cteList); 1147 1148 // Per-call allCteNames for rejectForwardCteReferences. The optional 1149 // additionalAllCteNames extends this scope (Phase 1: outer names 1150 // visible to inner CTE body forward-ref checks). Phase 0 path passes 1151 // null, so this is just collectCteNames(cteList). 1152 Set<String> allCteNames; 1153 if (additionalAllCteNames != null && !additionalAllCteNames.isEmpty()) { 1154 allCteNames = new HashSet<>(collectCteNames(cteList)); 1155 allCteNames.addAll(additionalAllCteNames); 1156 } else { 1157 allCteNames = collectCteNames(cteList); 1158 } 1159 1160 // Phase 1: snapshot outer-scope CTE names at entry so subsequent 1161 // iterations of this list always see the FULL outer scope for 1162 // forward-ref classification and withCteContext, even if a shadow 1163 // override later overwrites a name's cteMap entry. 1164 Set<String> outerKeysSnapshot = allowShadowOverride 1165 ? new HashSet<>(cteNameToStatementIndex.keySet()) 1166 : null; 1167 1168 // Build each CTE body left-to-right. Each CTE sees CTEs declared 1169 // strictly before it (standard SQL chain semantics, slice 4). 1170 // Slice 18: CTE bodies accept FROM-subqueries (mirroring the 1171 // outer-SELECT extraction path) AND scalar-subquery projections 1172 // (slice 11): for each CTE body, FROM-subqueries are extracted 1173 // first, then scalar bodies, then the CTE body is built/appended. 1174 // The per-CTE-body subqueryAliasToIndex is local to the iteration 1175 // so different CTE bodies cannot collide on FROM-subquery aliases. 1176 // Slice 60: running map of "CTE name → published column names" 1177 // for star expansion. Each CTE's published columns are added 1178 // AFTER its body is built so a CTE cannot self-reference and 1179 // forward references (rejected earlier) cannot leak through. 1180 // Set-op CTE bodies use the merged StatementGraph.outputColumns. 1181 // For non-set-op CTE bodies the column names also come from 1182 // StatementGraph.outputColumns. Explicit CTE column lists are 1183 // rejected at the star expander, not at populate time. 1184 Set<String> localVisibleSoFar = new HashSet<>(); 1185 for (int i = 0; i < cteList.size(); i++) { 1186 TCTE cte = cteList.getCTE(i); 1187 String cteName = cte.getTableName().toString(); 1188 String cteNameLower = cteName.toLowerCase(Locale.ROOT); 1189 // Slice 15 MUST 9 / round-4 MUST 1: reject duplicate CTE 1190 // names BEFORE rejectForwardCteReferences so duplicate-name 1191 // diagnostics are not preempted by forward-reference 1192 // diagnostics. cteNameToStatementIndex is keyed lower-case; 1193 // a duplicate entry would silently overwrite the earlier 1194 // body and leave OUTER_REFERENCE-of-CTE pointing at the 1195 // wrong statement. 1196 // 1197 // Slice 108: intra-list duplicate check uses localVisibleSoFar 1198 // (NOT outerKeysSnapshot) so an inner CTE shadowing an outer 1199 // CTE is admitted while inner-x, inner-x stays rejected (round-1 1200 // codex BLOCKER 1 fix). 1201 if (localVisibleSoFar.contains(cteNameLower)) { 1202 throw new SemanticIRBuildException( 1203 Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME, 1204 "duplicate CTE name '" + cteName 1205 + "' in WITH clause; CTE names must be unique", cte)); 1206 } 1207 // Slice 108: effectiveVisible = outerKeysSnapshot ∪ localVisibleSoFar. 1208 // Drives BOTH rejectForwardCteReferences AND 1209 // bodyProvider.withCteContext so inner-y body referencing outer-x 1210 // is admitted (round-2 codex BLOCKER 3 fix). 1211 Set<String> effectiveVisible; 1212 if (outerKeysSnapshot != null) { 1213 if (outerKeysSnapshot.isEmpty()) { 1214 effectiveVisible = localVisibleSoFar; 1215 } else if (localVisibleSoFar.isEmpty()) { 1216 effectiveVisible = outerKeysSnapshot; 1217 } else { 1218 effectiveVisible = new HashSet<>(outerKeysSnapshot); 1219 effectiveVisible.addAll(localVisibleSoFar); 1220 } 1221 } else { 1222 effectiveVisible = localVisibleSoFar; 1223 } 1224 rejectForwardCteReferences(cte, allCteNames, effectiveVisible); 1225 // Slice 60: bodyProvider gets the CTE-context narrowing 1226 // first; the effective-alias-keyed in-scope map is 1227 // applied LATER, after the body's own FROM-subqueries 1228 // are extracted (so we can walk the body's FROM clause 1229 // and resolve each relation to its effective alias). 1230 // This deferred narrowing replaces the slice-60 v1 path 1231 // that put the running ctePublishedColumns map (CTE-name 1232 // keyed) directly on the provider — that keying class 1233 // could collide when a subquery alias matched a CTE 1234 // name (codex diff-review). 1235 NameBindingProvider bodyProvider = provider.withCteContext(effectiveVisible); 1236 TSelectSqlStatement cteBody = cte.getSubquery(); 1237 // Slice 103 — snapshot lineage size BEFORE the body branch so 1238 // the slice-102 rename helper can rewrite outgoing 1239 // STATEMENT_OUTPUT refs in [lineageSize0, lineage.size()) 1240 // without touching prior CTE bodies' edges. Covers BOTH the 1241 // set-op and non-set-op branches (mirrors slice-102 1242 // buildMergeCteList at line ~5820). 1243 int lineageSize0 = lineage.size(); 1244 int bodyIdx; 1245 if (cteBody != null 1246 && cteBody.getSetOperatorType() != null 1247 && cteBody.getSetOperatorType() != ESetOperatorType.none) { 1248 // Slice 12: set-op CTE body. The outer set-op statement 1249 // carries the CTE name so BodyIndexes.cteByConsumerAndName 1250 // resolves it (slice-18 consumer-keyed projector lookup). 1251 // The CTE body's CTE list (if any) is rejected as a 1252 // nested-WITH inside buildSetOpProgram. 1253 bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, lineage, 1254 cteNameToStatementIndex, cteName, 1255 /*hasOuterCteListAlreadyProcessed=*/ false); 1256 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 1257 } else { 1258 // Slice 18: snapshot/rollback around recursive 1259 // FROM-subquery extraction inside this CTE body. 1260 // Mirrors the outer-SELECT wrapper below and the 1261 // slice-16 set-op wrapper. Currently defensive: a 1262 // thrown exception in a deeper level would otherwise 1263 // leak siblings/ancestors at this CTE's level into 1264 // stmts/lineage. The wrapper truncates back to the 1265 // pre-extraction boundary and rethrows. Per-CTE 1266 // granularity: earlier CTE bodies in the same WITH 1267 // list are NOT rolled back (they're already complete). 1268 int cteStmtsSize0 = stmts.size(); 1269 int cteLineageSize0 = lineage.size(); 1270 Map<String, Integer> cteSubqueryAliasToIndex; 1271 try { 1272 // Slice 60: pass the running ctePublishedColumns 1273 // so the body's own FROM-subqueries see earlier 1274 // CTEs at every recursion level. 1275 cteSubqueryAliasToIndex = 1276 extractFromSubqueriesAsStatements(cteBody, bodyProvider, 1277 stmts, lineage, cteNameToStatementIndex, 1278 ctePublishedColumns); 1279 } catch (RuntimeException ex) { 1280 while (stmts.size() > cteStmtsSize0) stmts.remove(stmts.size() - 1); 1281 while (lineage.size() > cteLineageSize0) lineage.remove(lineage.size() - 1); 1282 throw ex; 1283 } 1284 EnclosingScope cteEnclosing = buildEnclosingScope(cteBody, 1285 cteNameToStatementIndex, cteSubqueryAliasToIndex, 1286 /*parent=*/ null); 1287 Map<Integer, ScalarInfo> cteScalarMap = 1288 extractScalarSubqueriesAsStatements(cteBody, 1289 bodyProvider, stmts, lineage, 1290 cteNameToStatementIndex, cteEnclosing, 1291 /*allowRecursiveScalarSubqueryExtraction=*/ true); 1292 // Slice 60 (codex diff-review): build the per-CTE 1293 // effective-alias-keyed in-scope map by walking the 1294 // CTE body's FROM list. CTE references and 1295 // FROM-subquery aliases live in the same FROM 1296 // namespace (preflight rejects duplicates), so 1297 // effective-alias keying makes a name collision 1298 // physically impossible. 1299 Map<String, List<String>> cteBodyInScope = 1300 buildEffectiveAliasInScopeMap(cteBody, bodyProvider, 1301 ctePublishedColumns, cteSubqueryAliasToIndex, 1302 stmts); 1303 NameBindingProvider cteBodyProviderWithStar = bodyProvider 1304 .withInScopeRelationColumns(cteBodyInScope); 1305 // Slice 114 — switch from the 7-arg buildSelectStatement 1306 // to the 14-arg buildSelectStatementImpl so the CTE 1307 // body's WHERE clause can extract uncorrelated predicate 1308 // subqueries (IN-SELECT / EXISTS / NOT EXISTS / scalar 1309 // comparison / ANY-ALL-SOME) as their own statements. 1310 // The wrapper mirrors the outer-SELECT entry pattern in 1311 // {@link #build}: if the build appends predicate bodies 1312 // and then a later post-extraction reject fires, the 1313 // try/catch truncates stmts/lineage back to the 1314 // pre-call boundary so a partial extraction doesn't 1315 // leak into the program. The slice-113 set-op branch 1316 // call site is itself enclosed by the slice-16 1317 // SET-OP-WIDE rollback at {@link #buildSetOpProgram}; 1318 // the CTE-body call sites do NOT inherit a similar 1319 // enclosing wrapper, which is why slice 114 adds one 1320 // here. The from-subquery / scalar-subquery 1321 // extractions above this point have their own 1322 // slice-17/18 wrappers, so the pre-CALL snapshot 1323 // bounds the truncate exactly to whatever 1324 // buildSelectStatementImpl appended. 1325 int cteBodyStmtsSnapshot = stmts.size(); 1326 int cteBodyLineageSnapshot = lineage.size(); 1327 StatementGraph body; 1328 try { 1329 if (isPivotSelect(cteBody)) { 1330 // Slice 139 — a nested PIVOT/UNPIVOT as a CTE body. 1331 // The slice-129 pivot router in 1332 // buildSelectStatementImpl is gated to the OUTER 1333 // SELECT (name == null && !isPredicateBody), so a 1334 // pivot built here (name == cteName) would otherwise 1335 // fall to the normal path and reject with the 1336 // misleading TABLE_BINDING_UNRESOLVED 1337 // ("null(piviot_table)"). Route it to buildPivotSelect 1338 // with the CTE name as the statement name — mirroring 1339 // the slice-138 processDirectSubqueryTable branch for a 1340 // FROM-subquery body. The CTE becomes a proper pivot 1341 // StatementGraph; the outer query references the CTE 1342 // name (CTE-kind) so the standard cross-stmt CTE path 1343 // emits STATEMENT_OUTPUT(outer) → STATEMENT_OUTPUT(cte), 1344 // and the emitLineageForStatement(body, ...) call below 1345 // wires the pivot's own output → source edges 1346 // (base-table TABLE_COLUMN, or — when the pivot's source 1347 // is itself a subquery, slice 136 — STATEMENT_OUTPUT via 1348 // cteSubqueryAliasToIndex). All pivot deferrals are 1349 // preserved by buildPivotSelect's own guards 1350 // (PIVOT_WITH_QUERY_CLAUSE / MULTIPLE_CLAUSES / 1351 // UNPIVOT / STAR_CATALOG_REQUIRED). 1352 body = buildPivotSelect(cteBody, 1353 cteBodyProviderWithStar, cteName); 1354 } else { 1355 body = buildSelectStatementImpl(cteBody, 1356 cteBodyProviderWithStar, cteName, 1357 /*hasOuterCteListAlreadyProcessed=*/ false, 1358 /*allowFromSubqueries=*/ true, 1359 /*allowScalarProjectionSubqueries=*/ true, 1360 /*allowWindowProjection=*/ true, 1361 // Slice 114 — keep JOIN-ON predicate 1362 // subqueries rejected inside CTE bodies 1363 // (preserve slice 23/26 contract; the lift 1364 // is WHERE-only; the two flags are 1365 // independent per slice 113 split). 1366 /*allowJoinOnPredicateSubqueries=*/ false, 1367 /*stmtsForExtraction=*/ stmts, 1368 /*lineageForExtraction=*/ lineage, 1369 /*cteMapForExtraction=*/ cteNameToStatementIndex, 1370 /*isPredicateBody=*/ false, 1371 /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE, 1372 /*allowWherePredicateSubqueries=*/ true); 1373 } 1374 } catch (RuntimeException ex) { 1375 while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 1376 while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 1377 throw ex; 1378 } 1379 bodyIdx = stmts.size(); 1380 stmts.add(body); 1381 // Slice 108 — emit lineage BEFORE the cteMap.put so that 1382 // in the shadow case (allowShadowOverride=true with 1383 // cteNameLower already in cteMap from outer pass), the 1384 // body's column refs to <cteNameLower> still resolve to 1385 // the OUTER body (PG inner-x body sees outer-x via the 1386 // closer-enclosing-not-yet-shadowed fallback). Non-shadow 1387 // cases are unaffected because cteMap does not yet contain 1388 // cteNameLower at this point and the body cannot reference 1389 // its own name without going through the recursive-CTE 1390 // path (already rejected upstream). 1391 emitLineageForStatement(body, bodyIdx, lineage, 1392 cteNameToStatementIndex, cteSubqueryAliasToIndex, 1393 cteScalarMap); 1394 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 1395 } 1396 // Slice 103 — apply the slice-102 rename helper if the CTE 1397 // declares an explicit column list (no-op otherwise). The 1398 // helper returns the published column list (renamed if 1399 // explicit, else body's inner names). Slice-60's 1400 // `ctePublishedColumns.put` is collapsed into this single 1401 // call site (covers both branches above). 1402 List<String> publishedCols = applyExplicitCteColumnListRename( 1403 cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT"); 1404 ctePublishedColumns.put(cteNameLower, publishedCols); 1405 localVisibleSoFar.add(cteNameLower); 1406 } 1407 } 1408 1409 /** 1410 * Slice 108 — outer-SELECT processing extracted from the previous inline 1411 * body of {@link #build} (lines ~665–763 pre-slice-108). 1412 * 1413 * <p>{@code hasOuterCteListAlreadyProcessed} is an EXPLICIT boolean 1414 * parameter (round-2 codex BLOCKER 4 fix). Previously this was inferred 1415 * from {@code select.getCteList() != null && size > 0}; after the 1416 * slice-108 buildInsert shadow path nulls {@code source.getCteList()} 1417 * before calling, that inference would be wrong. The caller passes the 1418 * truth. 1419 * 1420 * <p>The post-walk {@code cteNameToStatementIndex.keySet()} replaces the 1421 * pre-walk {@code allCteNames} because the walker has populated every 1422 * declared CTE name by lowercase key — they are equal sets. 1423 */ 1424 private static void buildSelectBodyAfterCteWalk( 1425 TSelectSqlStatement select, 1426 NameBindingProvider provider, 1427 List<StatementGraph> stmts, 1428 List<LineageEdge> lineage, 1429 Map<String, Integer> cteNameToStatementIndex, 1430 Map<String, List<String>> ctePublishedColumns, 1431 boolean hasOuterCteListAlreadyProcessed) { 1432 Set<String> allCteNames = cteNameToStatementIndex.isEmpty() 1433 ? Collections.<String>emptySet() 1434 : new HashSet<>(cteNameToStatementIndex.keySet()); 1435 1436 // Slice 12: top-level set-op dispatch. CTE list (if any) was 1437 // already processed above; pass hasOuterCteListAlreadyProcessed=true 1438 // so buildSetOpProgram doesn't re-flag it as a nested WITH. 1439 if (select.getSetOperatorType() != null 1440 && select.getSetOperatorType() != ESetOperatorType.none) { 1441 NameBindingProvider outerProvider = provider.withCteContext(allCteNames); 1442 buildSetOpProgram(select, outerProvider, stmts, lineage, 1443 cteNameToStatementIndex, /*setOpName=*/ null, 1444 /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteListAlreadyProcessed); 1445 return; 1446 } 1447 1448 // Outer statement: pre-extract any FROM-clause subqueries as their 1449 // own statements, then any scalar-subquery projections, then build 1450 // the outer body, then emit lineage with the global CTE map, the 1451 // outer-local subquery alias map, AND the scalar-projection map. 1452 // Slice 60: outerProvider gets the CTE-context narrowing here; 1453 // the effective-alias-keyed in-scope map is applied LATER, after 1454 // outer FROM-subqueries are extracted. The same deferred 1455 // narrowing pattern as the CTE-body branch — see the codex 1456 // diff-review note on alias/CTE-name collision. 1457 NameBindingProvider outerProvider = provider.withCteContext(allCteNames); 1458 // Slice 17: snapshot/rollback around recursive FROM-subquery 1459 // extraction. The recursive extractor mutates stmts/lineage as 1460 // each level's bodies land; if a deeper-level rejection fires 1461 // after sibling/ancestor mutations, this wrapper truncates the 1462 // lists back to the pre-call boundary and rethrows. Mirrors the 1463 // slice-16 buildSetOpProgram wrapper (§14.18 process lesson #21: 1464 // when a class of mutation-free checks can fire after partial 1465 // mutation, close it transactionally instead of point-fixing). 1466 // 1467 // The rollback is currently defensive: build() allocates fresh 1468 // stmts/lineage per invocation, so a thrown exception's caller 1469 // cannot directly observe leaked state. The wrapper is kept 1470 // because (a) the slice-17 preflight closes the most direct 1471 // partial-mutation classes BEFORE the recursive extraction 1472 // runs, but recursive levels can still fail at deeper rejection 1473 // points (e.g. a nested set-op-in-FROM-subquery body inside a 1474 // sibling that succeeds at the preflight); (b) consistency with 1475 // slice 16's pattern means a future refactor that lifts the 1476 // build() per-call list allocation does not silently re-open 1477 // the partial-mutation class. 1478 int stmtsSize0 = stmts.size(); 1479 int lineageSize0 = lineage.size(); 1480 Map<String, Integer> outerSubqueryAliasToIndex; 1481 try { 1482 outerSubqueryAliasToIndex = 1483 extractFromSubqueriesAsStatements(select, outerProvider, 1484 stmts, lineage, cteNameToStatementIndex, 1485 ctePublishedColumns); 1486 } catch (RuntimeException ex) { 1487 while (stmts.size() > stmtsSize0) stmts.remove(stmts.size() - 1); 1488 while (lineage.size() > lineageSize0) lineage.remove(lineage.size() - 1); 1489 throw ex; 1490 } 1491 EnclosingScope outerEnclosing = buildEnclosingScope(select, 1492 cteNameToStatementIndex, outerSubqueryAliasToIndex, 1493 /*parent=*/ null); 1494 Map<Integer, ScalarInfo> outerScalarMap = 1495 extractScalarSubqueriesAsStatements(select, outerProvider, 1496 stmts, lineage, cteNameToStatementIndex, outerEnclosing, 1497 /*allowRecursiveScalarSubqueryExtraction=*/ true); 1498 // Slice 60 (codex diff-review): build the outer's 1499 // effective-alias-keyed in-scope map by walking the outer 1500 // SELECT's FROM list. Effective-alias keying eliminates the 1501 // CTE-name vs subquery-alias collision class. 1502 Map<String, List<String>> outerInScope = buildEffectiveAliasInScopeMap( 1503 select, outerProvider, ctePublishedColumns, 1504 outerSubqueryAliasToIndex, stmts); 1505 NameBindingProvider outerProviderWithStar = outerProvider 1506 .withInScopeRelationColumns(outerInScope); 1507 // Slice 23: outer-SELECT path uses buildSelectStatementImpl directly so 1508 // the slice-23 EXISTS-extraction can append predicate-body statements 1509 // to `stmts`/`lineage`. Snapshot/rollback wrapper around the call 1510 // matches the slice-16/17/20 pattern: a partial extraction (e.g. third 1511 // EXISTS rejected after first two extracted) truncates the lists. 1512 int outerStmtsSnapshot = stmts.size(); 1513 int outerLineageSnapshot = lineage.size(); 1514 StatementGraph outer; 1515 try { 1516 outer = buildSelectStatementImpl(select, outerProviderWithStar, null, 1517 /*hasOuterCteListAlreadyProcessed=*/ hasOuterCteListAlreadyProcessed, 1518 /*allowFromSubqueries=*/ true, 1519 /*allowScalarProjectionSubqueries=*/ true, 1520 /*allowWindowProjection=*/ true, 1521 /*allowJoinOnPredicateSubqueries=*/ true, 1522 stmts, lineage, 1523 /*cteMapForExtraction=*/ cteNameToStatementIndex, 1524 /*isPredicateBody=*/ false, 1525 /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE, 1526 /*allowWherePredicateSubqueries=*/ true); 1527 } catch (RuntimeException e) { 1528 while (stmts.size() > outerStmtsSnapshot) stmts.remove(stmts.size() - 1); 1529 while (lineage.size() > outerLineageSnapshot) lineage.remove(lineage.size() - 1); 1530 throw e; 1531 } 1532 int outerIndex = stmts.size(); 1533 stmts.add(outer); 1534 emitLineageForStatement(outer, outerIndex, lineage, 1535 cteNameToStatementIndex, outerSubqueryAliasToIndex, outerScalarMap); 1536 } 1537 1538 /** 1539 * Slice 78 — admit a single {@code INSERT INTO target SELECT ...} 1540 * statement. Builds the source SELECT via {@link #build} (reusing 1541 * the existing pipeline unchanged), then appends an {@code "INSERT"}- 1542 * kind {@link StatementGraph} carrying the target relation and 1543 * cross-statement lineage edges. 1544 * 1545 * <p>Admitted shape: {@code INSERT INTO <target> [(c1, c2, ...)] 1546 * <subquery-SELECT>}. Rejections: 1547 * <ul> 1548 * <li>{@link EInsertSource#values}, {@code values_empty}, 1549 * {@code default_values}, {@code execute}, 1550 * {@code values_function}, {@code values_multi_table}, 1551 * {@code hive_query}, {@code values_oracle_record}, 1552 * {@code set_column_value}, {@code value_table} → 1553 * {@link DiagnosticCode#INSERT_SOURCE_NOT_SUPPORTED}.</li> 1554 * <li>Oracle {@code INSERT ALL} / {@code INSERT FIRST} → 1555 * {@link DiagnosticCode#INSERT_MULTI_TABLE_NOT_SUPPORTED}. 1556 * Hive multi-insert ({@code multiInsertStatements} non-empty) is 1557 * routed to {@link #buildHiveMultiInsert} instead of rejected.</li> 1558 * <li>Missing target table (defensive — the parser usually rejects 1559 * first) → {@link DiagnosticCode#INSERT_TARGET_MISSING}.</li> 1560 * <li>Explicit column list arity ≠ source SELECT output count → 1561 * {@link DiagnosticCode#INSERT_COLUMN_COUNT_MISMATCH}.</li> 1562 * </ul> 1563 * 1564 * <p>The source SELECT is built first via {@code build()} and its 1565 * full {@link SemanticProgram} (CTE bodies + scalar bodies + 1566 * FROM-subquery bodies + outer SELECT + cross-stmt lineage) is 1567 * appended verbatim to the returned program. The INSERT 1568 * {@link StatementGraph} is appended LAST; its 1569 * {@link StatementGraph#getRelations() relations} lists the source 1570 * SELECT as a single {@link RelationKind#SUBQUERY} entry whose 1571 * {@code qualifiedName} is the source SELECT's outer-statement name 1572 * (synthesised when needed). All other column-ref lists stay empty 1573 * on the INSERT — an INSERT has no projection of its own. 1574 * 1575 * <p>Cross-statement {@link LineageEdge}s for the INSERT are 1576 * {@code from = TABLE_COLUMN(target_qname, target_col_i_name)} 1577 * and {@code to = STATEMENT_OUTPUT(selectIdx, source_output_i_name)}. 1578 * Target column names are the explicit INSERT column-list spellings 1579 * when supplied, else the source SELECT's positional output names. 1580 */ 1581 /** 1582 * Build under caller-supplied {@code options}; see 1583 * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}. 1584 */ 1585 public static SemanticBuildResult buildInsertResult( 1586 TInsertSqlStatement insert, NameBindingProvider provider, 1587 SemanticIRBuildOptions options) { 1588 return executeBuild(options, () -> buildInsertImpl(insert, provider)); 1589 } 1590 1591 /** Build an INSERT and atomically return its program and diagnostics. */ 1592 public static SemanticBuildResult buildInsertResult( 1593 TInsertSqlStatement insert, NameBindingProvider provider) { 1594 return buildInsertResult(insert, provider, 1595 SemanticIRBuildOptions.defaults()); 1596 } 1597 1598 @Deprecated 1599 public static SemanticProgram buildInsert(TInsertSqlStatement insert, 1600 NameBindingProvider provider, 1601 SemanticIRBuildOptions options) { 1602 clearBuildDiagnostics(); 1603 return publishLegacyResult(buildInsertResult(insert, provider, options)); 1604 } 1605 1606 @Deprecated 1607 public static SemanticProgram buildInsert(TInsertSqlStatement insert, 1608 NameBindingProvider provider) { 1609 clearBuildDiagnostics(); 1610 return publishLegacyResult(buildInsertResult(insert, provider)); 1611 } 1612 1613 private static SemanticProgram buildInsertImpl(TInsertSqlStatement insert, 1614 NameBindingProvider provider) { 1615 if (insert == null) { 1616 throw new IllegalArgumentException("insert must not be null"); 1617 } 1618 if (provider == null) { 1619 throw new IllegalArgumentException("provider must not be null"); 1620 } 1621 1622 // Oracle INSERT ALL / FIRST rejects: their multi-value AST shape 1623 // is fundamentally different from the Hive multi-insert path. 1624 // Slice 78 scopes single-target INSERT SELECT; slice 93 lifts 1625 // the Hive multi-insert case via buildHiveMultiInsert. 1626 if (insert.isInsertAll() || insert.isInsertFirst()) { 1627 throw new SemanticIRBuildException(Diagnostic.error( 1628 DiagnosticCode.INSERT_MULTI_TABLE_NOT_SUPPORTED, 1629 "multi-table INSERT (INSERT ALL / INSERT FIRST) is not " 1630 + "supported by SemanticIRBuilder.buildInsert; " 1631 + "slice 78 admits single-target INSERT INTO <target> SELECT ...", 1632 insert)); 1633 } 1634 // Slice 109 — outer-WITH on Hive multi-insert 1635 // (`WITH x AS (...) FROM x INSERT INTO t1 SELECT ... INSERT INTO t2 1636 // SELECT ...`) is now admitted via buildHiveMultiInsert's CTE-aware 1637 // path. The slice-104 early reject for this shape is removed; the 1638 // helper builds the outer CTE bodies ONCE upfront and reuses the 1639 // shared cteMap/publishedMap across every sub-SELECT. 1640 // INSERT_OUTER_WITH_ON_HIVE_MULTI_INSERT_NOT_SUPPORTED stays declared- 1641 // but-unreached for API stability (slice 71/72/82/86/95/96/97/98/108 1642 // retain-for-documentation precedent). 1643 // 1644 // Hive multi-insert: FROM src INSERT INTO t1 SELECT ... INSERT INTO t2 SELECT ... 1645 // Each sub-SELECT already carries the shared FROM source in its fromClause. 1646 if (!insert.getMultiInsertStatements().isEmpty()) { 1647 return buildHiveMultiInsert(insert, provider); 1648 } 1649 1650 EInsertSource src = insert.getInsertSource(); 1651 if (src != EInsertSource.subquery) { 1652 throw new SemanticIRBuildException(Diagnostic.error( 1653 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 1654 "INSERT source '" + src + "' is not supported by " 1655 + "SemanticIRBuilder.buildInsert; slice 78 admits " 1656 + "subquery-source INSERT only (INSERT INTO <target> SELECT ...)", 1657 insert)); 1658 } 1659 1660 // Slice 85 — cheap statement-level OUTPUT_INTO reject runs 1661 // BEFORE the source SELECT is built so a multi-violation 1662 // shape (e.g. `INSERT INTO t OUTPUT INSERTED.x INTO #log 1663 // SELECT ... FROM bad_join`) routes to the cheaper structural 1664 // code first. 1665 if (insert.getOutputClause() != null 1666 && insert.getOutputClause().getIntoTable() != null) { 1667 throw new SemanticIRBuildException(Diagnostic.error( 1668 DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED, 1669 "INSERT OUTPUT ... INTO <target> writes a second target; " 1670 + "slice 85 admits projection-only OUTPUT", 1671 insert)); 1672 } 1673 1674 TTable targetTable = insert.getTargetTable(); 1675 if (targetTable == null || targetTable.getTableName() == null) { 1676 throw new SemanticIRBuildException(Diagnostic.error( 1677 DiagnosticCode.INSERT_TARGET_MISSING, 1678 "INSERT statement has no resolvable target table", 1679 insert)); 1680 } 1681 String targetQName = targetTable.getTableName().toString(); 1682 if (targetQName.isEmpty()) { 1683 throw new SemanticIRBuildException(Diagnostic.error( 1684 DiagnosticCode.INSERT_TARGET_MISSING, 1685 "INSERT target table name is empty", 1686 insert)); 1687 } 1688 1689 TSelectSqlStatement source = insert.getSubQuery(); 1690 if (source == null) { 1691 // Defensive: getInsertSource() == subquery but subQuery is 1692 // null. Surface as INSERT_TARGET_MISSING's source half. 1693 throw new SemanticIRBuildException(Diagnostic.error( 1694 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 1695 "INSERT source is declared as subquery but no SELECT " 1696 + "statement was attached", 1697 insert)); 1698 } 1699 1700 // Slice 104 — outer-WITH on INSERT. The parser attaches the outer 1701 // WITH clause to insert.getCteList(), NOT to source.getCteList(). 1702 // Before slice 104 buildInsert ignored insert.getCteList(), which 1703 // silently mis-bound CTE references in the source SELECT as 1704 // TABLE-kind relations with phantom columns. The slice-104 fix is 1705 // an AST handoff: move insert.getCteList() onto source.getCteList() 1706 // for the duration of the inner build(source) call so the 1707 // slice-103 SELECT-side CTE walker handles construction, rename, 1708 // and rejects (recursive / duplicate / forward-reference / arity 1709 // mismatch). Restore in finally so the AST is observably 1710 // unchanged to the caller (Java field references — token-chain 1711 // state is perturbed by setCteList(null)'s removeTokens() but 1712 // observably benign for downstream Semantic IR). 1713 // 1714 // Slice 107 / 108 — mixed outer-WITH + inner-WITH on INSERT. PG / 1715 // Oracle / Snowflake admit at parse. Three sub-cases: 1716 // (a) Only outer WITH populated. AST handoff (slice 104): move 1717 // insert.cteList onto source.cteList and call build(source). 1718 // (b) Only inner WITH populated. Pass through unchanged (the 1719 // walker handles it on its own). 1720 // (c) Both outer and inner WITH populated. Slice 107 admitted 1721 // this for disjoint names via a flat-merge; slice 108 admits 1722 // it for the SHADOWING case too (`WITH x ... INSERT ... WITH 1723 // x ... SELECT ... FROM x` — inner shadows outer per 1724 // PG/Oracle/Snowflake nested-WITH semantics). The slice-108 1725 // implementation uses a TWO-PASS walker invocation in this 1726 // method: outer pass first (allowShadowOverride=false), then 1727 // inner pass (allowShadowOverride=true, 1728 // additionalAllCteNames=outer-names). The walker's two-set 1729 // visibility model (outerKeysSnapshot ∪ localVisibleSoFar) 1730 // keeps PG semantics correct: inner-x's body sees outer-x via 1731 // the cteMap (override is post-build), and inner CTEs declared 1732 // after inner-x see inner-x. The OUTER body stays in stmts[] 1733 // at its position; its cteMap entry is just no longer 1734 // referenced by name (shadowed). Source SELECT's `FROM x` 1735 // resolves to inner-x. 1736 // 1737 // INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED stays declared but 1738 // is no longer reached by slice 108. Slice107Test §F/§Q (cross- 1739 // boundary duplicate rejects) are deleted; positive coverage moves 1740 // to Slice108Test. 1741 TCTEList outerCtes = insert.getCteList(); 1742 TCTEList savedSourceCtes = source.getCteList(); 1743 boolean handoffApplied = false; 1744 SemanticProgram inner; 1745 boolean haveOuterCtes = outerCtes != null && outerCtes.size() > 0; 1746 boolean haveInnerCtes = savedSourceCtes != null && savedSourceCtes.size() > 0; 1747 if (haveOuterCtes && haveInnerCtes) { 1748 // Slice 108 — two-pass walker. Null both AST CTE lists before 1749 // calling buildSelectBodyAfterCteWalk so the helper does not 1750 // re-process source.getCteList(). hasOuterCteListAlreadyProcessed 1751 // is passed true (round-2 codex BLOCKER 4 fix). 1752 source.setCteList(null); 1753 insert.setCteList(null); 1754 handoffApplied = true; 1755 try { 1756 List<StatementGraph> innerStmts = new ArrayList<>(); 1757 List<LineageEdge> innerLineage = new ArrayList<>(); 1758 Map<String, Integer> cteMap = new HashMap<>(); 1759 Map<String, List<String>> publishedMap = new HashMap<>(); 1760 // Outer pass: outerAllNames as its own scope. 1761 buildSelectCteList(outerCtes, provider, innerStmts, innerLineage, 1762 cteMap, publishedMap, 1763 /*allowShadowOverride=*/ false, 1764 /*additionalAllCteNames=*/ null); 1765 // Inner pass: outerAllNames also visible for forward-ref 1766 // classification (round-1 codex BLOCKER 2 fix); shadow 1767 // override admits cross-boundary duplicate names. 1768 Set<String> outerAllNames = collectCteNames(outerCtes); 1769 buildSelectCteList(savedSourceCtes, provider, innerStmts, innerLineage, 1770 cteMap, publishedMap, 1771 /*allowShadowOverride=*/ true, 1772 /*additionalAllCteNames=*/ outerAllNames); 1773 // Source SELECT body sees the post-pass cteMap (inner wins 1774 // for shadowed names). 1775 buildSelectBodyAfterCteWalk(source, provider, innerStmts, innerLineage, 1776 cteMap, publishedMap, 1777 /*hasOuterCteListAlreadyProcessed=*/ true); 1778 inner = new SemanticProgram(innerStmts, innerLineage); 1779 } finally { 1780 source.setCteList(savedSourceCtes); 1781 insert.setCteList(outerCtes); 1782 } 1783 } else { 1784 // Single-sided cases. Slice 104 AST handoff for outer-only; 1785 // pass-through for inner-only or no CTEs. 1786 if (haveOuterCtes) { 1787 source.setCteList(outerCtes); 1788 insert.setCteList(null); 1789 handoffApplied = true; 1790 } 1791 try { 1792 // buildImpl, NOT the public two-argument build: this is an 1793 // INTERNAL recursion into the DML's own source SELECT and must 1794 // inherit the ambient options, or the three-argument 1795 // buildInsert/buildCreateTable/buildCreateView degrade 1796 // overloads would silently still throw. The public two-argument 1797 // overload deliberately pins defaults and is for EXTERNAL 1798 // callers only. 1799 inner = buildImpl(source, provider); 1800 } finally { 1801 if (handoffApplied) { 1802 source.setCteList(savedSourceCtes); 1803 insert.setCteList(outerCtes); 1804 } 1805 } 1806 } 1807 1808 // Slice 93 — delegate INSERT-graph assembly to the shared helper 1809 // used by both single-target (slice 78) and Hive multi-insert 1810 // (slice 93). out is freshly empty so the helper's rebase offset 1811 // is 0 (no-op for inner lineage). RETURNING/OUTPUT clauses are 1812 // passed directly (slice 85 still owns the projection build). 1813 List<StatementGraph> out = new ArrayList<>(inner.getStatements().size() + 1); 1814 List<LineageEdge> outLineage = new ArrayList<>(); 1815 assembleInsertGraphAndLineage( 1816 insert, targetTable, targetQName, inner, 1817 "INSERT", 1818 insert.getReturningClause(), 1819 insert.getOutputClause(), 1820 out, outLineage, provider); 1821 return new SemanticProgram(out, outLineage); 1822 } 1823 1824 /** 1825 * Slice 93 — admit a Hive multi-insert block of the form 1826 * {@code FROM src INSERT INTO t1 SELECT col1 INSERT INTO t2 SELECT col2}. 1827 * 1828 * <p>The parser represents the whole block as one {@link TInsertSqlStatement} 1829 * whose first INSERT-SELECT pair is the primary statement and whose 1830 * additional pairs are in {@link TInsertSqlStatement#getMultiInsertStatements()}. 1831 * Crucially, each sub-SELECT already carries the shared FROM source in its own 1832 * {@code fromClause} / {@code fromSourceTable} — no post-processing is needed. 1833 * 1834 * <p>Produces a flat {@link SemanticProgram} containing per-pair blocks of 1835 * statements concatenated in INSERT order: each block contributes its source 1836 * SELECT's inner statements (CTE bodies / FROM-subquery bodies extracted by 1837 * {@link #build}) followed by its outer SELECT followed by an INSERT graph. 1838 * The minimum is {@code 2N} statements (one SELECT + one INSERT per target); 1839 * sub-SELECTs with extracted inner programs produce more. Each INSERT carries 1840 * cross-statement lineage edges pointing at its preceding SELECT via 1841 * {@link LineageRef#statementOutput}; per-pair inner lineage edges are 1842 * rebased by the current {@code out.size()} so absolute statement indices 1843 * remain valid across the concatenated program. 1844 * 1845 * <p>Safety note on the source-table fallback: this method enables 1846 * {@code provider.withSourceTableFallback(true)} so secondary sub-SELECTs 1847 * (which Resolver2 does not traverse) can still bind their column refs. 1848 * The fallback is constrained at the provider level to fire only when 1849 * Phase 2 did not run AND any explicit qualifier matches Phase 1's source — 1850 * see {@link Resolver2NameBindingProvider#bindColumn}. Current Hive 1851 * multi-insert parses always present a single FROM source, so Phase 1's 1852 * unqualified-column resolution is unambiguous in practice. 1853 */ 1854 private static SemanticProgram buildHiveMultiInsert(TInsertSqlStatement insert, 1855 NameBindingProvider provider) { 1856 // Slice 93 — source-table fallback strategy for Hive multi-insert. 1857 // 1858 // TSQLResolver2 does NOT process the secondary inserts in 1859 // getMultiInsertStatements(): their column refs have 1860 // resolution == null (Phase 2 did not run) even though Phase 1's 1861 // linkColumnToTable sets sourceTable. To let collectColumnRefs 1862 // accept these bindings, we enable a narrow source-table fallback 1863 // in the provider — but ONLY when every sub-SELECT has a SINGLE 1864 // FROM source (the common Hive multi-insert shape that current 1865 // parser support admits). In single-source contexts, Phase 1's 1866 // unqualified-column resolution is unambiguous; the fallback is 1867 // safe (round-2 codex Q1 BLOCKING). 1868 // 1869 // If any sub-SELECT has multiple FROM sources, Phase 1 may have 1870 // heuristically picked one source for an unqualified column — 1871 // promoting that to EXACT_MATCH could silently mis-bind. In that 1872 // case the fallback stays disabled; users must qualify column 1873 // references in the secondary branch (the qualifier-matches-source 1874 // safety in bindColumn still allows qualified refs through). 1875 boolean singleSource = isSingleSourceMultiInsert(insert); 1876 NameBindingProvider effectiveProvider = singleSource 1877 ? provider.withSourceTableFallback(true) 1878 : provider; 1879 1880 List<StatementGraph> out = new ArrayList<>(); 1881 List<LineageEdge> outLineage = new ArrayList<>(); 1882 1883 // Slice 109 — outer WITH on multi-insert: build the CTE bodies ONCE 1884 // upfront so each sub-SELECT's `FROM x` resolves against the shared 1885 // cteMap/publishedMap. The parser attaches the outer WITH to the 1886 // primary insert's getCteList(); sub-INSERTs in 1887 // getMultiInsertStatements() carry null cteLists. The AST handoff 1888 // mirrors the slice-104 single-target pattern but only nulls 1889 // insert.getCteList() — there is no source SELECT to move it onto 1890 // because each sub-INSERT has its own. 1891 TCTEList outerCtes = insert.getCteList(); 1892 Map<String, Integer> cteMap = new HashMap<>(); 1893 Map<String, List<String>> publishedMap = new HashMap<>(); 1894 boolean handoffApplied = false; 1895 if (outerCtes != null && outerCtes.size() > 0) { 1896 insert.setCteList(null); 1897 handoffApplied = true; 1898 try { 1899 buildSelectCteList(outerCtes, effectiveProvider, out, outLineage, 1900 cteMap, publishedMap, 1901 /*allowShadowOverride=*/ false, 1902 /*additionalAllCteNames=*/ null); 1903 } catch (RuntimeException ex) { 1904 // Restore eagerly on CTE-build failure so a downstream caller 1905 // observing the AST sees the original cteList. 1906 insert.setCteList(outerCtes); 1907 throw ex; 1908 } 1909 } 1910 1911 try { 1912 // Primary INSERT (first target) 1913 appendOneHiveInsert(insert, effectiveProvider, out, outLineage, 1914 cteMap, publishedMap); 1915 1916 // Additional INSERTs from getMultiInsertStatements() 1917 for (Object miObj : insert.getMultiInsertStatements()) { 1918 appendOneHiveInsert((TInsertSqlStatement) miObj, effectiveProvider, 1919 out, outLineage, cteMap, publishedMap); 1920 } 1921 } finally { 1922 if (handoffApplied) { 1923 insert.setCteList(outerCtes); 1924 } 1925 } 1926 1927 return new SemanticProgram(out, outLineage); 1928 } 1929 1930 /** 1931 * Slice 93 — true when every INSERT-SELECT pair in a Hive multi-insert 1932 * block has a single FROM source (i.e., one entry in 1933 * {@code subQuery.getTables()}). Guards the source-table fallback so 1934 * Phase 1's heuristic source assignment is only trusted in contexts 1935 * where it is provably unambiguous (round-2 codex Q1 BLOCKING). 1936 */ 1937 private static boolean isSingleSourceMultiInsert(TInsertSqlStatement insert) { 1938 if (!isSingleSourceSubQuery(insert.getSubQuery())) { 1939 return false; 1940 } 1941 for (Object miObj : insert.getMultiInsertStatements()) { 1942 TInsertSqlStatement mi = (TInsertSqlStatement) miObj; 1943 if (!isSingleSourceSubQuery(mi.getSubQuery())) { 1944 return false; 1945 } 1946 } 1947 return true; 1948 } 1949 1950 private static boolean isSingleSourceSubQuery(TSelectSqlStatement sel) { 1951 return sel != null && sel.getTables() != null && sel.getTables().size() == 1; 1952 } 1953 1954 /** 1955 * Build one INSERT-SELECT pair into {@code out} / {@code outLineage}. 1956 * Called by {@link #buildHiveMultiInsert} for the primary and each 1957 * additional INSERT in a Hive multi-insert block. Each call validates 1958 * the target/source, builds the source SELECT via {@link #build}, and 1959 * delegates the post-build INSERT-graph assembly to 1960 * {@link #assembleInsertGraphAndLineage} so the layout exactly mirrors 1961 * the single-target slice-78 INSERT path (the helper also handles 1962 * inner-lineage rebasing when {@code out} is non-empty). 1963 */ 1964 private static void appendOneHiveInsert(TInsertSqlStatement insert, 1965 NameBindingProvider provider, 1966 List<StatementGraph> out, 1967 List<LineageEdge> outLineage, 1968 Map<String, Integer> cteMap, 1969 Map<String, List<String>> publishedMap) { 1970 TTable targetTable = insert.getTargetTable(); 1971 if (targetTable == null || targetTable.getTableName() == null) { 1972 throw new SemanticIRBuildException(Diagnostic.error( 1973 DiagnosticCode.INSERT_TARGET_MISSING, 1974 "Hive multi-insert: INSERT has no resolvable target table", 1975 insert)); 1976 } 1977 String targetQName = targetTable.getTableName().toString(); 1978 if (targetQName.isEmpty()) { 1979 throw new SemanticIRBuildException(Diagnostic.error( 1980 DiagnosticCode.INSERT_TARGET_MISSING, 1981 "Hive multi-insert: INSERT target table name is empty", 1982 insert)); 1983 } 1984 TSelectSqlStatement source = insert.getSubQuery(); 1985 if (source == null) { 1986 throw new SemanticIRBuildException(Diagnostic.error( 1987 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 1988 "Hive multi-insert: INSERT has no source SELECT", 1989 insert)); 1990 } 1991 1992 // Slice 109 — when outer CTEs are present (cteMap non-empty), build 1993 // the source SELECT via buildSelectBodyAfterCteWalk directly into 1994 // out/outLineage so it sees the shared cteMap/publishedMap. The 1995 // slice-93 path (no outer CTEs) keeps the build(source, provider) + 1996 // assembleInsertGraphAndLineage flow unchanged. 1997 if (cteMap.isEmpty()) { 1998 // Internal recursion — inherit ambient options (see buildInsertImpl). 1999 SemanticProgram inner = buildImpl(source, provider); 2000 // Hive has no RETURNING/OUTPUT — pass null clauses directly. 2001 assembleInsertGraphAndLineage( 2002 insert, targetTable, targetQName, inner, 2003 "Hive multi-insert: INSERT", 2004 /*returningClause=*/ null, 2005 /*outputClause=*/ null, 2006 out, outLineage, provider); 2007 return; 2008 } 2009 2010 // Slice 109 — defensive: parser probe shows sub-SELECTs in Hive 2011 // multi-insert do NOT carry their own cteList. If a future parser 2012 // change ever attached one, mixed outer+inner WITH semantics would 2013 // need slice-107/108-style two-pass walker support; until then the 2014 // shape rejects with the existing mixed-WITH code. 2015 if (source.getCteList() != null && source.getCteList().size() > 0) { 2016 throw new SemanticIRBuildException(Diagnostic.error( 2017 DiagnosticCode.INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED, 2018 "Hive multi-insert: mixed outer + inner WITH on a " 2019 + "sub-SELECT is not supported by " 2020 + "SemanticIRBuilder.buildHiveMultiInsert; " 2021 + "slice 109 admits outer-only WITH on multi-insert", 2022 insert)); 2023 } 2024 2025 // Snapshot out.size() so the source SELECT and its inner extractions 2026 // are pinned to known positions. The slice-23 EXISTS-extraction and 2027 // FROM-subquery extraction paths inside buildSelectBodyAfterCteWalk 2028 // append directly to out/outLineage; the source SELECT lands LAST. 2029 int beforeSelectIdx = out.size(); 2030 buildSelectBodyAfterCteWalk(source, provider, out, outLineage, 2031 cteMap, publishedMap, 2032 /*hasOuterCteListAlreadyProcessed=*/ true); 2033 if (out.size() <= beforeSelectIdx) { 2034 // Defensive: buildSelectBodyAfterCteWalk always appends at least 2035 // the source SELECT; this branch is unreachable in practice. 2036 throw new SemanticIRBuildException(Diagnostic.error( 2037 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 2038 "Hive multi-insert: INSERT source built no statements", 2039 insert)); 2040 } 2041 int selectIdx = out.size() - 1; 2042 assembleInsertTargetGraphFromAppended( 2043 insert, targetTable, targetQName, selectIdx, 2044 "Hive multi-insert: INSERT", 2045 /*returningClause=*/ null, 2046 /*outputClause=*/ null, 2047 out, outLineage, provider); 2048 } 2049 2050 /** 2051 * Slice 93 — shared INSERT-graph assembly used by both the slice-78 2052 * single-target {@link #buildInsert} and the slice-93 Hive multi-insert 2053 * {@link #appendOneHiveInsert}. Appends {@code inner.getStatements()} 2054 * to {@code out} (rebasing {@code inner.getLineage()}'s STATEMENT_OUTPUT 2055 * indices when {@code out} is non-empty), then appends an INSERT-kind 2056 * {@link StatementGraph} and per-source-output cross-statement 2057 * {@link LineageEdge}s. 2058 * 2059 * <p>Discriminators between the two callers: 2060 * <ul> 2061 * <li>{@code diagnosticPrefix} is woven into column-count-mismatch 2062 * and empty-inner-source error messages so the originating call 2063 * site is identifiable.</li> 2064 * <li>{@code returningClause} / {@code outputClause} are passed 2065 * directly to {@link #buildReturningColumns} (slice 78 supplies 2066 * the INSERT's RETURNING/OUTPUT clauses; slice 93's Hive path 2067 * passes {@code null}/{@code null} since Hive has no 2068 * RETURNING/OUTPUT). Passing the clauses directly keeps the 2069 * discriminator visible at every call site rather than hidden 2070 * behind a boolean (round-2 codex Q3 suggestion).</li> 2071 * </ul> 2072 * 2073 * <p>Mutates both {@code out} and {@code outLineage}. 2074 */ 2075 private static void assembleInsertGraphAndLineage( 2076 TInsertSqlStatement insert, 2077 TTable targetTable, 2078 String targetQName, 2079 SemanticProgram inner, 2080 String diagnosticPrefix, 2081 TReturningClause returningClause, 2082 TOutputClause outputClause, 2083 List<StatementGraph> out, 2084 List<LineageEdge> outLineage, 2085 NameBindingProvider provider) { 2086 List<StatementGraph> innerStmts = inner.getStatements(); 2087 if (innerStmts.isEmpty()) { 2088 // Defensive: build() always returns at least one statement when 2089 // it doesn't throw. This branch is unreachable in practice but 2090 // surfaces a structured diagnostic instead of an 2091 // IndexOutOfBoundsException on the sourceOuter access below. 2092 throw new SemanticIRBuildException(Diagnostic.error( 2093 DiagnosticCode.INSERT_SOURCE_NOT_SUPPORTED, 2094 diagnosticPrefix + " source built no statements", 2095 insert)); 2096 } 2097 2098 // Rebase inner lineage edges by the current out.size() offset 2099 // (round-2 codex Q4 BLOCKING). For the slice-78 single-target 2100 // path out is empty (offset=0) so rebase is a no-op; for the 2101 // slice-93 Hive path each subsequent INSERT-SELECT pair adds 2102 // an offset matching the absolute position of its inner block. 2103 int offset = out.size(); 2104 int selectIdx = offset + innerStmts.size() - 1; 2105 out.addAll(innerStmts); 2106 for (LineageEdge e : inner.getLineage()) { 2107 outLineage.add(rebaseLineageEdge(e, offset)); 2108 } 2109 2110 StatementGraph sourceOuter = innerStmts.get(innerStmts.size() - 1); 2111 List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns(); 2112 int sourceOutCount = sourceOutputs.size(); 2113 2114 // Optional explicit INSERT column list. Verbatim bare-name 2115 // spelling per slice-78 contract; arity mismatch rejects. 2116 TObjectNameList colList = insert.getColumnList(); 2117 List<String> targetColumnNames = new ArrayList<>(); 2118 if (colList != null && colList.size() > 0) { 2119 for (int i = 0; i < colList.size(); i++) { 2120 TObjectName n = colList.getObjectName(i); 2121 targetColumnNames.add(n == null ? "" : n.toString()); 2122 } 2123 if (targetColumnNames.size() != sourceOutCount) { 2124 throw new SemanticIRBuildException(Diagnostic.error( 2125 DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH, 2126 diagnosticPrefix + " column list has " 2127 + targetColumnNames.size() 2128 + " column(s) but source SELECT produced " 2129 + sourceOutCount + " output(s)", 2130 insert)); 2131 } 2132 } 2133 2134 // INSERT StatementGraph — slice-78 single-target shape with the 2135 // source SELECT as a SUBQUERY-kind relation entry. 2136 String sourceName = sourceOuter.getName(); 2137 String sourceRelAlias = (sourceName != null && !sourceName.isEmpty()) 2138 ? sourceName : "__insert_source__"; 2139 RelationBinding sourceBinding = new RelationBinding( 2140 RelationKind.SUBQUERY, sourceRelAlias); 2141 List<RelationSource> insertRelations = new ArrayList<>(); 2142 insertRelations.add(new RelationSource(sourceRelAlias, sourceBinding)); 2143 2144 RelationBinding targetBinding = new RelationBinding( 2145 RelationKind.TABLE, targetQName); 2146 TargetRelation target = new TargetRelation(targetBinding, targetColumnNames); 2147 2148 int insertIdx = out.size(); 2149 String insertTargetAlias = effectiveAliasOf(targetTable); 2150 if (insertTargetAlias == null || insertTargetAlias.isEmpty()) { 2151 insertTargetAlias = targetQName; 2152 } 2153 // Slice 85: RETURNING/OUTPUT projections. Clauses are passed 2154 // through directly from the call site (slice-78 single-target 2155 // forwards the INSERT's own clauses; slice-93 Hive multi-insert 2156 // forwards null/null since Hive has no RETURNING/OUTPUT). 2157 List<OutputColumn> returningCols = buildReturningColumns( 2158 returningClause, 2159 outputClause, 2160 "INSERT", 2161 targetQName, 2162 insertTargetAlias, 2163 targetTable, 2164 /*fromSideRelations=*/ Collections.<RelationSource>emptyList(), 2165 /*fromSideAliasToStmtIndex=*/ Collections.<String, Integer>emptyMap(), 2166 provider, 2167 insertIdx, 2168 outLineage, 2169 insert); 2170 2171 StatementGraph insertOuter = new StatementGraph( 2172 /*name=*/ null, 2173 "INSERT", 2174 insertRelations, 2175 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 2176 returningCols, 2177 /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2178 /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2179 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2180 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2181 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2182 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2183 /*distinct=*/ false, 2184 /*setOperator=*/ null, 2185 /*rowLimit=*/ null, 2186 target); 2187 out.add(insertOuter); 2188 2189 // Cross-statement lineage: target.col_i ← STATEMENT_OUTPUT(selectIdx, srcName_i) 2190 for (int i = 0; i < sourceOutCount; i++) { 2191 String srcName = sourceOutputs.get(i).getName(); 2192 String tgtName = (i < targetColumnNames.size()) 2193 ? targetColumnNames.get(i) : srcName; 2194 if (tgtName == null || tgtName.isEmpty()) { 2195 continue; 2196 } 2197 outLineage.add(new LineageEdge( 2198 LineageRef.tableColumn(targetQName, tgtName), 2199 LineageRef.statementOutput(selectIdx, srcName))); 2200 } 2201 } 2202 2203 /** 2204 * Slice 109 — assemble the INSERT-target half (TargetRelation, INSERT 2205 * StatementGraph, RETURNING/OUTPUT projections, and cross-statement 2206 * lineage edges) when the source SELECT and its inner extractions have 2207 * ALREADY been appended directly to {@code out}/{@code outLineage} by 2208 * {@link #buildSelectBodyAfterCteWalk}. The slice-93 2209 * {@link #assembleInsertGraphAndLineage} helper, by contrast, takes a 2210 * pre-built {@link SemanticProgram} and rebases STATEMENT_OUTPUT 2211 * indices on the way in — that path is unused here because the source 2212 * SELECT was already built into absolute positions in {@code out}. 2213 * 2214 * <p>{@code selectIdx} must be the position of the source SELECT in 2215 * {@code out} (last statement appended by the caller before this helper 2216 * runs). RETURNING/OUTPUT clauses are passed directly (Hive multi- 2217 * insert callers pass {@code null}/{@code null}); other DMLs that 2218 * adopt this helper later can forward their own. 2219 */ 2220 private static void assembleInsertTargetGraphFromAppended( 2221 TInsertSqlStatement insert, 2222 TTable targetTable, 2223 String targetQName, 2224 int selectIdx, 2225 String diagnosticPrefix, 2226 TReturningClause returningClause, 2227 TOutputClause outputClause, 2228 List<StatementGraph> out, 2229 List<LineageEdge> outLineage, 2230 NameBindingProvider provider) { 2231 StatementGraph sourceOuter = out.get(selectIdx); 2232 List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns(); 2233 int sourceOutCount = sourceOutputs.size(); 2234 2235 TObjectNameList colList = insert.getColumnList(); 2236 List<String> targetColumnNames = new ArrayList<>(); 2237 if (colList != null && colList.size() > 0) { 2238 for (int i = 0; i < colList.size(); i++) { 2239 TObjectName n = colList.getObjectName(i); 2240 targetColumnNames.add(n == null ? "" : n.toString()); 2241 } 2242 if (targetColumnNames.size() != sourceOutCount) { 2243 throw new SemanticIRBuildException(Diagnostic.error( 2244 DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH, 2245 diagnosticPrefix + " column list has " 2246 + targetColumnNames.size() 2247 + " column(s) but source SELECT produced " 2248 + sourceOutCount + " output(s)", 2249 insert)); 2250 } 2251 } 2252 2253 String sourceName = sourceOuter.getName(); 2254 String sourceRelAlias = (sourceName != null && !sourceName.isEmpty()) 2255 ? sourceName : "__insert_source__"; 2256 RelationBinding sourceBinding = new RelationBinding( 2257 RelationKind.SUBQUERY, sourceRelAlias); 2258 List<RelationSource> insertRelations = new ArrayList<>(); 2259 insertRelations.add(new RelationSource(sourceRelAlias, sourceBinding)); 2260 2261 RelationBinding targetBinding = new RelationBinding( 2262 RelationKind.TABLE, targetQName); 2263 TargetRelation target = new TargetRelation(targetBinding, targetColumnNames); 2264 2265 int insertIdx = out.size(); 2266 String insertTargetAlias = effectiveAliasOf(targetTable); 2267 if (insertTargetAlias == null || insertTargetAlias.isEmpty()) { 2268 insertTargetAlias = targetQName; 2269 } 2270 List<OutputColumn> returningCols = buildReturningColumns( 2271 returningClause, 2272 outputClause, 2273 "INSERT", 2274 targetQName, 2275 insertTargetAlias, 2276 targetTable, 2277 /*fromSideRelations=*/ Collections.<RelationSource>emptyList(), 2278 /*fromSideAliasToStmtIndex=*/ Collections.<String, Integer>emptyMap(), 2279 provider, 2280 insertIdx, 2281 outLineage, 2282 insert); 2283 2284 StatementGraph insertOuter = new StatementGraph( 2285 /*name=*/ null, 2286 "INSERT", 2287 insertRelations, 2288 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 2289 returningCols, 2290 /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2291 /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2292 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2293 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2294 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2295 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2296 /*distinct=*/ false, 2297 /*setOperator=*/ null, 2298 /*rowLimit=*/ null, 2299 target); 2300 out.add(insertOuter); 2301 2302 for (int i = 0; i < sourceOutCount; i++) { 2303 String srcName = sourceOutputs.get(i).getName(); 2304 String tgtName = (i < targetColumnNames.size()) 2305 ? targetColumnNames.get(i) : srcName; 2306 if (tgtName == null || tgtName.isEmpty()) { 2307 continue; 2308 } 2309 outLineage.add(new LineageEdge( 2310 LineageRef.tableColumn(targetQName, tgtName), 2311 LineageRef.statementOutput(selectIdx, srcName))); 2312 } 2313 } 2314 2315 /** 2316 * Slice 93 — rebase a {@link LineageEdge}'s {@code STATEMENT_OUTPUT} 2317 * statement indices by {@code offset}. {@code TABLE_COLUMN} refs are 2318 * returned unchanged. Used to concatenate inner {@link SemanticProgram}s 2319 * into a larger one (Hive multi-insert: each INSERT-SELECT pair's inner 2320 * program contributes its own block of statements). 2321 */ 2322 private static LineageEdge rebaseLineageEdge(LineageEdge e, int offset) { 2323 if (offset == 0) { 2324 return e; 2325 } 2326 LineageRef from = rebaseLineageRef(e.getFrom(), offset); 2327 LineageRef to = rebaseLineageRef(e.getTo(), offset); 2328 if (from == e.getFrom() && to == e.getTo()) { 2329 return e; 2330 } 2331 return new LineageEdge(from, to); 2332 } 2333 2334 private static LineageRef rebaseLineageRef(LineageRef ref, int offset) { 2335 if (ref == null) { 2336 return null; 2337 } 2338 if (ref.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) { 2339 return ref; 2340 } 2341 return LineageRef.statementOutput( 2342 ref.getStatementIndex() + offset, ref.getOutputName()); 2343 } 2344 2345 /** 2346 * Slice 79 — admit a single {@code CREATE TABLE target [(c1, ...)] AS 2347 * SELECT ...} (CTAS) statement. Builds the source SELECT via 2348 * {@link #build} unchanged, then appends a {@code "CREATE_TABLE"}- 2349 * kind {@link StatementGraph} carrying the target relation and 2350 * cross-statement lineage edges (mirrors slice-78 INSERT). 2351 * 2352 * <p>Admitted shape: {@code CREATE [OR REPLACE] TABLE target 2353 * [(c1, c2, ...)] AS <subquery-SELECT>}. Plain 2354 * {@code CREATE TABLE target (a INT, b VARCHAR)} (column DDL with 2355 * no AS SELECT) is rejected via 2356 * {@link DiagnosticCode#CREATE_AS_NO_SOURCE_SELECT}. Explicit 2357 * column-list arity mismatch surfaces as 2358 * {@link DiagnosticCode#CREATE_AS_COLUMN_COUNT_MISMATCH}; a 2359 * missing / empty target name surfaces (defensively) as 2360 * {@link DiagnosticCode#CREATE_AS_TARGET_MISSING}. 2361 * 2362 * <p>For CTAS the explicit column-list spellings come from 2363 * {@link TCreateTableSqlStatement#getColumnList()} — only the bare 2364 * column name from each {@link TColumnDefinition} is consumed; 2365 * data-type tokens are ignored by slice 79. 2366 */ 2367 /** 2368 * Build under caller-supplied {@code options}; see 2369 * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}. 2370 */ 2371 public static SemanticBuildResult buildCreateTableResult( 2372 TCreateTableSqlStatement create, NameBindingProvider provider, 2373 SemanticIRBuildOptions options) { 2374 return executeBuild(options, () -> buildCreateTableImpl(create, provider)); 2375 } 2376 2377 /** Build a CTAS and atomically return its program and diagnostics. */ 2378 public static SemanticBuildResult buildCreateTableResult( 2379 TCreateTableSqlStatement create, NameBindingProvider provider) { 2380 return buildCreateTableResult(create, provider, 2381 SemanticIRBuildOptions.defaults()); 2382 } 2383 2384 @Deprecated 2385 public static SemanticProgram buildCreateTable(TCreateTableSqlStatement create, 2386 NameBindingProvider provider, 2387 SemanticIRBuildOptions options) { 2388 clearBuildDiagnostics(); 2389 return publishLegacyResult( 2390 buildCreateTableResult(create, provider, options)); 2391 } 2392 2393 @Deprecated 2394 public static SemanticProgram buildCreateTable(TCreateTableSqlStatement create, 2395 NameBindingProvider provider) { 2396 clearBuildDiagnostics(); 2397 return publishLegacyResult(buildCreateTableResult(create, provider)); 2398 } 2399 2400 private static SemanticProgram buildCreateTableImpl(TCreateTableSqlStatement create, 2401 NameBindingProvider provider) { 2402 if (create == null) { 2403 throw new IllegalArgumentException("create must not be null"); 2404 } 2405 if (provider == null) { 2406 throw new IllegalArgumentException("provider must not be null"); 2407 } 2408 2409 // Target name extraction. CTAS exposes the target via the 2410 // TCustomSqlStatement-inherited getTargetTable(); the explicit 2411 // getTableName() is a thin wrapper around tables[0].getTableName() 2412 // and also works. Use getTableName() for symmetry with the 2413 // slice-78 INSERT path. 2414 TObjectName targetName = create.getTableName(); 2415 if (targetName == null) { 2416 throw new SemanticIRBuildException(Diagnostic.error( 2417 DiagnosticCode.CREATE_AS_TARGET_MISSING, 2418 "CREATE TABLE has no resolvable target table name", 2419 create)); 2420 } 2421 String targetQName = targetName.toString(); 2422 if (targetQName == null || targetQName.isEmpty()) { 2423 throw new SemanticIRBuildException(Diagnostic.error( 2424 DiagnosticCode.CREATE_AS_TARGET_MISSING, 2425 "CREATE TABLE target table name is empty", 2426 create)); 2427 } 2428 2429 TSelectSqlStatement source = create.getSubQuery(); 2430 if (source == null) { 2431 throw new SemanticIRBuildException(Diagnostic.error( 2432 DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT, 2433 "CREATE TABLE has no AS SELECT subquery; slice 79 admits " 2434 + "CTAS (CREATE TABLE <target> [(c1, ...)] AS SELECT ...) only", 2435 create)); 2436 } 2437 2438 // Pull explicit column-list spellings BEFORE building the inner 2439 // — keeps the error path cheap for the structural-invalid case 2440 // (CTAS with column count mismatch is detected after the inner 2441 // build because we don't know the source output count yet). 2442 List<String> targetColumnNames = new ArrayList<>(); 2443 TColumnDefinitionList colList = create.getColumnList(); 2444 if (colList != null && colList.size() > 0) { 2445 for (int i = 0; i < colList.size(); i++) { 2446 TColumnDefinition cd = colList.getColumn(i); 2447 TObjectName n = (cd == null) ? null : cd.getColumnName(); 2448 String spelling = (n == null) ? "" : n.toString(); 2449 targetColumnNames.add(spelling); 2450 } 2451 } 2452 2453 return assembleCreateLikeProgram(create, source, provider, 2454 "CREATE_TABLE", targetQName, targetColumnNames); 2455 } 2456 2457 /** 2458 * Slice 79 — admit a single 2459 * {@code CREATE [OR REPLACE] VIEW v [(c1, ...)] AS SELECT ...} 2460 * statement. Mirrors {@link #buildCreateTable} except the source 2461 * SELECT is fetched via {@link TCreateViewSqlStatement#getSubquery()} 2462 * (lowercase 'q'), the target name from 2463 * {@link TCreateViewSqlStatement#getViewName()}, and the explicit 2464 * column-list spellings from {@link TViewAliasClause} on the AST. 2465 */ 2466 /** 2467 * Build under caller-supplied {@code options}; see 2468 * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}. 2469 */ 2470 public static SemanticBuildResult buildCreateViewResult( 2471 TCreateViewSqlStatement create, NameBindingProvider provider, 2472 SemanticIRBuildOptions options) { 2473 return executeBuild(options, () -> buildCreateViewImpl(create, provider)); 2474 } 2475 2476 /** Build a CREATE VIEW and atomically return its program and diagnostics. */ 2477 public static SemanticBuildResult buildCreateViewResult( 2478 TCreateViewSqlStatement create, NameBindingProvider provider) { 2479 return buildCreateViewResult(create, provider, 2480 SemanticIRBuildOptions.defaults()); 2481 } 2482 2483 @Deprecated 2484 public static SemanticProgram buildCreateView(TCreateViewSqlStatement create, 2485 NameBindingProvider provider, 2486 SemanticIRBuildOptions options) { 2487 clearBuildDiagnostics(); 2488 return publishLegacyResult( 2489 buildCreateViewResult(create, provider, options)); 2490 } 2491 2492 @Deprecated 2493 public static SemanticProgram buildCreateView(TCreateViewSqlStatement create, 2494 NameBindingProvider provider) { 2495 clearBuildDiagnostics(); 2496 return publishLegacyResult(buildCreateViewResult(create, provider)); 2497 } 2498 2499 private static SemanticProgram buildCreateViewImpl(TCreateViewSqlStatement create, 2500 NameBindingProvider provider) { 2501 if (create == null) { 2502 throw new IllegalArgumentException("create must not be null"); 2503 } 2504 if (provider == null) { 2505 throw new IllegalArgumentException("provider must not be null"); 2506 } 2507 2508 TObjectName viewName = create.getViewName(); 2509 if (viewName == null) { 2510 throw new SemanticIRBuildException(Diagnostic.error( 2511 DiagnosticCode.CREATE_AS_TARGET_MISSING, 2512 "CREATE VIEW has no resolvable view name", 2513 create)); 2514 } 2515 String targetQName = viewName.toString(); 2516 if (targetQName == null || targetQName.isEmpty()) { 2517 throw new SemanticIRBuildException(Diagnostic.error( 2518 DiagnosticCode.CREATE_AS_TARGET_MISSING, 2519 "CREATE VIEW target view name is empty", 2520 create)); 2521 } 2522 2523 TSelectSqlStatement source = create.getSubquery(); 2524 if (source == null) { 2525 throw new SemanticIRBuildException(Diagnostic.error( 2526 DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT, 2527 "CREATE VIEW has no AS SELECT subquery; slice 79 admits " 2528 + "CREATE VIEW <target> [(c1, ...)] AS SELECT ... only", 2529 create)); 2530 } 2531 2532 // View-side explicit column aliases via viewAliasClause. Items 2533 // whose alias is null are preserved as empty-string entries so 2534 // a parser-quirk gap doesn't silently collapse the list and 2535 // shift later aliases onto wrong source-output positions — 2536 // count-mismatch detection downstream stays accurate 2537 // (codex diff-review round 1 P2 catch). 2538 List<String> targetColumnNames = new ArrayList<>(); 2539 TViewAliasClause aliasClause = create.getViewAliasClause(); 2540 if (aliasClause != null) { 2541 TViewAliasItemList items = aliasClause.getViewAliasItemList(); 2542 if (items != null) { 2543 for (int i = 0; i < items.size(); i++) { 2544 TViewAliasItem item = items.getViewAliasItem(i); 2545 TObjectName alias = (item == null) ? null : item.getAlias(); 2546 String spelling = (alias == null) ? "" : alias.toString(); 2547 targetColumnNames.add(spelling); 2548 } 2549 } 2550 } 2551 2552 return assembleCreateLikeProgram(create, source, provider, 2553 "CREATE_VIEW", targetQName, targetColumnNames); 2554 } 2555 2556 /** 2557 * Shared assembly path for slice-79 CTAS / CREATE VIEW. Given a 2558 * pre-validated target name and the (possibly empty) list of 2559 * explicit column-list spellings, builds the source SELECT, 2560 * validates column-list arity, and emits the outer 2561 * StatementGraph + cross-stmt lineage edges. Mirrors the 2562 * post-source half of slice-78 {@link #buildInsert}. 2563 */ 2564 private static SemanticProgram assembleCreateLikeProgram( 2565 TParseTreeNode anchor, TSelectSqlStatement source, 2566 NameBindingProvider provider, String outerKind, 2567 String targetQName, List<String> targetColumnNames) { 2568 // Internal recursion — inherit ambient options (see buildInsertImpl). 2569 SemanticProgram inner = buildImpl(source, provider); 2570 List<StatementGraph> innerStmts = inner.getStatements(); 2571 if (innerStmts.isEmpty()) { 2572 throw new SemanticIRBuildException(Diagnostic.error( 2573 DiagnosticCode.CREATE_AS_NO_SOURCE_SELECT, 2574 "CREATE source built no statements", 2575 anchor)); 2576 } 2577 int selectIdx = innerStmts.size() - 1; 2578 StatementGraph sourceOuter = innerStmts.get(selectIdx); 2579 List<OutputColumn> sourceOutputs = sourceOuter.getOutputColumns(); 2580 int sourceOutCount = sourceOutputs.size(); 2581 2582 if (!targetColumnNames.isEmpty() 2583 && targetColumnNames.size() != sourceOutCount) { 2584 throw new SemanticIRBuildException(Diagnostic.error( 2585 DiagnosticCode.CREATE_AS_COLUMN_COUNT_MISMATCH, 2586 outerKind.equals("CREATE_TABLE") 2587 ? ("CREATE TABLE column list has " + targetColumnNames.size() 2588 + " column(s) but source SELECT produced " 2589 + sourceOutCount + " output(s)") 2590 : ("CREATE VIEW alias list has " + targetColumnNames.size() 2591 + " column(s) but source SELECT produced " 2592 + sourceOutCount + " output(s)"), 2593 anchor)); 2594 } 2595 2596 String sourceName = sourceOuter.getName(); 2597 String sourceRelAlias = (sourceName != null && !sourceName.isEmpty()) 2598 ? sourceName : "__create_source__"; 2599 RelationBinding sourceBinding = new RelationBinding( 2600 RelationKind.SUBQUERY, sourceRelAlias); 2601 List<RelationSource> createRelations = new ArrayList<>(); 2602 createRelations.add(new RelationSource(sourceRelAlias, sourceBinding)); 2603 2604 RelationBinding targetBinding = new RelationBinding( 2605 RelationKind.TABLE, targetQName); 2606 TargetRelation target = new TargetRelation(targetBinding, targetColumnNames); 2607 2608 List<StatementGraph> out = new ArrayList<>(innerStmts.size() + 1); 2609 out.addAll(innerStmts); 2610 List<LineageEdge> outLineage = new ArrayList<>(inner.getLineage()); 2611 2612 StatementGraph createOuter = new StatementGraph( 2613 /*name=*/ null, 2614 outerKind, 2615 createRelations, 2616 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 2617 /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2618 /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2619 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2620 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2621 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2622 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 2623 /*distinct=*/ false, 2624 /*setOperator=*/ null, 2625 /*rowLimit=*/ null, 2626 target); 2627 out.add(createOuter); 2628 2629 for (int i = 0; i < sourceOutCount; i++) { 2630 String srcName = sourceOutputs.get(i).getName(); 2631 String tgtName = (i < targetColumnNames.size()) 2632 ? targetColumnNames.get(i) : srcName; 2633 if (tgtName == null || tgtName.isEmpty()) { 2634 continue; 2635 } 2636 outLineage.add(new LineageEdge( 2637 LineageRef.tableColumn(targetQName, tgtName), 2638 LineageRef.statementOutput(selectIdx, srcName))); 2639 } 2640 2641 return new SemanticProgram(out, outLineage); 2642 } 2643 2644 /** 2645 * Slice 80 / 82 — admit {@code UPDATE target SET c1 = expr1, 2646 * c2 = expr2, ... [FROM source_list] [WHERE pred]} statements. 2647 * Emits one {@code "UPDATE"}-kind {@link StatementGraph} carrying 2648 * the target relation plus synthetic {@link OutputColumn} entries 2649 * per SET assignment (output name = SET LHS verbatim spelling; 2650 * sources = column refs collected from the RHS expression). 2651 * Optional WHERE refs surface on 2652 * {@link StatementGraph#getFilterColumnRefs()}. 2653 * 2654 * <p>Slice 82 lifts the slice-80 {@code UPDATE_JOINED_NOT_SUPPORTED} 2655 * reject for the common PG / MSSQL / BigQuery / Snowflake / Redshift 2656 * FROM-side joined UPDATE shapes. The IR shape gains two slots: 2657 * {@code relations[]} now carries TABLE-kind RelationSources for 2658 * FROM-side sources (slice 80 left empty), and 2659 * {@code joinColumnRefs[]} now carries ON-clause column refs from 2660 * FROM-side JOINs. The target stays on 2661 * {@link StatementGraph#getTarget()}; a reference-identity filter 2662 * excludes the target's own TTable instance from {@code relations[]}. 2663 * 2664 * <p>Admitted shape: 2665 * <ul> 2666 * <li>Single-target UPDATE without FROM (slice 80) — 2667 * {@code relations[]} stays empty.</li> 2668 * <li>PG / BQ / SF / RS {@code UPDATE t SET ... FROM source} 2669 * (single FROM source).</li> 2670 * <li>PG / BQ {@code UPDATE t SET ... FROM s1, s2, ...} 2671 * (comma-FROM list).</li> 2672 * <li>PG / MSSQL {@code UPDATE t SET ... FROM s1 [INNER|LEFT|RIGHT|FULL OUTER] JOIN s2 ON ...} 2673 * — ON refs populate {@code joinColumnRefs[]}.</li> 2674 * <li>MSSQL {@code UPDATE t SET ... FROM t INNER JOIN s ON ...} 2675 * — target may appear in FROM; reference-identity filter 2676 * excludes the target's own TTable instance from 2677 * {@code relations[]}.</li> 2678 * <li>Explicit {@code CROSS JOIN} (no ON; semantically equivalent 2679 * to comma-FROM).</li> 2680 * <li>SET LHS is a {@link EExpressionType#simple_object_name_t} 2681 * column reference (qualified {@code t.x} or bare {@code x}). 2682 * Oracle tuple {@code SET (a, b) = (...)} (LHS = list_t) 2683 * rejects via 2684 * {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}.</li> 2685 * <li>SET RHS may be any expression NOT containing a scalar 2686 * subquery and NOT containing a window function. Subqueries 2687 * reject via 2688 * {@link DiagnosticCode#UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED}; 2689 * window functions reuse the existing 2690 * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} 2691 * routed through {@link #rejectWindowFunctionInScope}.</li> 2692 * <li>Optional WHERE clause — existing WHERE-side rejects 2693 * (subqueries, window functions) continue to apply via the 2694 * shared {@link #containsAnySubquery} + 2695 * {@code rejectWindowFunctionInScope} helpers used by SELECT 2696 * WHERE.</li> 2697 * </ul> 2698 * 2699 * <p>Slice 82 reject scope, with slice 83 admitting subquery FROM 2700 * sources (the slice-82 {@code UPDATE_FROM_SUBQUERY_NOT_SUPPORTED} 2701 * code stays declared but unreached — slice-71/72 2702 * retain-for-documentation precedent): 2703 * <ul> 2704 * <li>Subquery as a FROM source — slice 83 admits via the 2705 * SELECT-side {@code processDirectSubqueryTable} extractor, 2706 * publishing a SUBQUERY-kind {@link RelationSource} and a 2707 * cross-statement {@link LineageEdge} per subquery-bound 2708 * output source.</li> 2709 * <li>USING in any FROM-side join item — 2710 * {@link DiagnosticCode#UPDATE_FROM_JOIN_USING_NOT_SUPPORTED}.</li> 2711 * <li>NATURAL JOIN in any FROM-side join item — 2712 * {@link DiagnosticCode#UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED}.</li> 2713 * <li>Subquery in any ON condition — 2714 * {@link DiagnosticCode#UPDATE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED}.</li> 2715 * <li>Window function in any ON condition — reuses 2716 * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} via 2717 * {@link #rejectWindowFunctionInScope}.</li> 2718 * </ul> 2719 * 2720 * <p>Deferred (rejected at the outer level before any SET 2721 * processing): 2722 * <ul> 2723 * <li>Top-level WITH on UPDATE → 2724 * {@link DiagnosticCode#UPDATE_CTE_NOT_SUPPORTED}.</li> 2725 * <li>RETURNING projection (PG / Oracle) → 2726 * {@link DiagnosticCode#UPDATE_RETURNING_CLAUSE_NOT_SUPPORTED}.</li> 2727 * <li>OUTPUT projection (SQL Server) → 2728 * {@link DiagnosticCode#UPDATE_OUTPUT_CLAUSE_NOT_SUPPORTED}.</li> 2729 * <li>ORDER BY / LIMIT on UPDATE (MySQL / Couchbase) → 2730 * {@link DiagnosticCode#UPDATE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED}.</li> 2731 * <li>Empty / missing SET clause, Couchbase UNSET-only updates → 2732 * {@link DiagnosticCode#UPDATE_NO_SET_CLAUSE}.</li> 2733 * <li>Missing target table (defensive) → 2734 * {@link DiagnosticCode#UPDATE_TARGET_MISSING}.</li> 2735 * </ul> 2736 * 2737 * <p>Cross-statement {@link LineageEdge}s, one per SET assignment: 2738 * <pre> 2739 * from = LineageRef.tableColumn(targetQName, target_col_i) 2740 * to = LineageRef.statementOutput(0, output_name_i) 2741 * </pre> 2742 * Statement index 0 is the UPDATE statement itself — the synthetic 2743 * output IS the per-assignment "projection" that flows into the 2744 * target column. This is the slice-78 INSERT contract 2745 * (TABLE_COLUMN → STATEMENT_OUTPUT) with the source SELECT replaced 2746 * by the UPDATE's own per-assignment outputs; consumers read 2747 * {@code outputs[i].sources} to enumerate the RHS column refs that 2748 * feed the target column. 2749 */ 2750 /** 2751 * Build under caller-supplied {@code options}; see 2752 * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}. 2753 */ 2754 public static SemanticBuildResult buildUpdateResult( 2755 TUpdateSqlStatement update, NameBindingProvider provider, 2756 SemanticIRBuildOptions options) { 2757 return executeBuild(options, () -> buildUpdateImpl(update, provider)); 2758 } 2759 2760 /** Build an UPDATE and atomically return its program and diagnostics. */ 2761 public static SemanticBuildResult buildUpdateResult( 2762 TUpdateSqlStatement update, NameBindingProvider provider) { 2763 return buildUpdateResult(update, provider, 2764 SemanticIRBuildOptions.defaults()); 2765 } 2766 2767 @Deprecated 2768 public static SemanticProgram buildUpdate(TUpdateSqlStatement update, 2769 NameBindingProvider provider, 2770 SemanticIRBuildOptions options) { 2771 clearBuildDiagnostics(); 2772 return publishLegacyResult(buildUpdateResult(update, provider, options)); 2773 } 2774 2775 @Deprecated 2776 public static SemanticProgram buildUpdate(TUpdateSqlStatement update, 2777 NameBindingProvider provider) { 2778 clearBuildDiagnostics(); 2779 return publishLegacyResult(buildUpdateResult(update, provider)); 2780 } 2781 2782 private static SemanticProgram buildUpdateImpl(TUpdateSqlStatement update, 2783 NameBindingProvider provider) { 2784 if (update == null) { 2785 throw new IllegalArgumentException("update must not be null"); 2786 } 2787 if (provider == null) { 2788 throw new IllegalArgumentException("provider must not be null"); 2789 } 2790 2791 // Slice 86 — defensive UsingScope reset at entry so a parent 2792 // scope cannot leak into UPDATE's binding decisions. Mirrors 2793 // SELECT-side buildSelectStatementImpl (slice 65). The UPDATE's 2794 // own UsingScope is installed after the FROM-join walker (step 2795 // 5.8 below). 2796 provider = provider.withUsingScope(UsingScope.EMPTY); 2797 2798 // 1) Slice 105 — admit top-level WITH on UPDATE. Walks the CTE 2799 // list left-to-right, building each body as a preceding 2800 // StatementGraph and producing cteNameToStatementIndex + 2801 // ctePublishedColumns for the FROM-as-CTE branch in 2802 // buildUpdateRelation below. Mirrors the slice-101 MERGE walker. 2803 // `stmts` / `lineage` allocated here (hoisted from the prior 2804 // slice-83 location) so the CTE walker can append. 2805 // UPDATE_CTE_NOT_SUPPORTED stays declared-but-unreached 2806 // (slice 71/72/82/86/95/96/97/98/99/100/101/102/103/104 precedent). 2807 List<StatementGraph> stmts = new ArrayList<>(); 2808 List<LineageEdge> lineage = new ArrayList<>(); 2809 Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>(); 2810 Map<String, Integer> cteNameToStatementIndex = buildUpdateCteList( 2811 update, provider, stmts, lineage, ctePublishedColumns); 2812 2813 // 2) Target table — defensive (parser usually rejects first). 2814 TTable targetTable = update.getTargetTable(); 2815 if (targetTable == null || targetTable.getTableName() == null) { 2816 throw new SemanticIRBuildException(Diagnostic.error( 2817 DiagnosticCode.UPDATE_TARGET_MISSING, 2818 "UPDATE statement has no resolvable target table", 2819 update)); 2820 } 2821 String targetQName = targetTable.getTableName().toString(); 2822 if (targetQName == null || targetQName.isEmpty()) { 2823 throw new SemanticIRBuildException(Diagnostic.error( 2824 DiagnosticCode.UPDATE_TARGET_MISSING, 2825 "UPDATE target table name is empty", 2826 update)); 2827 } 2828 2829 // 3) Slice 82 — FROM-side joined UPDATE is now admitted. The 2830 // slice-80 UPDATE_JOINED_NOT_SUPPORTED rejects (which fired on 2831 // update.tables.size() > 1 and update.getFromSourceJoin() != null) 2832 // are removed. The shape-specific rejects below (subquery in 2833 // FROM, USING, NATURAL, subquery in ON, window in ON) replace 2834 // them. UPDATE_JOINED_NOT_SUPPORTED remains declared but 2835 // unreached for API stability (the residual join-form-target 2836 // shape `UPDATE (a JOIN b) SET ...` does not parse in any 2837 // supported dialect — verified by AST probe). 2838 // 2839 // Reject ordering within buildUpdate: WITH / target-missing / 2840 // RETURNING / OUTPUT / ORDER BY / LIMIT / SET-empty all run 2841 // before the per-source FROM walk so a single rejection wins 2842 // on multi-violation shapes (e.g. `UPDATE t ... FROM s 2843 // RETURNING ...` rejects RETURNING before the FROM walk). 2844 2845 // 4) Slice 85 lifts the RETURNING / OUTPUT rejects — projections 2846 // are now admitted via {@link #buildReturningColumns} called after 2847 // SET / WHERE / FROM walks complete (the projection expressions 2848 // need the providerWithStar binding constructed in step 5.5). 2849 // The cheap statement-level OUTPUT_INTO reject fires here so a 2850 // multi-violation shape (OUTPUT … INTO target with RETURNING 2851 // content errors) routes to the cheaper structural code first. 2852 // {@code UPDATE_RETURNING_CLAUSE_NOT_SUPPORTED} and 2853 // {@code UPDATE_OUTPUT_CLAUSE_NOT_SUPPORTED} stay declared but 2854 // unreached (slice 71/72 retain-for-documentation precedent). 2855 if (update.getOutputClause() != null 2856 && update.getOutputClause().getIntoTable() != null) { 2857 throw new SemanticIRBuildException(Diagnostic.error( 2858 DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED, 2859 "UPDATE OUTPUT ... INTO <target> writes a second target; " 2860 + "slice 85 admits projection-only OUTPUT", 2861 update)); 2862 } 2863 if (update.getOrderByClause() != null 2864 || update.getLimitClause() != null) { 2865 throw new SemanticIRBuildException(Diagnostic.error( 2866 DiagnosticCode.UPDATE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED, 2867 "UPDATE with ORDER BY / LIMIT (MySQL / Couchbase) is " 2868 + "not supported by SemanticIRBuilder.buildUpdate; " 2869 + "slice 80 admits no row-pruning on UPDATE", 2870 update)); 2871 } 2872 2873 // 5) SET / UNSET — slice 80 requires a non-empty SET clause; a 2874 // Couchbase UNSET-only update (UnSetTerms populated, SET empty) 2875 // routes through the same code with discriminating message text. 2876 TResultColumnList sets = update.getResultColumnList(); 2877 boolean hasUnSet = update.getUnSetTerms() != null 2878 && update.getUnSetTerms().size() > 0; 2879 if (sets == null || sets.size() == 0) { 2880 String reason = hasUnSet 2881 ? "UPDATE has only an UNSET clause (Couchbase); slice 80 " 2882 + "requires a non-empty SET clause" 2883 : "UPDATE has no SET clause"; 2884 throw new SemanticIRBuildException(Diagnostic.error( 2885 DiagnosticCode.UPDATE_NO_SET_CLAUSE, 2886 reason, 2887 update)); 2888 } 2889 2890 // 5.5) Slice 83 — extract FROM subqueries as their own 2891 // StatementGraphs (after slice 105's CTE walker so the CTE 2892 // bodies precede any extracted FROM-subquery in the program). 2893 // 2894 // The extractor reuses the SELECT-side 2895 // {@link #processDirectSubqueryTable} verbatim — passing the 2896 // slice-105 cteNameToStatementIndex + ctePublishedColumns so a 2897 // nested SELECT inside a FROM-subquery can still resolve outer 2898 // CTE references through CTEScope (Resolver2 already binds CTE 2899 // refs in UPDATE correctly; the maps are passed for parity with 2900 // the SELECT/MERGE call sites). Inner predicate subqueries in 2901 // WHERE / JOIN ON / GROUP BY are caught by the slice-17 leak 2902 // guard ({@link #rejectSubqueriesInFromSubqueryBodyClauses}). 2903 // 2904 // No snapshot/rollback wrapper here (codex round-1 Q5 NICE): 2905 // buildUpdate owns fresh local stmts/lineage lists and 2906 // propagates exceptions to the caller — no observer can see 2907 // partial mutation. 2908 // 2909 // Slice 110 — decorate `provider` with `withCteContext` BEFORE 2910 // passing it to `extractUpdateFromSubqueries` so a nested SELECT 2911 // inside an extracted FROM-subquery body (e.g. 2912 // `UPDATE t SET col = sub.x FROM (SELECT id, x FROM cte) sub`) 2913 // routes CTE refs through `RelationKind.CTE`. Mirrors the 2914 // slice-106 DELETE-side `providerWithCte` pattern at line ~3205 2915 // (codex round-2 Q2 BLOCKING fix in slice 106). The slice-105 2916 // UPDATE site missed this decoration; slice 110 closes the gap 2917 // here since it also adds the same decoration on the WHERE-side 2918 // predicate-subquery extraction (line ~2370 below). 2919 NameBindingProvider providerWithCte = cteNameToStatementIndex.isEmpty() 2920 ? provider 2921 : provider.withCteContext(cteNameToStatementIndex.keySet()); 2922 Map<String, Integer> subqueryAliasToIndex = 2923 extractUpdateFromSubqueries(update, providerWithCte, stmts, lineage, 2924 cteNameToStatementIndex, ctePublishedColumns); 2925 // Build the in-scope map (subquery-alias → published column 2926 // names, plus CTE-bound alias → CTE published columns) so 2927 // `provider.withInScopeRelationColumns(map)` recognises 2928 // `sub.x` AND `cte.x` for the consuming UPDATE. Base-table 2929 // FROM-side relations don't need an entry; their column 2930 // resolution stays on the Resolver2 catalog path. 2931 Map<String, List<String>> updateInScope = buildUpdateInScopeMap( 2932 update, subqueryAliasToIndex, stmts, 2933 cteNameToStatementIndex, ctePublishedColumns); 2934 // Slice 110 — base `providerWithStar` on `providerWithCte` 2935 // (instead of raw `provider`) so SET RHS / WHERE / RETURNING 2936 // collectors and the slice-86 USING/NATURAL walker all see the 2937 // outer CTE context. Without this, a CTE-bound reference inside 2938 // a JOIN ON expression or a SET RHS scalar would bind as TABLE- 2939 // kind even when the CTE is declared at the UPDATE level. 2940 NameBindingProvider providerWithStar = updateInScope.isEmpty() 2941 ? providerWithCte 2942 : providerWithCte.withInScopeRelationColumns(updateInScope); 2943 2944 // 5.7) Slice 86 — relocated from slice-82 step 8. The FROM-side 2945 // join walker now runs BEFORE SET RHS / WHERE collection so the 2946 // slice-86 UsingScope (step 5.8 below) can be applied to those 2947 // collectors. Slice 65 SELECT-side ordering: buildRelations → 2948 // buildUsingScope → buildOutputColumns / buildFilter / etc. 2949 // The join walker uses `providerWithStar` (inScope only — no 2950 // UsingScope yet) because USING/NATURAL emit joinColumnRefs[] 2951 // directly via emitMergedJoinRefs without consulting UsingScope. 2952 // 2953 // The walker treats `update.getJoins()` as the authoritative 2954 // FROM-list representation: 2955 // - PG plain `FROM s` → joins=[{table=s, items=[]}] 2956 // - PG comma-FROM → joins=[{s1, items=[]}, {s2, items=[]}, ...] 2957 // - PG / MSSQL explicit JOIN → joins=[{driver, items=[item1,...]}] 2958 // - MSSQL target-in-FROM → joins=[{target_alias, items=[other,...]}] 2959 // 2960 // For each TJoin: the driver table goes through buildUpdateRelation 2961 // (which applies the slice-82 FROM-source rejects + identity 2962 // filter); each JoinItem is walked through buildUpdateJoinItem 2963 // which (slice 86) admits USING / NATURAL via slice-64/65/66 2964 // shared helpers in addition to ON / CROSS. 2965 List<RelationSource> relations = new ArrayList<>(); 2966 // Slice 82 codex round-1 Q2 BLOCKING — LinkedHashSet dedup spans 2967 // the whole FROM so a column appearing in two ON clauses 2968 // produces one entry. Slice 86 USING/NATURAL emit refs also flow 2969 // through this dedup. 2970 java.util.LinkedHashSet<ColumnRef> joinRefsSet = 2971 new java.util.LinkedHashSet<>(); 2972 for (TJoin join : update.getJoins()) { 2973 TTable leftTable = join.getTable(); 2974 buildUpdateRelation(leftTable, targetTable, relations, update, 2975 cteNameToStatementIndex); 2976 TJoinItemList items = join.getJoinItems(); 2977 if (items == null) continue; 2978 // Slice 86 — per top-level TJoin LeftOutputState seeded 2979 // with providerWithStar (codex round-1 B2 BLOCKING: inScope 2980 // installed so extracted FROM-subquery drivers' published 2981 // columns are visible to lookupRelationColumnNames for 2982 // NATURAL inference). Reset between top-level TJoins so 2983 // comma-FROM groups stay independent (matches SELECT-side 2984 // buildRelations slice-66 behavior). 2985 LeftOutputState leftState = new LeftOutputState(); 2986 seedLeftOutput(leftState, leftTable, providerWithStar); 2987 for (int i = 0; i < items.size(); i++) { 2988 TJoinItem item = items.getJoinItem(i); 2989 // Slice 86 — extended buildUpdateJoinItem signature 2990 // threads the join context (topJoin / items / itemIndex) 2991 // and LeftOutputState to the USING/NATURAL admit paths 2992 // so they can call the SELECT-side slice-64/65/66 2993 // shared helpers verbatim. 2994 // Slice 105 — threads cteNameToStatementIndex so the 2995 // join walker's per-item buildUpdateRelation call can 2996 // route objectname-typed CTE references to a SUBQUERY- 2997 // kind RelationSource pointing at the CTE statement. 2998 buildUpdateJoinItem(join, items, i, targetTable, 2999 providerWithStar, relations, joinRefsSet, leftState, 3000 update, cteNameToStatementIndex); 3001 } 3002 } 3003 List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet); 3004 3005 // 5.8) Slice 86 — install the UPDATE's own UsingScope on 3006 // providerWithStar AFTER the join walker so SET RHS / WHERE / 3007 // RETURNING refs see merged-key resolution (mirrors SELECT-side 3008 // buildSelectStatementImpl slice 65 ordering). The join walker 3009 // itself emits joinColumnRefs via direct emit-refs helpers, so 3010 // UsingScope is irrelevant to ON refs (matches SELECT-side 3011 // contract). 3012 UsingScope updateUsingScope = buildUpdateUsingScope(update, providerWithStar); 3013 if (!updateUsingScope.isEmpty()) { 3014 providerWithStar = providerWithStar.withUsingScope(updateUsingScope); 3015 } 3016 3017 // 5.9) Slice 115 — extract uncorrelated scalar subqueries on SET 3018 // RHS as their own <scalar_subquery_<idx>> StatementGraphs 3019 // appended to `stmts` BEFORE the UPDATE statement. Mirrors slice 3020 // 11 SELECT-side scalar projection extraction. A SET assignment 3021 // whose RHS is exactly a top-level subquery_t admits as a scalar 3022 // SET RHS: the body is built via buildSelectStatement (with the 3023 // slice-11 scalar-body invariants: allowFromSubqueries=false, 3024 // allowScalarProjectionSubqueries=false, allowWindowProjection= 3025 // false), inner predicate-leak guards run, and the resulting 3026 // ScalarInfo (extracted body index + inner output name) is stored 3027 // for the per-assignment loop and lineage emission below. 3028 // 3029 // Correlated scalar subqueries (whose inner refs would resolve to 3030 // an outer alias such as the UPDATE target or a FROM-side 3031 // relation) STILL reject via the slice-11 promoter called with 3032 // EnclosingScope.empty() — the inner ref's alias does not match 3033 // any local relation and no enclosing scope is provided, so 3034 // promoteCorrelatedRefsToOuterReference throws 3035 // SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS. Lifting UPDATE-side 3036 // correlation is a follow-up slice (slice 14 SELECT analogue 3037 // extended to UPDATE). 3038 Map<Integer, List<ScalarInfo>> setRhsScalarInfo = 3039 extractScalarSubqueriesFromUpdateSetRhs(update, providerWithStar, 3040 stmts, lineage, cteNameToStatementIndex, 3041 subqueryAliasToIndex); 3042 3043 // 6) Per-assignment processing. Each TResultColumn carries an 3044 // assignment_t TExpression whose leftOperand is the SET LHS 3045 // (target column reference) and whose rightOperand is the value 3046 // expression. We collect: 3047 // - target column spelling → TargetRelation.columns[i] 3048 // - synthetic output name → outputs[i].name (verbatim LHS 3049 // spelling, mirrors slice-78 3050 // INSERT column-list contract) 3051 // - RHS source column refs → outputs[i].sources 3052 List<OutputColumn> outputs = new ArrayList<>(); 3053 List<String> targetColumnNames = new ArrayList<>(); 3054 for (int i = 0; i < sets.size(); i++) { 3055 TResultColumn rc = sets.getResultColumn(i); 3056 TExpression assignment = (rc == null) ? null : rc.getExpr(); 3057 // Defensive: per TUpdateSqlStatement's javadoc each SET term 3058 // is an assignment_t. If the parser produced something else 3059 // (no AST shape observed in the tested corpora) we still 3060 // route through TUPLE_ASSIGNMENT_NOT_SUPPORTED so an 3061 // unexpected shape surfaces a stable diagnostic. 3062 if (assignment == null 3063 || assignment.getExpressionType() != EExpressionType.assignment_t) { 3064 throw new SemanticIRBuildException(Diagnostic.error( 3065 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 3066 "UPDATE SET assignment #" + (i + 1) + " is not a " 3067 + "simple column-value assignment_t; slice 80 " 3068 + "admits target_col = expr assignments only", 3069 update)); 3070 } 3071 TExpression lhs = assignment.getLeftOperand(); 3072 TExpression rhs = assignment.getRightOperand(); 3073 if (lhs == null || rhs == null) { 3074 throw new SemanticIRBuildException(Diagnostic.error( 3075 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 3076 "UPDATE SET assignment #" + (i + 1) 3077 + " is missing an operand", 3078 update)); 3079 } 3080 // Tuple LHS (Oracle) - SET (a, b) = (SELECT c1, c2 FROM ...) 3081 // surfaces as list_t. Reject before any subquery-on-RHS 3082 // walk so the diagnostic clearly identifies the tuple shape. 3083 if (lhs.getExpressionType() == EExpressionType.list_t) { 3084 throw new SemanticIRBuildException(Diagnostic.error( 3085 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 3086 "UPDATE SET tuple assignment '(a, b) = ...' is not " 3087 + "supported by SemanticIRBuilder.buildUpdate; " 3088 + "slice 80 admits target_col = expr only", 3089 update)); 3090 } 3091 if (lhs.getExpressionType() != EExpressionType.simple_object_name_t) { 3092 throw new SemanticIRBuildException(Diagnostic.error( 3093 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 3094 "UPDATE SET assignment #" + (i + 1) + " LHS is " 3095 + "expressionType=" + lhs.getExpressionType() 3096 + "; slice 80 admits simple column references only", 3097 update)); 3098 } 3099 TObjectName targetCol = lhs.getObjectOperand(); 3100 if (targetCol == null) { 3101 throw new SemanticIRBuildException(Diagnostic.error( 3102 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 3103 "UPDATE SET assignment #" + (i + 1) + " LHS has no " 3104 + "TObjectName operand", 3105 update)); 3106 } 3107 String colSpelling = targetCol.toString(); 3108 3109 // Slice 115 — top-level subquery_t SET RHS already extracted 3110 // in step 5.9 as a <scalar_subquery_<idx>> StatementGraph. 3111 // OutputColumn carries empty sources; slice-115/119 cross-stmt 3112 // edge below wires the consumer to the extracted body. 3113 if (rhs.getExpressionType() == EExpressionType.subquery_t) { 3114 targetColumnNames.add(colSpelling); 3115 outputs.add(new OutputColumn(colSpelling, 3116 /*derived=*/ true, /*aggregate=*/ false, 3117 Collections.<ColumnRef>emptyList())); 3118 continue; 3119 } 3120 // Slice 119 — mixed-expression scalar subquery path: subquery 3121 // nested inside a compound RHS (e.g. `SET col = (SELECT...) + 1`). 3122 // The scalar(s) were already extracted in step 5.9; collect only 3123 // the non-subquery column refs by skipping extracted subq nodes. 3124 if (containsAnySubqueryExpression(rhs)) { 3125 List<TExpression> subqRootsList = 3126 collectNestedSubqueryExpressions(rhs); 3127 if (subqRootsList.isEmpty()) { 3128 // P2-1 codex-review: containsAnySubqueryExpression 3129 // returned true (via getSubQuery() != null) but no 3130 // subquery_t nodes were found by acceptChildren 3131 // traversal (e.g. EXISTS or non-scalar predicate 3132 // subquery). Preserve the original reject so lineage 3133 // is not silently dropped. 3134 throw new SemanticIRBuildException(Diagnostic.error( 3135 DiagnosticCode.UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED, 3136 "UPDATE SET assignment #" + (i + 1) + " right-hand " 3137 + "side contains a non-scalar subquery " 3138 + "(slice 119 admits only scalar subquery_t " 3139 + "inside compound expressions)", 3140 update)); 3141 } 3142 Set<TExpression> subqRoots = Collections.newSetFromMap( 3143 new IdentityHashMap<TExpression, Boolean>()); 3144 subqRoots.addAll(subqRootsList); 3145 // P2-2 codex-review: window functions in the non-subquery 3146 // part of a compound RHS are still illegal. Use the 3147 // skipping variant so scalar body contents are not scanned 3148 // (window functions inside a scalar SELECT are legitimate). 3149 rejectWindowFunctionInScopeSkipping(rhs, "UPDATE SET RHS", 3150 subqRoots); 3151 List<ColumnRef> sources = collectColumnRefsSkipping( 3152 rhs, providerWithStar, subqRoots); 3153 targetColumnNames.add(colSpelling); 3154 outputs.add(new OutputColumn(colSpelling, 3155 /*derived=*/ true, /*aggregate=*/ false, sources)); 3156 continue; 3157 } 3158 // Window function on RHS — reuse the existing scope reject. 3159 rejectWindowFunctionInScope(rhs, "UPDATE SET RHS"); 3160 3161 // Collect physical column refs from the RHS expression. 3162 List<ColumnRef> sources = collectColumnRefs(rhs, providerWithStar); 3163 boolean derived = 3164 rhs.getExpressionType() != EExpressionType.simple_object_name_t; 3165 targetColumnNames.add(colSpelling); 3166 outputs.add(new OutputColumn(colSpelling, 3167 derived, /*aggregate=*/ false, sources)); 3168 } 3169 3170 // 7) WHERE refs — slice 110 lifts the slice-80 blanket subquery 3171 // reject by routing uncorrelated predicate-subquery wrappers 3172 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 3173 // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction pipeline 3174 // refactored to take a PredicateClauseContext. Each extracted 3175 // wrapper lands as its own <predicate_subquery_<i>> StatementGraph 3176 // BEFORE the UPDATE statement (so updateIdx already accounts for 3177 // them via stmts.size() below). Remaining non-subquery refs flow 3178 // into filterColumnRefs via collectColumnRefsSkipping. SET-RHS 3179 // subqueries still reject (slice-110 scope excludes SET RHS). 3180 // Window functions in non-subquery subtrees still reject via 3181 // the existing rejectWindowFunctionInScopeSkipping helper. 3182 List<ColumnRef> filterRefs; 3183 TWhereClause where = update.getWhereClause(); 3184 if (where == null || where.getCondition() == null) { 3185 filterRefs = Collections.<ColumnRef>emptyList(); 3186 } else { 3187 Set<TExpression> extractedWhereRoots = 3188 Collections.<TExpression>emptySet(); 3189 if (containsAnySubquery(where)) { 3190 // Slice 110 — `providerWithStar` already carries 3191 // `withCteContext(cteNameToStatementIndex.keySet())` 3192 // (applied at the providerWithCte → providerWithStar 3193 // chain above) so the predicate body's inner SELECT's 3194 // `FROM cte` refs route through `RelationKind.CTE`. 3195 // Without that, emitLineageForStatement would lose the 3196 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge to the CTE 3197 // body. 3198 extractedWhereRoots = 3199 extractUncorrelatedPredicateSubqueriesFromClause( 3200 where.getCondition(), providerWithStar, 3201 stmts, lineage, cteNameToStatementIndex, 3202 PredicateClauseContext.UPDATE_WHERE); 3203 rejectAnyRemainingSubqueriesFromClause( 3204 where.getCondition(), extractedWhereRoots, 3205 PredicateClauseContext.UPDATE_WHERE); 3206 } 3207 rejectWindowFunctionInScopeSkipping(where, "WHERE clause", 3208 extractedWhereRoots); 3209 // Slice 83 — providerWithStar so WHERE refs against 3210 // extracted subquery aliases bind correctly. Slice 110 — 3211 // skip extracted predicate-subquery subtrees so inner refs 3212 // do not leak into outer filterColumnRefs (mirrors the 3213 // slice-23 JOIN-ON ref collector). 3214 filterRefs = collectColumnRefsSkipping(where, providerWithStar, 3215 extractedWhereRoots); 3216 } 3217 3218 // (Step 8 of slice 82 was relocated to step 5.7 by slice 86 so 3219 // the slice-86 UsingScope built at step 5.8 can apply to the 3220 // SET RHS / WHERE collectors above. The walker logic itself is 3221 // unchanged from slice 82's contract — only its position moved.) 3222 3223 RelationBinding targetBinding = new RelationBinding( 3224 RelationKind.TABLE, targetQName); 3225 TargetRelation target = new TargetRelation(targetBinding, targetColumnNames); 3226 3227 // Slice 85 — build RETURNING / OUTPUT projection columns BEFORE 3228 // the StatementGraph so the new returningColumns slot can be 3229 // populated. updateIdx is computed first (deterministic — the 3230 // DML's position is stmts.size() at the moment of the 3231 // upcoming stmts.add(updateStmt)). LineageEdges are emitted 3232 // here via the shared helper (consumer ← producer). 3233 int updateIdx = stmts.size(); 3234 // UPDATE target alias = effective alias from the target's 3235 // TTable (slice-82 / slice-83 use the same convention for 3236 // FROM-side reference identity). FROM-side relations is the 3237 // walked `relations[]` list already built above. 3238 String updateTargetAlias = effectiveAliasOf(targetTable); 3239 if (updateTargetAlias == null || updateTargetAlias.isEmpty()) { 3240 updateTargetAlias = targetQName; 3241 } 3242 // Slice 123 — hoist the slice-105 combined map (slice-83 3243 // subqueryAliasToIndex + slice-105 CTE-as-relation alias→cteIdx) 3244 // ABOVE buildReturningColumns so the slice-85 walker can promote a 3245 // RETURNING/OUTPUT ref to a SUBQUERY-kind FROM-side relation column 3246 // to a cross-stmt STATEMENT_OUTPUT → STATEMENT_OUTPUT edge. The same 3247 // map instance is reused below for emitUpdateSubquerySourceEdges 3248 // (slice 105), so the hoist is behaviour-preserving for that call. 3249 Map<String, Integer> combinedAliasToSubIdx = 3250 buildUpdateCombinedAliasToSubIdx(update, 3251 subqueryAliasToIndex, cteNameToStatementIndex); 3252 List<OutputColumn> returningColumns = buildReturningColumns( 3253 update.getReturningClause(), 3254 update.getOutputClause(), 3255 "UPDATE", 3256 targetQName, 3257 updateTargetAlias, 3258 /*targetTable=*/ targetTable, 3259 relations, 3260 combinedAliasToSubIdx, 3261 providerWithStar, 3262 updateIdx, 3263 lineage, 3264 update); 3265 3266 StatementGraph updateStmt = new StatementGraph( 3267 /*name=*/ null, 3268 "UPDATE", 3269 relations, 3270 outputs, 3271 returningColumns, 3272 filterRefs, 3273 /*joinColumnRefs=*/ joinRefs, 3274 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 3275 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 3276 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 3277 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 3278 /*distinct=*/ false, 3279 /*setOperator=*/ null, 3280 /*rowLimit=*/ null, 3281 target); 3282 3283 // Slice 83 — updateIdx is dynamic: stmts already contains any 3284 // extracted FROM-subquery statements from step 5.5. The 3285 // slice-78/80 contract `target.col_i ← STATEMENT_OUTPUT(idx, 3286 // out_i)` is preserved by indexing the UPDATE's own statement 3287 // position rather than the slice-80 hardcoded 0. 3288 stmts.add(updateStmt); 3289 3290 // Slice 78/80 cross-stmt edges — one per SET assignment: 3291 // target.col_i ← STATEMENT_OUTPUT(updateIdx, out_i) 3292 for (int i = 0; i < outputs.size(); i++) { 3293 String tgtName = targetColumnNames.get(i); 3294 String outName = outputs.get(i).getName(); 3295 if (tgtName == null || tgtName.isEmpty() 3296 || outName == null || outName.isEmpty()) { 3297 // Defensive — both should be the same verbatim spelling. 3298 continue; 3299 } 3300 lineage.add(new LineageEdge( 3301 LineageRef.tableColumn(targetQName, tgtName), 3302 LineageRef.statementOutput(updateIdx, outName))); 3303 } 3304 3305 // Slice 115 — for each SET assignment whose RHS was extracted as 3306 // a top-level scalar subquery in step 5.9, emit the cross-stmt 3307 // wire edge: 3308 // STATEMENT_OUTPUT(updateIdx, outName) → 3309 // STATEMENT_OUTPUT(scalarIdx, innerOutputName) 3310 // mirrors the SELECT-side slice-11 emission in 3311 // emitLineageForStatement (line ~7440). Runs AFTER the slice-78/ 3312 // 80 target edge loop so the target edge is always emitted 3313 // first; the scalar-bound assignment's OutputColumn.sources is 3314 // empty by construction so the slice-83 3315 // emitUpdateSubquerySourceEdges call below is a no-op for these 3316 // outputs. 3317 // Slice 115/119 — one edge per extracted scalar per SET assignment: 3318 // STATEMENT_OUTPUT(updateIdx, outName) → STATEMENT_OUTPUT(scalarIdx, innerOutputName) 3319 if (!setRhsScalarInfo.isEmpty()) { 3320 for (Map.Entry<Integer, List<ScalarInfo>> e : setRhsScalarInfo.entrySet()) { 3321 int ord = e.getKey(); 3322 if (ord < 0 || ord >= outputs.size()) continue; 3323 String outName = outputs.get(ord).getName(); 3324 if (outName == null || outName.isEmpty()) continue; 3325 for (ScalarInfo info : e.getValue()) { 3326 lineage.add(new LineageEdge( 3327 LineageRef.statementOutput(updateIdx, outName), 3328 LineageRef.statementOutput(info.statementIndex, 3329 info.innerOutputName))); 3330 } 3331 } 3332 } 3333 3334 // Slice 83 — emit STATEMENT_OUTPUT(updateIdx, out_i) → 3335 // STATEMENT_OUTPUT(subIdx, col) edges for output sources that 3336 // bind to a SUBQUERY-kind relation in this UPDATE's 3337 // relations[]. Base-table FROM-side sources stay as 3338 // outputs[i].sources only — preserves the slice-82 contract 3339 // that joined UPDATE without subqueries emits exactly ONE 3340 // cross-stmt edge per SET assignment (the target edge above). 3341 // Slice 105 — combine the slice-83 subqueryAliasToIndex with 3342 // the slice-105 CTE-as-relation alias→cteIdx entries so a SET 3343 // RHS reference to a CTE column (which lives on a SUBQUERY- 3344 // kind relation per slice 105) still produces a cross-stmt 3345 // STATEMENT_OUTPUT edge to the CTE body. Without the merge the 3346 // visible OutputColumn.sources stays correct but lineage[] 3347 // silently drops the canonical edge (codex round-2 Q5). 3348 // Slice 123 — combinedAliasToSubIdx is now built above (hoisted 3349 // for buildReturningColumns); reuse it here. 3350 if (!combinedAliasToSubIdx.isEmpty()) { 3351 emitUpdateSubquerySourceEdges(updateStmt, updateIdx, 3352 combinedAliasToSubIdx, lineage); 3353 } 3354 3355 return new SemanticProgram(stmts, lineage); 3356 } 3357 3358 /** 3359 * Slice 83 — emit STATEMENT_OUTPUT → STATEMENT_OUTPUT edges from 3360 * each UPDATE output to its subquery-bound source column. Walks 3361 * {@code outputs[i].sources} and, for any source whose 3362 * {@code relationAlias} matches a SUBQUERY-kind entry in the 3363 * statement's {@link RelationSource} list, emits an edge to the 3364 * corresponding extracted subquery's STATEMENT_OUTPUT position. 3365 * 3366 * <p>Why not call {@link #emitLineageForStatement}? The SELECT-path 3367 * helper emits edges for ALL output sources (TABLE-kind → 3368 * TABLE_COLUMN; CTE/SUBQUERY-kind → STATEMENT_OUTPUT). For UPDATE 3369 * the slice-78/80 contract is intentionally narrower: the only 3370 * cross-stmt edge per SET assignment is the target edge. Adding 3371 * STATEMENT_OUTPUT → TABLE_COLUMN edges for base-table FROM-side 3372 * sources would change the cross-stmt edge count contract that 3373 * slice-82 tests assert ({@code edges.size() == numAssignments}). 3374 * The slice-83 emitter therefore is SUBQUERY-only — base-table 3375 * FROM-side refs continue to surface via {@code outputs[i].sources} 3376 * but emit no extra LineageEdge. 3377 */ 3378 private static void emitUpdateSubquerySourceEdges( 3379 StatementGraph updateStmt, 3380 int updateIdx, 3381 Map<String, Integer> subqueryAliasToIndex, 3382 List<LineageEdge> lineage) { 3383 // Codex slice-83 diff-review Q1 BLOCKING — both the map and 3384 // the lookup must use the same casing policy so SQL like 3385 // `... FROM (SELECT ...) sub WHERE ... SUB.x = …` (resolver-2 3386 // may surface either case in `src.getRelationAlias()` depending 3387 // on dialect and quoting) still finds the SUBQUERY-kind entry. 3388 // The slice-83 inScope map and `subqueryAliasToIndex` are both 3389 // keyed lowercase; do the same here. (SELECT-side 3390 // `emitLineageForStatement` uses case-sensitive equality — 3391 // pre-existing limitation; a separate refactor.) 3392 Map<String, RelationSource> aliasToRelation = new HashMap<>(); 3393 for (RelationSource rs : updateStmt.getRelations()) { 3394 String key = rs.getAlias(); 3395 // Skip null / empty aliases — empty-string would produce a 3396 // vacuous "" key and could spuriously match other empty-alias 3397 // relations (codex round-2 Q2 advisory). 3398 if (key == null || key.isEmpty()) continue; 3399 aliasToRelation.put(key.toLowerCase(Locale.ROOT), rs); 3400 } 3401 for (OutputColumn out : updateStmt.getOutputColumns()) { 3402 String outName = out.getName(); 3403 if (outName == null || outName.isEmpty()) continue; 3404 for (ColumnRef src : out.getSources()) { 3405 String srcAlias = src.getRelationAlias(); 3406 if (srcAlias == null || srcAlias.isEmpty()) continue; 3407 RelationSource rel = aliasToRelation.get( 3408 srcAlias.toLowerCase(Locale.ROOT)); 3409 if (rel == null) continue; 3410 if (rel.getBinding() == null 3411 || rel.getBinding().getKind() != RelationKind.SUBQUERY) { 3412 continue; 3413 } 3414 Integer subIdx = subqueryAliasToIndex.get( 3415 rel.getAlias().toLowerCase(Locale.ROOT)); 3416 if (subIdx == null) continue; 3417 lineage.add(new LineageEdge( 3418 LineageRef.statementOutput(updateIdx, outName), 3419 LineageRef.statementOutput(subIdx, src.getColumnName()))); 3420 } 3421 } 3422 } 3423 3424 /** 3425 * Slice 115 — walk the UPDATE's SET clause and extract each 3426 * assignment whose RHS is exactly a top-level 3427 * {@link EExpressionType#subquery_t} as its own 3428 * {@code <scalar_subquery_<idx>>} {@link StatementGraph} appended to 3429 * {@code stmts} BEFORE the UPDATE. Mirrors the SELECT-side 3430 * {@link #extractScalarSubqueriesAsStatementsInternal} slice-11 3431 * pipeline but iterates SET assignments instead of result columns. 3432 * Returns {@code assignmentOrdinal → ScalarInfo} so {@link #buildUpdate} 3433 * can wire the cross-stmt edge for each extracted body. 3434 * 3435 * <p>Scope rejects (mirroring slice 11): 3436 * <ul> 3437 * <li>Multi-column inner SELECT — 3438 * {@link DiagnosticCode#SCALAR_SUBQUERY_COLUMN_COUNT}.</li> 3439 * <li>Inner projection has no alias and no column name — 3440 * {@link DiagnosticCode#SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED}.</li> 3441 * <li>Subqueries in scalar body's WHERE / JOIN ON / GROUP BY — 3442 * slice-11 {@link #rejectSubqueriesInScalarBodyClauses}.</li> 3443 * <li>FROM-subqueries inside scalar body — 3444 * {@code allowFromSubqueries=false} (slice-15 invariant).</li> 3445 * <li>Nested scalar projections inside scalar body — 3446 * {@code allowScalarProjectionSubqueries=false} (set-op-branch 3447 * precedent; slice 115 initial scope).</li> 3448 * <li>Window functions in scalar body — 3449 * {@code allowWindowProjection=false} (slice-11 precedent).</li> 3450 * <li>Correlated scalar subqueries (inner refs to outer aliases) — 3451 * {@link #promoteCorrelatedRefsToOuterReference} called with 3452 * {@link EnclosingScope#empty()} throws 3453 * {@link DiagnosticCode#SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS}. 3454 * Lifting UPDATE-side correlation is a follow-up slice 3455 * (slice 14 SELECT analogue extended to UPDATE).</li> 3456 * </ul> 3457 * 3458 * <p>Snapshot/rollback wrapper around the loop body mirrors 3459 * {@link #extractScalarSubqueriesAsStatements} so a partial 3460 * extraction (e.g. second of two scalar SET RHS fails on shape 3461 * validation) truncates {@code stmts}/{@code lineage} back to the 3462 * pre-call boundary. 3463 * 3464 * <p>Assignments whose RHS is not a top-level {@code subquery_t} are 3465 * silently skipped here; they fall through to the per-assignment 3466 * loop's existing slice-80 / slice-115 mixed-expression reject path. 3467 */ 3468 private static Map<Integer, List<ScalarInfo>> extractScalarSubqueriesFromUpdateSetRhs( 3469 TUpdateSqlStatement update, 3470 NameBindingProvider provider, 3471 List<StatementGraph> stmts, 3472 List<LineageEdge> lineage, 3473 Map<String, Integer> cteNameToStatementIndex, 3474 Map<String, Integer> subqueryAliasToIndex) { 3475 TResultColumnList sets = update.getResultColumnList(); 3476 if (sets == null || sets.size() == 0) { 3477 return Collections.<Integer, List<ScalarInfo>>emptyMap(); 3478 } 3479 // Fast pre-scan: any SET RHS that contains a subquery (top-level 3480 // subquery_t or nested inside a compound expression)? Avoids the 3481 // snapshot/rollback wrapper overhead when none are present. 3482 // Slice 115 handled top-level subquery_t only; slice 119 extends 3483 // to mixed-expression RHS (e.g. `SET col = (SELECT...) + 1`). 3484 // Tuple-LHS assignments (Oracle SET (a, b) = ...) are 3485 // intentionally skipped so the per-assignment loop's 3486 // UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED reject (slice 80 3487 // contract) wins. 3488 boolean anySubquery = false; 3489 for (int i = 0; i < sets.size(); i++) { 3490 TResultColumn rc = sets.getResultColumn(i); 3491 if (rc == null || rc.getExpr() == null) continue; 3492 TExpression assignment = rc.getExpr(); 3493 if (assignment.getExpressionType() != EExpressionType.assignment_t) { 3494 continue; 3495 } 3496 TExpression lhs = assignment.getLeftOperand(); 3497 if (lhs == null 3498 || lhs.getExpressionType() == EExpressionType.list_t) { 3499 continue; 3500 } 3501 TExpression rhs = assignment.getRightOperand(); 3502 if (rhs != null && containsAnySubqueryExpression(rhs)) { 3503 anySubquery = true; 3504 break; 3505 } 3506 } 3507 if (!anySubquery) { 3508 return Collections.<Integer, List<ScalarInfo>>emptyMap(); 3509 } 3510 int stmtsSnapshot = stmts.size(); 3511 int lineageSnapshot = lineage.size(); 3512 try { 3513 return extractScalarSubqueriesFromUpdateSetRhsInternal( 3514 update, provider, stmts, lineage, 3515 cteNameToStatementIndex, subqueryAliasToIndex, sets); 3516 } catch (RuntimeException ex) { 3517 while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1); 3518 while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1); 3519 throw ex; 3520 } 3521 } 3522 3523 /** 3524 * Internal body of {@link #extractScalarSubqueriesFromUpdateSetRhs}; 3525 * wrapped with snapshot/rollback by the public entry point. Do not 3526 * call directly from non-wrapper sites. 3527 */ 3528 private static Map<Integer, List<ScalarInfo>> extractScalarSubqueriesFromUpdateSetRhsInternal( 3529 TUpdateSqlStatement update, 3530 NameBindingProvider provider, 3531 List<StatementGraph> stmts, 3532 List<LineageEdge> lineage, 3533 Map<String, Integer> cteNameToStatementIndex, 3534 Map<String, Integer> subqueryAliasToIndex, 3535 TResultColumnList sets) { 3536 Map<Integer, List<ScalarInfo>> ordinalToInfo = new HashMap<>(); 3537 for (int i = 0; i < sets.size(); i++) { 3538 TResultColumn rc = sets.getResultColumn(i); 3539 if (rc == null || rc.getExpr() == null) continue; 3540 TExpression assignment = rc.getExpr(); 3541 if (assignment.getExpressionType() != EExpressionType.assignment_t) { 3542 continue; 3543 } 3544 TExpression lhs = assignment.getLeftOperand(); 3545 // Skip tuple-LHS assignments — the per-assignment loop's 3546 // slice-80 UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED reject 3547 // should win for these (e.g. Oracle `SET (a, b) = (SELECT 3548 // c1, c2 FROM ...)`). Without this skip, the inner SELECT's 3549 // multi-column projection would surface as 3550 // SCALAR_SUBQUERY_COLUMN_COUNT here instead. 3551 if (lhs == null 3552 || lhs.getExpressionType() == EExpressionType.list_t) { 3553 continue; 3554 } 3555 TExpression rhs = assignment.getRightOperand(); 3556 if (rhs == null || !containsAnySubqueryExpression(rhs)) { 3557 continue; // no subquery in this RHS — handled by per-assignment loop 3558 } 3559 // "outer alias" used in diagnostic messages — the SET LHS 3560 // column spelling. Mirrors the slice-11 `outerAlias` role. 3561 String outerAlias = (lhs.getExpressionType() == EExpressionType.simple_object_name_t 3562 && lhs.getObjectOperand() != null) 3563 ? lhs.getObjectOperand().toString() 3564 : ("SET assignment #" + (i + 1)); 3565 3566 // Determine which subquery TExpression nodes to extract. 3567 // Slice 115 path: RHS is exactly a top-level subquery_t → 3568 // single-element list. 3569 // Slice 119 path: RHS is a compound expression (arithmetic, 3570 // CASE, function) containing one or more subquery_t nodes 3571 // at any depth → list in traversal order. 3572 List<TExpression> subqExprs; 3573 if (rhs.getExpressionType() == EExpressionType.subquery_t) { 3574 subqExprs = Collections.singletonList(rhs); 3575 } else { 3576 subqExprs = collectNestedSubqueryExpressions(rhs); 3577 } 3578 if (subqExprs.isEmpty()) continue; // defensive (containsAnySubqueryExpression true but none found) 3579 3580 // Build the UPDATE-side enclosing scope once per assignment 3581 // (used by each per-scalar correlation promotion below). 3582 EnclosingScope innerEnclosing = buildUpdateEnclosingScope(update, 3583 cteNameToStatementIndex, subqueryAliasToIndex, 3584 /*parent=*/ null); 3585 3586 List<ScalarInfo> infos = new ArrayList<>(); 3587 for (TExpression subqExpr : subqExprs) { 3588 TSelectSqlStatement inner = subqExpr.getSubQuery(); 3589 if (inner == null) { 3590 throw new SemanticIRBuildException( 3591 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_NO_INNER_SELECT, 3592 "scalar subquery on UPDATE SET RHS for '" + outerAlias 3593 + "' has no inner SELECT", rc)); 3594 } 3595 // Pre-recursion validation (matches slice 11 ordering): 3596 // inspect inner column count and naming before recursive 3597 // build so the diagnostic is scalar-specific. 3598 TResultColumnList innerRcl = inner.getResultColumnList(); 3599 if (innerRcl == null || innerRcl.size() == 0) { 3600 throw new SemanticIRBuildException( 3601 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT, 3602 "scalar subquery on UPDATE SET RHS for '" + outerAlias 3603 + "' must project exactly one column, got 0", rc)); 3604 } 3605 if (innerRcl.size() != 1) { 3606 throw new SemanticIRBuildException( 3607 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT, 3608 "scalar subquery on UPDATE SET RHS for '" + outerAlias 3609 + "' must project exactly one column, got " 3610 + innerRcl.size(), rc)); 3611 } 3612 TResultColumn innerCol = innerRcl.getResultColumn(0); 3613 String innerAlias = innerCol.getColumnAlias(); 3614 String innerColName = innerCol.getColumnNameOnly(); 3615 boolean innerHasName = 3616 (innerAlias != null && !innerAlias.isEmpty()) 3617 || (innerColName != null && !innerColName.isEmpty()); 3618 if (!innerHasName && !isConstantExpression(innerCol.getExpr())) { 3619 throw new SemanticIRBuildException( 3620 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED, 3621 "scalar subquery on UPDATE SET RHS for '" + outerAlias 3622 + "' inner projection has no alias and no column " 3623 + "name; add an explicit alias inside the subquery", 3624 rc)); 3625 } 3626 // Predicate-leak guard: scalar body's WHERE / JOIN ON / 3627 // GROUP BY must not contain subqueries. Slice 121: the 3628 // UPDATE SET-RHS scalar path keeps the WHERE reject 3629 // (allowWherePredicateSubqueries=false) — only the 3630 // projection scalar path lifts WHERE predicate subqueries. 3631 rejectSubqueriesInScalarBodyClauses(inner, outerAlias, 3632 /*allowWherePredicateSubqueries=*/ false); 3633 3634 // Slice 117 / 119 — decorate provider with the inner 3635 // SELECT's local FROM aliases for tolerant outer-binding. 3636 Set<String> innerLocalAliases = precomputeInnerLocalAliases(inner); 3637 NameBindingProvider tolerantProvider = innerLocalAliases.isEmpty() 3638 ? provider 3639 : provider.withTolerantOuterBinding(innerLocalAliases); 3640 3641 String scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">"; 3642 StatementGraph innerStmt = buildSelectStatement(inner, tolerantProvider, 3643 scalarName, 3644 /*hasOuterCteListAlreadyProcessed=*/ false, 3645 /*allowFromSubqueries=*/ false, 3646 /*allowScalarProjectionSubqueries=*/ false, 3647 /*allowWindowProjection=*/ false); 3648 innerStmt = promoteCorrelatedRefsToOuterReference( 3649 innerStmt, outerAlias, innerEnclosing); 3650 int idx = stmts.size(); 3651 stmts.add(innerStmt); 3652 String innerOutName = effectiveOutputName(innerCol); 3653 infos.add(new ScalarInfo(idx, innerOutName)); 3654 emitLineageForStatement(innerStmt, idx, lineage, 3655 cteNameToStatementIndex, 3656 innerEnclosing.flattenSubqueryAliasToIndex(), 3657 Collections.<Integer, ScalarInfo>emptyMap()); 3658 } 3659 ordinalToInfo.put(i, infos); 3660 } 3661 return ordinalToInfo; 3662 } 3663 3664 /** 3665 * Slice 82 — process one FROM-side source table for joined 3666 * {@link #buildUpdate}. Applies the slice-82 reject contract for 3667 * non-table FROM sources, then appends a TABLE-kind 3668 * {@link RelationSource} unless the table is the target 3669 * (reference-identity filter — clean IR semantics: relations[] 3670 * models read-side sources only). 3671 */ 3672 private static void buildUpdateRelation(TTable t, TTable targetTable, 3673 List<RelationSource> relations, 3674 TUpdateSqlStatement update, 3675 Map<String, Integer> cteNameToStatementIndex) { 3676 if (t == null) { 3677 return; // defensive — parser should never produce a null table 3678 } 3679 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 3680 // Slice 83 — admit FROM-side subqueries. The inner SELECT 3681 // has already been extracted as its own StatementGraph by 3682 // {@link #extractUpdateFromSubqueries} (step 5.5 of 3683 // buildUpdate). Here we publish the SUBQUERY-kind 3684 // {@link RelationSource} so {@code outputs[i].sources} 3685 // resolved via the inScope-enhanced provider can route to 3686 // it. Alias and qualifiedName both use 3687 // {@code effectiveAliasOf(t)} — matching slice-14 / slice-58 3688 // SUBQUERY-kind convention used by SELECT. 3689 // 3690 // {@code UPDATE_FROM_SUBQUERY_NOT_SUPPORTED} stays declared 3691 // but unreached (slice-71/72 retain-for-documentation 3692 // precedent — keeps the public DiagnosticCode enum stable 3693 // for consumers that route by code). 3694 String subAlias = effectiveAliasOf(t); 3695 if (subAlias != null && !subAlias.isEmpty()) { 3696 relations.add(new RelationSource(subAlias, 3697 new RelationBinding(RelationKind.SUBQUERY, subAlias))); 3698 } 3699 return; 3700 } 3701 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 3702 // Defensive: TTable wrapping a TJoin. Not reached by any 3703 // observed parser path on the supported dialects (slice-82 3704 // probe set), but gets its own DiagnosticCode so consumers 3705 // can route this distinct shape without parsing message 3706 // text — per slice-80's message-text-discrimination 3707 // contract (codex round-1 Q4 BLOCKING). 3708 throw new SemanticIRBuildException(Diagnostic.error( 3709 DiagnosticCode.UPDATE_FROM_NESTED_JOIN_NOT_SUPPORTED, 3710 "UPDATE FROM source is a nested join wrapper; " 3711 + "slice 82 admits simple table FROM sources only", 3712 update)); 3713 } 3714 // Reference-identity filter: target's own TTable instance is 3715 // excluded from relations[]. In MSSQL `UPDATE T2 ... FROM Table2 T2 …`, 3716 // tables[0] / joins[0].getTable() IS the same instance as 3717 // update.getTargetTable(); excluding it keeps the IR clean 3718 // (relations[] models reads, target models writes). The 3719 // catalog-miss WARN walker's target-first ordering handles the 3720 // cross-instance-same-name MSSQL self-join edge case where two 3721 // distinct TTable instances share the same qualified name. 3722 if (t == targetTable) { 3723 return; 3724 } 3725 TObjectName tName = t.getTableName(); 3726 if (tName == null) { 3727 return; // defensive 3728 } 3729 // Slice 105 — FROM-side CTE detection. When the FROM-side table 3730 // is an objectname-typed reference whose bare name matches a 3731 // declared CTE in this UPDATE's outer WITH clause, emit a 3732 // SUBQUERY-kind RelationSource pointing at the CTE statement 3733 // (mirrors MERGE USING-as-CTE in slice 101). The slice-77 3734 // catalog-miss WARN walker filters to RelationKind.TABLE so 3735 // CTE-bound relations are naturally skipped, even when the 3736 // catalog also declares the same name (codex round-2 Q4 3737 // confirmed YES). The cross-stmt lineage edge from 3738 // STATEMENT_OUTPUT(updateIdx,col) → STATEMENT_OUTPUT(cteIdx,col) 3739 // is emitted by emitUpdateSubquerySourceEdges using the 3740 // combined alias→subIdx map. 3741 if (cteNameToStatementIndex != null 3742 && !cteNameToStatementIndex.isEmpty()) { 3743 String bareName = tName.toString(); 3744 if (bareName != null && !bareName.isEmpty()) { 3745 String bareNameLower = bareName.toLowerCase(Locale.ROOT); 3746 if (cteNameToStatementIndex.containsKey(bareNameLower)) { 3747 String cteAlias = effectiveAliasOf(t); 3748 if (cteAlias == null || cteAlias.isEmpty()) { 3749 cteAlias = bareName; 3750 } 3751 relations.add(new RelationSource(cteAlias, 3752 new RelationBinding(RelationKind.SUBQUERY, cteAlias))); 3753 return; 3754 } 3755 } 3756 } 3757 // effectiveAliasOf returns the SQL-written alias if present, 3758 // else the table name. RelationSource requires a non-empty 3759 // alias; this matches the slice-58/59 buildRelation contract. 3760 relations.add(new RelationSource(effectiveAliasOf(t), 3761 new RelationBinding(RelationKind.TABLE, tName.toString()))); 3762 } 3763 3764 /** 3765 * Slice 82 (extended by slice 86) — process one {@link TJoinItem} 3766 * for joined {@link #buildUpdate}. Routes USING / NATURAL JoinItems 3767 * through the SELECT-side slice-64/65/66 shared helpers 3768 * ({@link #populateUsingJoinRefs} / {@link #emitMergedJoinRefs} / 3769 * {@link #naturalSharedKeys}) so the UPDATE join walker emits the 3770 * same {@code joinColumnRefs[]} shape as a SELECT body. ON / CROSS 3771 * JoinItems retain the slice-82 reject contract (subquery in ON, 3772 * window in ON) and ref-collection path. 3773 * 3774 * <p>Slice 86 signature extension: the join context 3775 * ({@code topJoin}, {@code items}, {@code itemIndex}) and the 3776 * per-top-level-TJoin {@link LeftOutputState} are required by the 3777 * shared helpers — the prior-relations chain for emit-refs and the 3778 * accumulated left row type for NATURAL inference. 3779 * 3780 * <p>USING / NATURAL shape conflicts (USING+ON, NATURAL+USING, 3781 * NATURAL+ON) reuse the existing slice-64/66 codes 3782 * ({@link DiagnosticCode#JOIN_WITH_BOTH_ON_AND_USING}, 3783 * {@link DiagnosticCode#NATURAL_WITH_USING}, 3784 * {@link DiagnosticCode#NATURAL_WITH_ON}) rather than introducing 3785 * UPDATE-specific codes — matching slice 86's "reuse SELECT-side 3786 * machinery verbatim" architecture. 3787 * 3788 * <p>Slice 82's lifted reject codes 3789 * ({@link DiagnosticCode#UPDATE_FROM_JOIN_USING_NOT_SUPPORTED} and 3790 * {@link DiagnosticCode#UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED}) 3791 * stay declared-but-unreached for API stability — slice 71/72/82 3792 * retain-for-documentation precedent. 3793 */ 3794 private static void buildUpdateJoinItem(TJoin topJoin, 3795 TJoinItemList items, 3796 int itemIndex, 3797 TTable targetTable, 3798 NameBindingProvider provider, 3799 List<RelationSource> relations, 3800 java.util.LinkedHashSet<ColumnRef> joinRefs, 3801 LeftOutputState leftState, 3802 TUpdateSqlStatement update, 3803 Map<String, Integer> cteNameToStatementIndex) { 3804 if (items == null) return; 3805 TJoinItem item = items.getJoinItem(itemIndex); 3806 if (item == null) return; 3807 3808 TObjectNameList usingCols = item.getUsingColumns(); 3809 boolean hasUsing = usingCols != null && usingCols.size() > 0; 3810 boolean isNatural = isNaturalJoinType(item.getJoinType()); 3811 boolean hasOn = item.getOnCondition() != null; 3812 3813 // Slice 86 — USING/NATURAL admit paths. Shape conflicts use the 3814 // slice-64/66 SELECT-side codes verbatim; the UPDATE-specific 3815 // lifted codes (UPDATE_FROM_JOIN_USING_NOT_SUPPORTED / 3816 // UPDATE_FROM_JOIN_NATURAL_NOT_SUPPORTED) are no longer thrown 3817 // (declared-but-unreached for API stability). 3818 if (isNatural && hasUsing) { 3819 throw new SemanticIRBuildException(Diagnostic.error( 3820 DiagnosticCode.NATURAL_WITH_USING, 3821 "NATURAL JOIN must not carry a USING clause; choose " 3822 + "either NATURAL or USING, not both", item)); 3823 } 3824 if (isNatural && hasOn) { 3825 throw new SemanticIRBuildException(Diagnostic.error( 3826 DiagnosticCode.NATURAL_WITH_ON, 3827 "NATURAL JOIN must not carry an ON condition; rewrite " 3828 + "as JOIN ... ON, or drop the NATURAL keyword", item)); 3829 } 3830 if (hasUsing && hasOn) { 3831 throw new SemanticIRBuildException(Diagnostic.error( 3832 DiagnosticCode.JOIN_WITH_BOTH_ON_AND_USING, 3833 "JOIN cannot carry both ON and USING; choose one", item)); 3834 } 3835 3836 if (hasUsing) { 3837 // Right-side table first: applies slice-82 source-shape 3838 // rejects + identity filter exactly as the ON path. 3839 buildUpdateRelation(item.getTable(), targetTable, relations, update, 3840 cteNameToStatementIndex); 3841 // Slice 64 emit-refs: left-then-right per key, walking 3842 // priorRelations = topJoin.getTable() + items[0..itemIndex-1]. 3843 List<ColumnRef> usingRefs = new ArrayList<>(); 3844 populateUsingJoinRefs(topJoin, items, itemIndex, item.getTable(), 3845 usingCols, provider, usingRefs); 3846 joinRefs.addAll(usingRefs); 3847 // Slice 66 LeftOutputState update: merge right's columns 3848 // into accumulated state so a subsequent NATURAL JoinItem 3849 // sees the row type (matches SELECT-side 3850 // {@code buildRelations}). 3851 List<String> usingKeyNames = new ArrayList<>(usingCols.size()); 3852 for (int k = 0; k < usingCols.size(); k++) { 3853 TObjectName key = usingCols.getObjectName(k); 3854 if (key == null) continue; 3855 String keyName = key.getColumnNameOnly(); 3856 if (keyName != null && !keyName.isEmpty()) { 3857 usingKeyNames.add(keyName); 3858 } 3859 } 3860 mergeRightIntoLeftOutput(leftState, item.getTable(), provider, 3861 usingKeyNames); 3862 return; 3863 } 3864 3865 if (isNatural) { 3866 // Right-side table first; identity filter excludes target. 3867 buildUpdateRelation(item.getTable(), targetTable, relations, update, 3868 cteNameToStatementIndex); 3869 // Slice 66 catalog-required NATURAL inference. When either 3870 // side lacks resolvable column metadata the shared-column list 3871 // cannot be computed; DEGRADE (mirrors the SELECT-side 3872 // buildRelations path) — skip the merged join refs, record a 3873 // non-fatal NATURAL_CATALOG_REQUIRED warning, and append the 3874 // right's columns to the running state for consistency. 3875 NaturalKeyResult r = naturalSharedKeys(leftState, item.getTable(), provider); 3876 if (r.kind != NaturalKeyResult.Kind.SUCCESS) { 3877 recordNaturalDegradeWarning(r, item); 3878 appendRightToLeftOutput(leftState, item.getTable(), provider); 3879 return; 3880 } 3881 List<ColumnRef> naturalRefs = new ArrayList<>(); 3882 emitMergedJoinRefs(JoinKind.NATURAL, r.keys, topJoin, items, 3883 itemIndex, item.getTable(), provider, naturalRefs); 3884 joinRefs.addAll(naturalRefs); 3885 // Update LeftOutputState with the right's columns (merging 3886 // shared keys into existing slots, appending non-shared 3887 // columns as new entries). 3888 mergeRightIntoLeftOutput(leftState, item.getTable(), provider, r.keys); 3889 return; 3890 } 3891 3892 // ON / CROSS branch — slice-82 contract preserved. 3893 buildUpdateRelation(item.getTable(), targetTable, relations, update, 3894 cteNameToStatementIndex); 3895 // Slice 86 — append right to LeftOutputState so subsequent 3896 // NATURAL JoinItems in the same top-level TJoin observe the 3897 // accumulated row type. CROSS / ON contribute non-merged 3898 // columns to state (matches SELECT-side appendRightToLeftOutput). 3899 appendRightToLeftOutput(leftState, item.getTable(), provider); 3900 TExpression onCond = item.getOnCondition(); 3901 if (onCond == null) return; // CROSS JOIN: no ON. 3902 if (containsAnySubqueryExpression(onCond)) { 3903 throw new SemanticIRBuildException(Diagnostic.error( 3904 DiagnosticCode.UPDATE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED, 3905 "UPDATE FROM JOIN ON condition contains a subquery; " 3906 + "slice 82 admits scalar predicates only", 3907 item)); 3908 } 3909 rejectWindowFunctionInScope(onCond, "UPDATE FROM JOIN ON"); 3910 joinRefs.addAll(collectColumnRefs(onCond, provider)); 3911 } 3912 3913 /** 3914 * Slice 83 — extract every FROM-side subquery in 3915 * {@code update.getJoins()} as its own {@link StatementGraph} 3916 * appended to {@code stmts} before the UPDATE itself. Walks both 3917 * the driver TTable of each TJoin AND each JoinItem's right table. 3918 * Returns an alias → stmts-index map so the consuming UPDATE can 3919 * (a) build its in-scope column map via 3920 * {@link #buildUpdateInScopeMap}, and (b) emit 3921 * STATEMENT_OUTPUT → STATEMENT_OUTPUT edges via 3922 * {@link #emitUpdateSubquerySourceEdges}. 3923 * 3924 * <p>Reuses the SELECT-side {@link #processDirectSubqueryTable} 3925 * verbatim, passing empty CTE maps because slice 80 already 3926 * rejects top-level WITH on UPDATE 3927 * ({@link DiagnosticCode#UPDATE_CTE_NOT_SUPPORTED}). The inner 3928 * SELECT's own FROM-subqueries are handled recursively by the 3929 * helper. Inner predicate subqueries in WHERE / JOIN ON / 3930 * GROUP BY are caught by the slice-17 leak guard 3931 * ({@link #rejectSubqueriesInFromSubqueryBodyClauses}). Inner 3932 * top-level WITH is rejected by 3933 * {@code buildSelectStatement(hasOuterCteListAlreadyProcessed=false)}. 3934 * Inner scalar projection subqueries are rejected by 3935 * {@code buildSelectStatement(allowScalarProjectionSubqueries=false)}. 3936 * 3937 * <p>No mutation-guard wrapper here: buildUpdate owns fresh local 3938 * lists and exceptions propagate to the caller (codex round-1 Q5 3939 * NICE). 3940 */ 3941 private static Map<String, Integer> extractUpdateFromSubqueries( 3942 TUpdateSqlStatement update, 3943 NameBindingProvider provider, 3944 List<StatementGraph> stmts, 3945 List<LineageEdge> lineage, 3946 Map<String, Integer> cteNameToStatementIndex, 3947 Map<String, List<String>> ctePublishedColumns) { 3948 Map<String, Integer> aliasToIndex = new HashMap<>(); 3949 TJoinList joins = update.getJoins(); 3950 if (joins == null) return aliasToIndex; 3951 // Slice 105 — forward the outer-WITH CTE maps so a nested SELECT 3952 // inside an extracted FROM-subquery body can resolve outer-WITH 3953 // CTE references. Resolver2 wires CTEScope already; the maps are 3954 // forwarded for parity with the SELECT / MERGE call sites. 3955 Map<String, Integer> cteMap = cteNameToStatementIndex == null 3956 ? Collections.<String, Integer>emptyMap() 3957 : cteNameToStatementIndex; 3958 Map<String, List<String>> ctePublished = ctePublishedColumns == null 3959 ? Collections.<String, List<String>>emptyMap() 3960 : ctePublishedColumns; 3961 for (TJoin join : joins) { 3962 // Driver table — may be a subquery (PG / Snowflake / BQ / 3963 // Redshift `UPDATE t SET … FROM (SELECT …) sub` shape). 3964 processDirectSubqueryTable(join.getTable(), provider, 3965 stmts, lineage, cteMap, ctePublished, aliasToIndex); 3966 TJoinItemList items = join.getJoinItems(); 3967 if (items == null) continue; 3968 for (int i = 0; i < items.size(); i++) { 3969 TJoinItem item = items.getJoinItem(i); 3970 if (item == null) continue; 3971 // Right-side table of a JoinItem — may be a subquery 3972 // (MSSQL / PG `UPDATE t SET … FROM x JOIN (SELECT …) 3973 // sub ON …` shape). 3974 processDirectSubqueryTable(item.getTable(), provider, 3975 stmts, lineage, cteMap, ctePublished, aliasToIndex); 3976 } 3977 } 3978 return aliasToIndex; 3979 } 3980 3981 /** 3982 * Slice 83 — build an effective-alias-keyed in-scope map publishing 3983 * each extracted FROM-subquery's output column names. The consuming 3984 * UPDATE wraps its provider via 3985 * {@code provider.withInScopeRelationColumns(map)} so {@code sub.x} 3986 * resolves to the subquery's published column rather than failing 3987 * resolution against the catalog. 3988 * 3989 * <p>Base-table FROM-side relations do not need an entry: their 3990 * column resolution stays on the Resolver2 catalog path. Slice 60's 3991 * SELECT-side {@link #buildEffectiveAliasInScopeMap} also skips 3992 * base-table relations. 3993 * 3994 * <p>Slice 105 — when an outer WITH clause declares a CTE and a 3995 * FROM-side relation references that CTE by its bare name, publish 3996 * the CTE's column names against the FROM-side effective alias so 3997 * SET RHS / WHERE / ON refs against the CTE alias bind correctly. 3998 */ 3999 private static Map<String, List<String>> buildUpdateInScopeMap( 4000 TUpdateSqlStatement update, 4001 Map<String, Integer> subqueryAliasToIndex, 4002 List<StatementGraph> stmts, 4003 Map<String, Integer> cteNameToStatementIndex, 4004 Map<String, List<String>> ctePublishedColumns) { 4005 Map<String, List<String>> result = new HashMap<>(); 4006 boolean haveSubq = subqueryAliasToIndex != null 4007 && !subqueryAliasToIndex.isEmpty(); 4008 boolean haveCte = cteNameToStatementIndex != null 4009 && !cteNameToStatementIndex.isEmpty(); 4010 if (!haveSubq && !haveCte) { 4011 return result; 4012 } 4013 TJoinList joins = update.getJoins(); 4014 if (joins == null) return result; 4015 for (TJoin join : joins) { 4016 addUpdateRelationToInScopeMap(join.getTable(), 4017 subqueryAliasToIndex, stmts, result, 4018 cteNameToStatementIndex, ctePublishedColumns); 4019 TJoinItemList items = join.getJoinItems(); 4020 if (items == null) continue; 4021 for (int i = 0; i < items.size(); i++) { 4022 TJoinItem item = items.getJoinItem(i); 4023 if (item == null) continue; 4024 addUpdateRelationToInScopeMap(item.getTable(), 4025 subqueryAliasToIndex, stmts, result, 4026 cteNameToStatementIndex, ctePublishedColumns); 4027 } 4028 } 4029 return result; 4030 } 4031 4032 private static void addUpdateRelationToInScopeMap(TTable t, 4033 Map<String, Integer> subqueryAliasToIndex, 4034 List<StatementGraph> stmts, 4035 Map<String, List<String>> result, 4036 Map<String, Integer> cteNameToStatementIndex, 4037 Map<String, List<String>> ctePublishedColumns) { 4038 if (t == null) return; 4039 // Slice 105 — CTE-as-FROM-relation in-scope publication. When 4040 // the FROM-side table is an objectname-typed reference whose 4041 // bare name matches a declared outer CTE, publish the CTE's 4042 // own column names against the FROM-side effective alias so 4043 // SET RHS / WHERE refs against the CTE alias bind correctly. 4044 if (cteNameToStatementIndex != null 4045 && !cteNameToStatementIndex.isEmpty() 4046 && ctePublishedColumns != null 4047 && t.getTableType() 4048 == gudusoft.gsqlparser.ETableSource.objectname) { 4049 TObjectName tName = t.getTableName(); 4050 if (tName != null) { 4051 String bare = tName.toString(); 4052 if (bare != null && !bare.isEmpty()) { 4053 String bareLower = bare.toLowerCase(Locale.ROOT); 4054 if (cteNameToStatementIndex.containsKey(bareLower)) { 4055 String aliasKey = effectiveAliasLowerCaseOrNull(t); 4056 if (aliasKey == null) aliasKey = bareLower; 4057 List<String> cols = ctePublishedColumns.get(bareLower); 4058 if (cols != null) { 4059 result.put(aliasKey, cols); 4060 } 4061 return; 4062 } 4063 } 4064 } 4065 } 4066 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) { 4067 return; 4068 } 4069 if (subqueryAliasToIndex == null) { 4070 return; 4071 } 4072 String key = effectiveAliasLowerCaseOrNull(t); 4073 if (key == null) return; 4074 Integer idx = subqueryAliasToIndex.get(key); 4075 if (idx == null) return; 4076 result.put(key, outputColumnNames(stmts.get(idx))); 4077 } 4078 4079 /** 4080 * Slice 81 / slice 84 — admit single-target and joined 4081 * {@code DELETE} statements and produce a {@code "DELETE"}-kind 4082 * {@link StatementGraph} (§8.1.4 row D11 follow-up via slice 84's 4083 * joined-DELETE candidate (a)). 4084 * 4085 * <p>Structurally mirrors slice-80 + slice-82 + slice-83 4086 * {@link #buildUpdate} but with no SET clause and an empty 4087 * {@code outputColumns} list — DELETE has no projection of its 4088 * own (RETURNING / OUTPUT projections are deferred to a later 4089 * slice). The target relation is exposed via the slice-78 4090 * {@link TargetRelation} slot; its {@code columns} list is 4091 * intentionally empty because DELETE removes whole rows rather 4092 * than writing specific columns. 4093 * 4094 * <p>WHERE-side reads still surface on 4095 * {@link StatementGraph#getFilterColumnRefs()} so downstream 4096 * governance can audit "what predicates does this DELETE depend 4097 * on". Cross-statement {@link LineageEdge}s are NOT emitted (the 4098 * slice-78 / slice-80 {@code target.col_i ← STATEMENT_OUTPUT(…)} 4099 * contract has no DELETE analogue: there is no source 4100 * projection). 4101 * 4102 * <p>Slice 84 admit scope (lifts slice-81's blanket joined-DELETE 4103 * reject for the common PG / MSSQL FROM-side shapes; mirrors 4104 * slice 82 + slice 83 onto DELETE): 4105 * <ul> 4106 * <li>PG / Snowflake / BQ / Redshift {@code DELETE FROM t USING 4107 * source_list [WHERE]} — {@code source_list} = simple table, 4108 * comma-separated tables, or chain of explicit JOIN ... ON 4109 * (driver is taken from {@code referenceJoins}).</li> 4110 * <li>MSSQL {@code DELETE FROM t FROM driver_table [JOIN other 4111 * ON ...] [WHERE]} — the target may itself appear in the 4112 * FROM-FROM clause as a different TTable instance.</li> 4113 * <li>MSSQL {@code DELETE alias FROM t alias INNER JOIN ... ON …} 4114 * — the alias-form DELETE where target is matched by alias.</li> 4115 * <li>CROSS JOIN inside USING — no ON; semantically equivalent 4116 * to comma-FROM.</li> 4117 * <li>{@code DELETE FROM t USING (SELECT …) s [WHERE]} — 4118 * FROM-subquery as a USING source; mirrors slice-83 UPDATE 4119 * FROM-subquery extraction.</li> 4120 * </ul> 4121 * 4122 * <p>Slice 84 reject scope (preserves slice-81 reject coverage 4123 * for shapes that still need a refinement slice): 4124 * <ul> 4125 * <li>{@link DiagnosticCode#DELETE_JOINED_NOT_SUPPORTED} — any 4126 * shape with {@code delete.getJoins().size() > 0}: MySQL 4127 * multi-target {@code DELETE T1, T2 FROM …}, MySQL 4128 * self-reference {@code DELETE T1 FROM T1}, MySQL 4129 * multi-USING {@code DELETE FROM T1 USING T1, T2}. 4130 * Candidates (c) and (d) in §8.1.4 lift these later.</li> 4131 * <li>{@link DiagnosticCode#DELETE_FROM_JOIN_USING_NOT_SUPPORTED} 4132 * — {@code USING(col1, col2)} on a FROM-side join item; 4133 * mirror of slice-82 {@code UPDATE_FROM_JOIN_USING_*}.</li> 4134 * <li>{@link DiagnosticCode#DELETE_FROM_JOIN_NATURAL_NOT_SUPPORTED} 4135 * — {@code NATURAL JOIN} on a FROM-side join item.</li> 4136 * <li>{@link DiagnosticCode#DELETE_FROM_NESTED_JOIN_NOT_SUPPORTED} 4137 * — defensive: TTable wrapping a TJoin in the FROM source 4138 * (not reached by any observed parser path on supported 4139 * dialects, but kept distinct from the subquery code per 4140 * slice-80 message-text-discrimination contract).</li> 4141 * <li>{@link DiagnosticCode#DELETE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED} 4142 * — subquery in a JOIN ON predicate.</li> 4143 * </ul> 4144 * 4145 * <p>Other rejected shapes (slice-81 baseline preserved): 4146 * {@link DiagnosticCode#DELETE_CTE_NOT_SUPPORTED}, 4147 * {@link DiagnosticCode#DELETE_TARGET_MISSING}, 4148 * {@link DiagnosticCode#DELETE_RETURNING_CLAUSE_NOT_SUPPORTED}, 4149 * {@link DiagnosticCode#DELETE_OUTPUT_CLAUSE_NOT_SUPPORTED}, 4150 * {@link DiagnosticCode#DELETE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED}. 4151 * 4152 * <p>WHERE-side subqueries reuse the existing 4153 * {@link DiagnosticCode#WHERE_HAS_SUBQUERY_NOT_SUPPORTED} (no 4154 * new DELETE-side code) — consistent with slice-80 UPDATE WHERE 4155 * handling. Window functions in WHERE / ON reuse 4156 * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK}. 4157 * 4158 * <p>IR shape (slice 84 changes from slice 81): 4159 * <ul> 4160 * <li>{@code relations[]} — now carries TABLE-kind 4161 * {@link RelationSource}s for joined-DELETE FROM-side 4162 * sources, plus SUBQUERY-kind sources for {@code USING 4163 * (SELECT …)} extractions. Slice 81 left it empty. 4164 * Reference-identity filter excludes the target's own 4165 * TTable instance; the slice-82 walker-order swap (target 4166 * before relations[] in 4167 * {@link gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer#collectCatalogMissWarnings}) 4168 * handles same-qualified-name target+driver collisions 4169 * (e.g. MSSQL {@code DELETE FROM t FROM t spqh JOIN sp}).</li> 4170 * <li>{@code joinColumnRefs[]} — now carries ON-clause refs 4171 * collected from each JoinItem under a per-DELETE 4172 * {@link java.util.LinkedHashSet} for cross-JoinItem dedup 4173 * (slice-82 codex round-1 Q2 BLOCKING precedent).</li> 4174 * <li>The DELETE itself emits NO new cross-stmt 4175 * {@link LineageEdge}s — empty {@code outputColumns[]} 4176 * means there is no STATEMENT_OUTPUT(deleteIdx, …) anchor 4177 * for slice-83's SUBQUERY-kind emitter. Extracted 4178 * FROM-subqueries DO emit their own internal lineage edges 4179 * via {@code emitLineageForStatement} inside 4180 * {@link #processDirectSubqueryTable}.</li> 4181 * </ul> 4182 */ 4183 /** 4184 * Build under caller-supplied {@code options}; see 4185 * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}. 4186 */ 4187 public static SemanticBuildResult buildDeleteResult( 4188 TDeleteSqlStatement delete, NameBindingProvider provider, 4189 SemanticIRBuildOptions options) { 4190 return executeBuild(options, () -> buildDeleteImpl(delete, provider)); 4191 } 4192 4193 /** Build a DELETE and atomically return its program and diagnostics. */ 4194 public static SemanticBuildResult buildDeleteResult( 4195 TDeleteSqlStatement delete, NameBindingProvider provider) { 4196 return buildDeleteResult(delete, provider, 4197 SemanticIRBuildOptions.defaults()); 4198 } 4199 4200 @Deprecated 4201 public static SemanticProgram buildDelete(TDeleteSqlStatement delete, 4202 NameBindingProvider provider, 4203 SemanticIRBuildOptions options) { 4204 clearBuildDiagnostics(); 4205 return publishLegacyResult(buildDeleteResult(delete, provider, options)); 4206 } 4207 4208 @Deprecated 4209 public static SemanticProgram buildDelete(TDeleteSqlStatement delete, 4210 NameBindingProvider provider) { 4211 clearBuildDiagnostics(); 4212 return publishLegacyResult(buildDeleteResult(delete, provider)); 4213 } 4214 4215 private static SemanticProgram buildDeleteImpl(TDeleteSqlStatement delete, 4216 NameBindingProvider provider) { 4217 if (delete == null) { 4218 throw new IllegalArgumentException("delete must not be null"); 4219 } 4220 if (provider == null) { 4221 throw new IllegalArgumentException("provider must not be null"); 4222 } 4223 4224 // 1) Slice 106 — admit top-level WITH on DELETE. Walks the CTE 4225 // list left-to-right, building each body as a preceding 4226 // StatementGraph and producing cteNameToStatementIndex + 4227 // ctePublishedColumns for the FROM-as-CTE branch in 4228 // buildDeleteRelation below. Mirrors the slice-105 UPDATE 4229 // walker. `stmts` / `lineage` allocated here (hoisted from the 4230 // prior slice-84 location) so the CTE walker can append. 4231 // DELETE_CTE_NOT_SUPPORTED stays declared-but-unreached 4232 // (slice 71/72/82/86/95/96/97/98/99/100/101/102/103/104/105 4233 // precedent). 4234 List<StatementGraph> stmts = new ArrayList<>(); 4235 List<LineageEdge> lineage = new ArrayList<>(); 4236 Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>(); 4237 Map<String, Integer> cteNameToStatementIndex = buildDeleteCteList( 4238 delete, provider, stmts, lineage, ctePublishedColumns); 4239 4240 // 2) Target table — defensive (parser usually rejects first). 4241 TTable targetTable = delete.getTargetTable(); 4242 if (targetTable == null || targetTable.getTableName() == null) { 4243 throw new SemanticIRBuildException(Diagnostic.error( 4244 DiagnosticCode.DELETE_TARGET_MISSING, 4245 "DELETE statement has no resolvable target table", 4246 delete)); 4247 } 4248 String targetQName = targetTable.getTableName().toString(); 4249 if (targetQName == null || targetQName.isEmpty()) { 4250 throw new SemanticIRBuildException(Diagnostic.error( 4251 DiagnosticCode.DELETE_TARGET_MISSING, 4252 "DELETE target table name is empty", 4253 delete)); 4254 } 4255 4256 // 3) Slice 84 / Slice 92 — joined-DELETE discriminator. 4257 // Parser-probe-verified shapes: 4258 // - Admit (slice 84): PG `DELETE FROM t USING j` / MSSQL 4259 // `DELETE FROM t FROM t spqh JOIN sp` / MSSQL `DELETE spqh 4260 // FROM t spqh JOIN sp` / Snowflake DELETE-USING — all have 4261 // joins.size=0 and referenceJoins.size > 0. 4262 // - Admit (slice 92): MySQL `DELETE T1 FROM T1 [WHERE pred]` 4263 // self-reference — joins.size=1, refJoins.size=1, and all 4264 // three names (joins[0].table, refJoins[0].table, target) 4265 // agree case-insensitively. Semantically identical to 4266 // `DELETE FROM T1 [WHERE pred]`; produces the same IR shape. 4267 // - Reject (slice-81 code preserved for non-self-ref): 4268 // MySQL `DELETE T1, T2 FROM …` (joins.size=2) and 4269 // MySQL `DELETE FROM T1 USING T1, T2` (refJoins.size=2). 4270 // 4271 // Slice 84 drops the slice-81 `tables.size > 1` and 4272 // `fromSourceJoin != null` blanket rejects (both fire for 4273 // admit shapes; probe confirms no parser-reachable shape 4274 // needs them when joins.size == 0). Candidate (d) in §8.1.4 4275 // (Hive multi-insert) remains open for a future slice. 4276 boolean mysqlSelfRef = false; 4277 if (delete.joins != null && delete.joins.size() > 0) { 4278 // Slice 92 — admit MySQL self-reference form: 4279 // DELETE T1 FROM T1 [WHERE …] 4280 // The check requires all three names to match (codex 4281 // plan-review rounds Q1+Q5 BLOCKING fix: checking only 4282 // joins[0] is insufficient — DELETE T1 FROM T2 would 4283 // incorrectly admit because joins[0]=T1=target but 4284 // refJoins[0]=T2≠target). 4285 mysqlSelfRef = isMysqlSelfReferenceDelete(delete, targetQName); 4286 if (!mysqlSelfRef) { 4287 throw new SemanticIRBuildException(Diagnostic.error( 4288 DiagnosticCode.DELETE_JOINED_NOT_SUPPORTED, 4289 "DELETE with multi-target / multi-USING clause is " 4290 + "not supported by SemanticIRBuilder.buildDelete; " 4291 + "slice 84 admits PG `DELETE FROM t USING j` and " 4292 + "MSSQL `DELETE FROM t FROM t JOIN s` shapes; " 4293 + "slice 92 admits MySQL " 4294 + "`DELETE T1 FROM T1 [WHERE …]` self-reference", 4295 delete)); 4296 } 4297 } 4298 4299 // 4) Slice 85 lifts the RETURNING / OUTPUT rejects on DELETE. 4300 // The cheap statement-level OUTPUT_INTO reject fires here so a 4301 // multi-violation shape routes to the cheaper structural code 4302 // first. {@code DELETE_RETURNING_CLAUSE_NOT_SUPPORTED} and 4303 // {@code DELETE_OUTPUT_CLAUSE_NOT_SUPPORTED} stay declared but 4304 // unreached (slice 71/72 retain-for-documentation precedent). 4305 if (delete.getOutputClause() != null 4306 && delete.getOutputClause().getIntoTable() != null) { 4307 throw new SemanticIRBuildException(Diagnostic.error( 4308 DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED, 4309 "DELETE OUTPUT ... INTO <target> writes a second target; " 4310 + "slice 85 admits projection-only OUTPUT", 4311 delete)); 4312 } 4313 if (delete.getOrderByClause() != null 4314 || delete.getLimitClause() != null) { 4315 throw new SemanticIRBuildException(Diagnostic.error( 4316 DiagnosticCode.DELETE_ORDER_BY_OR_LIMIT_NOT_SUPPORTED, 4317 "DELETE with ORDER BY / LIMIT (MySQL) is not " 4318 + "supported by SemanticIRBuilder.buildDelete; " 4319 + "slice 81 admits no row-pruning on DELETE", 4320 delete)); 4321 } 4322 4323 // 4.7) Slice 84 — extract FROM-subqueries from referenceJoins 4324 // (after slice 106's CTE walker so the CTE bodies precede any 4325 // extracted FROM-subquery in the program). Mirrors slice-83 4326 // UPDATE FROM-subquery extraction (which uses 4327 // update.getJoins()); here we use delete.getReferenceJoins(). 4328 // buildDelete owns fresh local stmts/lineage lists (allocated 4329 // in step 1 above) so exceptions propagate cleanly to the 4330 // caller — no snapshot/rollback wrapper. 4331 // 4332 // Slice 106 — forward cteNameToStatementIndex + 4333 // ctePublishedColumns so a nested SELECT inside an extracted 4334 // FROM-subquery body can resolve outer-WITH CTE references 4335 // (Resolver2 wires CTEScope; the maps are forwarded for parity 4336 // with the SELECT / MERGE / UPDATE call sites and so the 4337 // §N test for `USING (SELECT … FROM cte) sub` produces the 4338 // expected cross-stmt edge to the CTE body). 4339 // 4340 // Decorate the provider with the outer-WITH CTE name set so 4341 // the SELECT-side {@link #buildRelation} routes references to 4342 // those names through {@link RelationKind#CTE} (rather than 4343 // TABLE), which in turn makes 4344 // {@link #emitLineageForStatement} emit the cross-stmt 4345 // {@code STATEMENT_OUTPUT(subIdx,col) → 4346 // STATEMENT_OUTPUT(cteIdx,col)} edge required by §N. This 4347 // mirrors the SELECT-side outer-WITH walker 4348 // (see {@link #build}'s {@code outerProvider}). 4349 NameBindingProvider providerWithCte = cteNameToStatementIndex.isEmpty() 4350 ? provider 4351 : provider.withCteContext(cteNameToStatementIndex.keySet()); 4352 Map<String, Integer> subqueryAliasToIndex = 4353 extractDeleteFromSubqueries(delete, providerWithCte, stmts, lineage, 4354 cteNameToStatementIndex, ctePublishedColumns); 4355 Map<String, List<String>> deleteInScope = buildDeleteInScopeMap( 4356 delete, subqueryAliasToIndex, stmts, 4357 cteNameToStatementIndex, ctePublishedColumns); 4358 NameBindingProvider providerWithStar = deleteInScope.isEmpty() 4359 ? providerWithCte 4360 : providerWithCte.withInScopeRelationColumns(deleteInScope); 4361 4362 // 5) WHERE refs — slice 111 lifts the slice-81 blanket subquery 4363 // reject by routing uncorrelated predicate-subquery wrappers 4364 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 4365 // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction pipeline 4366 // refactored by slice 110 to take a PredicateClauseContext. The 4367 // new DELETE_WHERE constant carries clause-specific 4368 // DiagnosticCode IDs (8 new DELETE_WHERE_* codes) and a 4369 // "DELETE WHERE clause" label. Each extracted wrapper lands as 4370 // its own <predicate_subquery_<i>> StatementGraph BEFORE the 4371 // DELETE (deleteIdx below = stmts.size() naturally accounts for 4372 // them — slice-83 dynamic-index pattern, slice 110 UPDATE 4373 // precedent). Remaining non-subquery refs flow into 4374 // filterColumnRefs via collectColumnRefsSkipping (or 4375 // collectColumnRefsTolerant on the slice-92 MySQL self-ref 4376 // path). Window functions in non-subquery subtrees still reject 4377 // via rejectWindowFunctionInScopeSkipping. Slice 84 — 4378 // providerWithStar so WHERE refs against extracted subquery 4379 // aliases bind correctly (slice-83 precedent). 4380 // 4381 // Slice 106 — providerWithCte (then providerWithStar on top of 4382 // it) already decorates the provider with the outer-WITH CTE 4383 // name set so the predicate body's inner SELECT routes 4384 // `FROM cte` refs through RelationKind.CTE and 4385 // emitLineageForStatement emits the 4386 // STATEMENT_OUTPUT(subIdx,col) → STATEMENT_OUTPUT(cteIdx,col) 4387 // cross-stmt edge (slice 110 UPDATE precedent). 4388 List<ColumnRef> filterRefs; 4389 TWhereClause where = delete.getWhereClause(); 4390 if (where == null || where.getCondition() == null) { 4391 filterRefs = Collections.<ColumnRef>emptyList(); 4392 } else { 4393 Set<TExpression> extractedWhereRoots = 4394 Collections.<TExpression>emptySet(); 4395 if (containsAnySubquery(where)) { 4396 extractedWhereRoots = 4397 extractUncorrelatedPredicateSubqueriesFromClause( 4398 where.getCondition(), providerWithStar, 4399 stmts, lineage, cteNameToStatementIndex, 4400 PredicateClauseContext.DELETE_WHERE); 4401 rejectAnyRemainingSubqueriesFromClause( 4402 where.getCondition(), extractedWhereRoots, 4403 PredicateClauseContext.DELETE_WHERE); 4404 } 4405 rejectWindowFunctionInScopeSkipping(where, "WHERE clause", 4406 extractedWhereRoots); 4407 // Codex diff-review P1 fix: for MySQL self-reference DELETE the 4408 // MySQL parser puts 3 T1 instances in stmt.tables (target + 4409 // joins[0] + refJoins[0]), making Resolver2's inferredCandidates 4410 // see 3 candidates for any unqualified column → NOT_FOUND → 4411 // COLUMN_BINDING_NON_EXACT. Use a tolerant collector for the 4412 // self-ref path: EXACT_MATCH bindings (qualified refs) are 4413 // preserved verbatim; non-exact bindings emit the column ref with 4414 // the SQL-written qualifier (null for unqualified refs) instead of 4415 // throwing. Qualified refs like WHERE T1.id = 1 still get full 4416 // EXACT_MATCH treatment; only WHERE id = 1 (no qualifier) falls 4417 // back to the tolerant path. Slice 111 — both helpers now skip 4418 // extracted predicate-subquery subtrees so inner refs do not 4419 // leak into outer filterColumnRefs. 4420 filterRefs = mysqlSelfRef 4421 ? collectColumnRefsTolerant(where, providerWithStar, 4422 targetQName, extractedWhereRoots) 4423 : collectColumnRefsSkipping(where, providerWithStar, 4424 extractedWhereRoots); 4425 } 4426 4427 // 5.5) Slice 84 — walk delete.getReferenceJoins() to populate 4428 // relations[] (TABLE-kind FROM-side sources, target excluded 4429 // by reference identity; SUBQUERY-kind for USING (SELECT …)) 4430 // and joinColumnRefs[] (ON-clause refs across all JoinItems). 4431 // Mirrors slice-82 buildUpdate's FROM walk, with the 4432 // `update.getJoins()` source replaced by 4433 // `delete.getReferenceJoins()`. 4434 // 4435 // Slice 92 — for MySQL self-reference DELETE T1 FROM T1, 4436 // refJoins[0] is the same table as the target; skip the loop 4437 // so relations[] stays empty (mirrors the slice-81 single-target 4438 // contract). Resolver2's ScopeBuilder has already registered 4439 // the FROM-clause table (including any alias) via the 4440 // `referenceJoins` walk in preVisit(TDeleteSqlStatement), so 4441 // WHERE refs resolve correctly even without a relations[] entry. 4442 List<RelationSource> relations = new ArrayList<>(); 4443 // Slice-82 codex round-1 Q2 BLOCKING precedent — joinRefs 4444 // accumulates across multiple JoinItems in chained-JOIN 4445 // shapes. LinkedHashSet ensures cross-JoinItem dedup. 4446 java.util.LinkedHashSet<ColumnRef> joinRefsSet = 4447 new java.util.LinkedHashSet<>(); 4448 TJoinList refJoins = delete.getReferenceJoins(); 4449 if (!mysqlSelfRef && refJoins != null) { 4450 for (int ji = 0; ji < refJoins.size(); ji++) { 4451 TJoin join = refJoins.getJoin(ji); 4452 TTable leftTable = join.getTable(); 4453 // Slice 106 — threads cteNameToStatementIndex so the 4454 // FROM-driver buildDeleteRelation call can route 4455 // objectname-typed CTE references to a SUBQUERY-kind 4456 // RelationSource pointing at the CTE statement. 4457 buildDeleteRelation(leftTable, targetTable, relations, delete, 4458 cteNameToStatementIndex); 4459 TJoinItemList items = join.getJoinItems(); 4460 if (items == null) continue; 4461 for (int i = 0; i < items.size(); i++) { 4462 TJoinItem item = items.getJoinItem(i); 4463 // Slice 106 — threads cteNameToStatementIndex through 4464 // the JoinItem walker so JOIN-side CTE refs (MSSQL 4465 // `FROM target t JOIN cte ON …`) get SUBQUERY-kind 4466 // RelationSource emission. 4467 buildDeleteJoinItem(item, targetTable, providerWithStar, 4468 relations, joinRefsSet, delete, 4469 cteNameToStatementIndex); 4470 } 4471 } 4472 } 4473 List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet); 4474 4475 // 6) Build the DELETE outer. 4476 // - relations[] may be non-empty for joined DELETE (slice 84); 4477 // empty for single-target DELETE (slice 81 contract). 4478 // - target.columns empty by design — DELETE removes whole rows. 4479 RelationBinding targetBinding = new RelationBinding( 4480 RelationKind.TABLE, targetQName); 4481 TargetRelation target = new TargetRelation( 4482 targetBinding, Collections.<String>emptyList()); 4483 4484 // Slice 85 — build RETURNING / OUTPUT projection columns BEFORE 4485 // the StatementGraph so the new returningColumns slot can be 4486 // populated. deleteIdx mirrors the slice-84 stmts.size() pattern. 4487 int deleteIdx = stmts.size(); 4488 // DELETE target alias = effective alias from the target's 4489 // TTable (slice-84 convention). 4490 String deleteTargetAlias = effectiveAliasOf(targetTable); 4491 if (deleteTargetAlias == null || deleteTargetAlias.isEmpty()) { 4492 deleteTargetAlias = targetQName; 4493 } 4494 // Slice 123 — combine slice-84 FROM-subquery aliases with slice-106 4495 // CTE-as-relation aliases so a RETURNING/OUTPUT ref to a SUBQUERY- 4496 // kind FROM-side relation column emits the cross-stmt 4497 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge into the producer. 4498 Map<String, Integer> returningAliasToSubIdx = 4499 buildDeleteCombinedAliasToSubIdx(delete, 4500 subqueryAliasToIndex, cteNameToStatementIndex); 4501 List<OutputColumn> returningColumns = buildReturningColumns( 4502 delete.getReturningClause(), 4503 delete.getOutputClause(), 4504 "DELETE", 4505 targetQName, 4506 deleteTargetAlias, 4507 /*targetTable=*/ targetTable, 4508 relations, 4509 returningAliasToSubIdx, 4510 providerWithStar, 4511 deleteIdx, 4512 lineage, 4513 delete); 4514 4515 StatementGraph deleteStmt = new StatementGraph( 4516 /*name=*/ null, 4517 "DELETE", 4518 relations, 4519 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 4520 returningColumns, 4521 filterRefs, 4522 joinRefs, 4523 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4524 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4525 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4526 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 4527 /*distinct=*/ false, 4528 /*setOperator=*/ null, 4529 /*rowLimit=*/ null, 4530 target); 4531 4532 stmts.add(deleteStmt); 4533 // Slice 85 — extracted FROM-subqueries have already emitted 4534 // their own internal lineage edges into `lineage` via 4535 // processDirectSubqueryTable; buildReturningColumns also 4536 // already appended STATEMENT_OUTPUT(deleteIdx, retName) → 4537 // TABLE_COLUMN(targetQName, baseCol) edges above. No further 4538 // edges are needed. 4539 return new SemanticProgram(stmts, lineage); 4540 } 4541 4542 /** 4543 * Slice 94 — admit the single-target MERGE skeleton: 4544 * <pre> 4545 * MERGE INTO target [AS] tgt 4546 * USING (source_table | (SELECT ...) ) [AS] src 4547 * ON <join condition> 4548 * WHEN MATCHED [AND <cond>] THEN UPDATE SET c1 = expr1 [, ...] 4549 * WHEN NOT MATCHED [AND <cond>] THEN INSERT [(c1, ...)] VALUES (expr1, ...) 4550 * WHEN MATCHED [AND <cond>] THEN DELETE 4551 * </pre> 4552 * 4553 * <p>Emits one {@code "MERGE"}-kind {@link StatementGraph} carrying: 4554 * <ul> 4555 * <li>{@link TargetRelation} on {@code getTarget()} only — slice 4556 * 78/80 contract: target lives on the dedicated target slot, 4557 * NOT in {@code relations[]}. The slice-77/79 catalog walker 4558 * fires the kind-discriminated "MERGE target relation 'X'" 4559 * message via {@code targetWarnMessage("MERGE")}.</li> 4560 * <li>{@code relations[]} = one entry for the USING source 4561 * (TABLE-kind base table or SUBQUERY-kind aliased subquery). 4562 * The slice-77 FROM walker fires "FROM relation 'X'" for 4563 * missing source.</li> 4564 * <li>{@code outputColumns[]} = empty (MERGE has no projection).</li> 4565 * <li>{@code joinColumnRefs[]} = ON condition refs + per-WHEN AND 4566 * condition refs, LinkedHashSet-deduplicated (slice 82 4567 * pattern).</li> 4568 * <li>{@code filterColumnRefs[]} = per-WHEN action WHERE refs 4569 * (UPDATE WHERE, UPDATE...DELETE WHERE, INSERT WHERE; slice 4570 * 95). Empty when no action WHERE is present.</li> 4571 * </ul> 4572 * 4573 * <p>Per-WHEN action lineage: 4574 * <ul> 4575 * <li>{@code WHEN MATCHED THEN UPDATE SET col_i = expr_i}: emit 4576 * one {@link LineageEdge} per (target col, RHS source ref) 4577 * pair as {@code TABLE_COLUMN(target,col) ← <ref>} — direct, 4578 * no STATEMENT_OUTPUT intermediate (MERGE has no SELECT 4579 * projection). Codex round-2 Q4 confirmed YES.</li> 4580 * <li>{@code WHEN NOT MATCHED THEN INSERT (c1, ...) VALUES (e1, ...)}: 4581 * same pattern — one edge per (insert col, source ref).</li> 4582 * <li>{@code WHEN MATCHED THEN DELETE}: no per-column lineage 4583 * (slice 81 DELETE contract).</li> 4584 * <li>{@code WHEN MATCHED [AND <cond>] THEN DO NOTHING} (PG 15+, 4585 * slice 96): admitted as a no-op action. No per-column 4586 * lineage (slice 81 DELETE precedent). Per-WHEN AND 4587 * condition refs still feed {@code joinColumnRefs[]} via 4588 * the pre-dispatch block.</li> 4589 * <li>{@code WHEN NOT MATCHED BY SOURCE [AND <cond>] THEN 4590 * UPDATE SET ... | DELETE} (SQL Server, slice 97): 4591 * admitted with the SQL Server semantic invariant that 4592 * SET RHS and per-WHEN AND cond may not reference USING 4593 * source columns (no source row exists when the action 4594 * fires). Source-side refs reject with 4595 * {@link DiagnosticCode#MERGE_NOT_MATCHED_BY_SOURCE_REFERENCES_SOURCE}. 4596 * INSERT on BY SOURCE is parser-admitted but semantically 4597 * invalid; rejects with 4598 * {@link DiagnosticCode#MERGE_NOT_MATCHED_BY_SOURCE_INSERT_NOT_VALID}. 4599 * UPDATE target self-refs ({@code t.a = t.b}) emit no 4600 * lineage edges (slice-94 alias-filter convention; codex 4601 * round-1 Q2 confirmed). PG 17+ BY SOURCE syntax still 4602 * parses as type 2 plain NOT MATCHED in parser 4.1.5.0 4603 * — that parser gap is not addressed in slice 97.</li> 4604 * </ul> 4605 * 4606 * <p>For USING-subquery, the inner SELECT is built via {@link #build} 4607 * and appended as a preceding {@link StatementGraph}; its inner 4608 * lineage edges are rebased by the current statement-list offset so 4609 * STATEMENT_OUTPUT indices stay valid (slice 78 INSERT pattern). 4610 * 4611 * <p>Resolver2 already handles MERGE via {@link gudusoft.gsqlparser.resolver2.scope.MergeScope} 4612 * — both USING base tables and USING subqueries surface as 4613 * {@code sourceTable + EXACT_MATCH} bindings on RHS / VALUES / 4614 * ON / WHEN-AND refs. Codex round-2 Q5 BLOCKING fix: we install 4615 * an explicit slice-83-style published-column map only for 4616 * USING subqueries (deterministic; cheap; matches the SELECT- 4617 * side FROM-subquery pattern even when redundant). 4618 */ 4619 /** 4620 * Build under caller-supplied {@code options}; see 4621 * {@link #build(TSelectSqlStatement, NameBindingProvider, SemanticIRBuildOptions)}. 4622 */ 4623 public static SemanticBuildResult buildMergeResult( 4624 TMergeSqlStatement merge, NameBindingProvider provider, 4625 SemanticIRBuildOptions options) { 4626 return executeBuild(options, () -> buildMergeImpl(merge, provider)); 4627 } 4628 4629 /** Build a MERGE and atomically return its program and diagnostics. */ 4630 public static SemanticBuildResult buildMergeResult( 4631 TMergeSqlStatement merge, NameBindingProvider provider) { 4632 return buildMergeResult(merge, provider, 4633 SemanticIRBuildOptions.defaults()); 4634 } 4635 4636 @Deprecated 4637 public static SemanticProgram buildMerge(TMergeSqlStatement merge, 4638 NameBindingProvider provider, 4639 SemanticIRBuildOptions options) { 4640 clearBuildDiagnostics(); 4641 return publishLegacyResult(buildMergeResult(merge, provider, options)); 4642 } 4643 4644 @Deprecated 4645 public static SemanticProgram buildMerge(TMergeSqlStatement merge, 4646 NameBindingProvider provider) { 4647 clearBuildDiagnostics(); 4648 return publishLegacyResult(buildMergeResult(merge, provider)); 4649 } 4650 4651 private static SemanticProgram buildMergeImpl(TMergeSqlStatement merge, 4652 NameBindingProvider provider) { 4653 if (merge == null) { 4654 throw new IllegalArgumentException("merge must not be null"); 4655 } 4656 if (provider == null) { 4657 throw new IllegalArgumentException("provider must not be null"); 4658 } 4659 // Slice 94 — defensive UsingScope reset at entry. MERGE does 4660 // not produce its own UsingScope but a parent context might 4661 // (e.g. nested-statement contexts); mirrors slice 80 / 86 4662 // buildUpdate hygiene. 4663 provider = provider.withUsingScope(UsingScope.EMPTY); 4664 4665 // Slice 101 — hoist allocations earlier so buildMergeCteList can 4666 // append CTE bodies as preceding statements. The slice-94 reject 4667 // at this location is replaced by the CTE walker below. 4668 List<StatementGraph> stmts = new ArrayList<>(); 4669 List<LineageEdge> lineage = new ArrayList<>(); 4670 4671 // 1) Slice 101 — admit top-level WITH on MERGE. Walks CTE list 4672 // left-to-right, building each body as a preceding statement. 4673 // Produces cteNameToStatementIndex + ctePublishedColumns for the 4674 // USING-as-CTE branch below. Mirrors SELECT-side build() at 4675 // lines 516-653. `MERGE_CTE_NOT_SUPPORTED` stays declared-but- 4676 // unreached for API stability (slice 71/72/82/86/95/96/97/98/99/100 4677 // precedent). 4678 Map<String, List<String>> ctePublishedColumns = new LinkedHashMap<>(); 4679 Map<String, Integer> cteNameToStatementIndex = buildMergeCteList( 4680 merge, provider, stmts, lineage, ctePublishedColumns); 4681 4682 // 2) Target table — defensive. 4683 TTable targetTable = merge.getTargetTable(); 4684 if (targetTable == null || targetTable.getTableName() == null) { 4685 throw new SemanticIRBuildException(Diagnostic.error( 4686 DiagnosticCode.MERGE_TARGET_MISSING, 4687 "MERGE statement has no resolvable target table", 4688 merge)); 4689 } 4690 String targetQName = targetTable.getTableName().toString(); 4691 if (targetQName == null || targetQName.isEmpty()) { 4692 throw new SemanticIRBuildException(Diagnostic.error( 4693 DiagnosticCode.MERGE_TARGET_MISSING, 4694 "MERGE target table name is empty", 4695 merge)); 4696 } 4697 4698 // 3) USING source — defensive. 4699 TTable usingTable = merge.getUsingTable(); 4700 if (usingTable == null) { 4701 throw new SemanticIRBuildException(Diagnostic.error( 4702 DiagnosticCode.MERGE_USING_SOURCE_MISSING, 4703 "MERGE statement has no USING source", 4704 merge)); 4705 } 4706 4707 // 4) ON condition — defensive (parser usually rejects first). 4708 TExpression onCondition = merge.getCondition(); 4709 if (onCondition == null) { 4710 throw new SemanticIRBuildException(Diagnostic.error( 4711 DiagnosticCode.MERGE_ON_CONDITION_MISSING, 4712 "MERGE statement has no ON condition", 4713 merge)); 4714 } 4715 4716 // 5) OUTPUT INTO / RETURNING / LIMIT / error logging rejects. 4717 // Slice 98 lifts MSSQL MERGE OUTPUT projection (non-INTO) via 4718 // the slice-85 buildReturningColumns walker; the actual call 4719 // is deferred until after step 8 because the walker needs the 4720 // populated relations[] (USING source). OUTPUT INTO continues 4721 // to reject (writes a second target). The RETURNING-clause 4722 // branch stays declared-but-unreached: PG parser drops 4723 // MERGE RETURNING silently, Oracle PARSE_FAILED, Couchbase 4724 // has no test reach (slice 71/72/82/86/95/96/97 precedent). 4725 if (merge.getOutputClause() != null 4726 && merge.getOutputClause().getIntoTable() != null) { 4727 throw new SemanticIRBuildException(Diagnostic.error( 4728 DiagnosticCode.OUTPUT_INTO_NOT_SUPPORTED, 4729 "MERGE OUTPUT ... INTO <target> writes a second " 4730 + "target; slice 98 admits OUTPUT projection only", 4731 merge)); 4732 } 4733 if (merge.getReturningClause() != null) { 4734 throw new SemanticIRBuildException(Diagnostic.error( 4735 DiagnosticCode.MERGE_RETURNING_CLAUSE_NOT_SUPPORTED, 4736 "MERGE RETURNING projection (Oracle / Couchbase) is " 4737 + "not supported by SemanticIRBuilder.buildMerge", 4738 merge)); 4739 } 4740 if (merge.getLimitClause() != null) { 4741 throw new SemanticIRBuildException(Diagnostic.error( 4742 DiagnosticCode.MERGE_LIMIT_NOT_SUPPORTED, 4743 "MERGE with LIMIT (Couchbase) is not supported by " 4744 + "SemanticIRBuilder.buildMerge", 4745 merge)); 4746 } 4747 if (merge.getErrorLoggingClause() != null) { 4748 throw new SemanticIRBuildException(Diagnostic.error( 4749 DiagnosticCode.MERGE_ERROR_LOGGING_NOT_SUPPORTED, 4750 "MERGE LOG ERRORS INTO (Oracle) is not supported by " 4751 + "SemanticIRBuilder.buildMerge", 4752 merge)); 4753 } 4754 4755 // 6) Build USING source RelationSource. If USING is a subquery, 4756 // extract it as a preceding StatementGraph and emit a SUBQUERY- 4757 // kind RelationSource that points at it; slice-83 pattern. 4758 // Otherwise emit a TABLE-kind RelationSource. 4759 // Slice 101 — `stmts` / `lineage` were hoisted to the top of 4760 // buildMerge so the CTE walker can append its preceding CTE 4761 // body statements first. Do NOT re-declare them here. 4762 String usingAlias = effectiveAliasOf(usingTable); 4763 if (usingAlias == null || usingAlias.isEmpty()) { 4764 usingAlias = (usingTable.getName() == null 4765 || usingTable.getName().toString().isEmpty()) 4766 ? "__merge_using__" 4767 : usingTable.getName().toString(); 4768 } 4769 boolean usingIsSubquery = usingTable.getTableType() 4770 == gudusoft.gsqlparser.ETableSource.subquery; 4771 List<RelationSource> relations = new ArrayList<>(); 4772 Map<String, List<String>> mergeInScope = new LinkedHashMap<>(); 4773 NameBindingProvider providerWithStar = provider; 4774 // Slice 94 — alias resolution maps for the per-WHEN action 4775 // lineage emitter. TABLE-kind sources map alias → qualifiedName; 4776 // SUBQUERY-kind sources map alias → statement index of the 4777 // extracted inner SELECT. A SEPARATE `targetAliases` set 4778 // identifies refs whose relationAlias is the target alias 4779 // (codex round-1 diff Q1 BLOCKING — without this separation, 4780 // a self-merge where USING happens to share the target's name 4781 // would mis-classify the source alias as the target alias). 4782 Map<String, String> aliasToTableQName = new HashMap<>(); 4783 Map<String, Integer> aliasToSubIdx = new HashMap<>(); 4784 Set<String> targetAliases = new HashSet<>(); 4785 String targetAlias = effectiveAliasOf(targetTable); 4786 if (targetAlias != null && !targetAlias.isEmpty()) { 4787 targetAliases.add(targetAlias.toLowerCase(Locale.ROOT)); 4788 } 4789 targetAliases.add(targetQName.toLowerCase(Locale.ROOT)); 4790 if (usingIsSubquery) { 4791 TSelectSqlStatement usingSelect = usingTable.getSubquery(); 4792 if (usingSelect == null) { 4793 throw new SemanticIRBuildException(Diagnostic.error( 4794 DiagnosticCode.MERGE_SOURCE_NOT_SUPPORTED, 4795 "MERGE USING declared as subquery but no inner " 4796 + "SELECT statement was attached", 4797 merge)); 4798 } 4799 // Slice 122 — close the slice-110 documented parity gap so a 4800 // USING body that references an outer MERGE CTE 4801 // (`WITH cte AS (...) MERGE INTO t USING (SELECT ... FROM cte) s`) 4802 // binds CTE-kind and emits the cross-stmt edge into the CTE body. 4803 // Build the USING body directly into the SHARED stmts/lineage via 4804 // buildSelectProgramInto (no rebase — see its javadoc for the full 4805 // rationale), seeded with a COPY of the outer MERGE's CTE maps. 4806 // The COPY (not the live map) keeps a USING-body-own CTE from 4807 // leaking into the outer cteNameToStatementIndex consulted later 4808 // by the slice-116 action-WHERE extractor and the USING-as-CTE 4809 // `else` branch — matching pre-slice-122, where own-WITH CTEs were 4810 // never registered in the outer map. 4811 Map<String, Integer> usingCteMap = 4812 new HashMap<>(cteNameToStatementIndex); 4813 Map<String, List<String>> usingCtePublished = 4814 new LinkedHashMap<>(ctePublishedColumns); 4815 int subIdx = buildSelectProgramInto(usingSelect, provider, 4816 stmts, lineage, usingCteMap, usingCtePublished); 4817 // Codex round-2 Q5 BLOCKING fix: install slice-83-style 4818 // in-scope map for USING subquery columns, scoped only to 4819 // the USING alias (codex round-3 Q2: ensure scoped to 4820 // USING alias only, no override of target / base-table). 4821 StatementGraph usingOuter = stmts.get(subIdx); 4822 List<String> publishedCols = new ArrayList<>(); 4823 for (OutputColumn oc : usingOuter.getOutputColumns()) { 4824 if (oc.getName() != null && !oc.getName().isEmpty()) { 4825 publishedCols.add(oc.getName()); 4826 } 4827 } 4828 mergeInScope.put( 4829 usingAlias.toLowerCase(Locale.ROOT), publishedCols); 4830 providerWithStar = provider.withInScopeRelationColumns( 4831 mergeInScope); 4832 relations.add(new RelationSource(usingAlias, 4833 new RelationBinding(RelationKind.SUBQUERY, usingAlias))); 4834 aliasToSubIdx.put( 4835 usingAlias.toLowerCase(Locale.ROOT), subIdx); 4836 } else { 4837 // Slice 101 — USING-as-CTE detection. When MERGE has a WITH 4838 // clause and the USING bare name matches a CTE declared in 4839 // that WITH clause, route to a SUBQUERY-kind RelationSource 4840 // pointing at the CTE's already-built statement index. This 4841 // ensures: 4842 // (a) lineage edges flow to STATEMENT_OUTPUT(cteIdx, col), 4843 // not the fictitious TABLE_COLUMN(cteName, col); 4844 // (b) the slice-77 catalog-miss WARN walker (which walks 4845 // only TABLE-kind RelationSources) skips the CTE name; 4846 // (c) Resolver2-bound CTE refs (probe 2026-05-17: status 4847 // EXACT_MATCH with sourceTable=<cteName>) flow through 4848 // the same emitMergeLineageEdge dispatch. 4849 // Case-insensitive lookup matches SQL identifier semantics. 4850 String usingBareName = (usingTable.getName() == null) 4851 ? "" 4852 : usingTable.getName().toString(); 4853 String usingBareNameLower = 4854 usingBareName.toLowerCase(Locale.ROOT); 4855 Integer cteIdx = usingBareNameLower.isEmpty() 4856 ? null 4857 : cteNameToStatementIndex.get(usingBareNameLower); 4858 if (cteIdx != null) { 4859 // USING references a declared CTE. 4860 List<String> publishedCols = ctePublishedColumns.get( 4861 usingBareNameLower); 4862 if (publishedCols == null) { 4863 publishedCols = new ArrayList<>(); 4864 } 4865 mergeInScope.put( 4866 usingAlias.toLowerCase(Locale.ROOT), 4867 publishedCols); 4868 providerWithStar = provider.withInScopeRelationColumns( 4869 mergeInScope); 4870 relations.add(new RelationSource(usingAlias, 4871 new RelationBinding( 4872 RelationKind.SUBQUERY, usingAlias))); 4873 aliasToSubIdx.put( 4874 usingAlias.toLowerCase(Locale.ROOT), cteIdx); 4875 // Also register the bare CTE name in case the SQL 4876 // omits the alias (e.g. `USING src ON ...` with no 4877 // trailing alias). Mirrors the TABLE-kind branch 4878 // (line below) which also registers the bare name. 4879 aliasToSubIdx.put(usingBareNameLower, cteIdx); 4880 } else { 4881 // TABLE-kind USING — use the source table's qualified name 4882 // as the binding's qualifiedName so the slice-77 catalog 4883 // walker can find it. 4884 String usingQName = (usingTable.getTableName() == null) 4885 ? usingAlias 4886 : usingTable.getTableName().toString(); 4887 relations.add(new RelationSource(usingAlias, 4888 new RelationBinding(RelationKind.TABLE, usingQName))); 4889 aliasToTableQName.put( 4890 usingAlias.toLowerCase(Locale.ROOT), usingQName); 4891 // Also register the bare name in case the SQL omits the 4892 // alias (e.g. `USING managers ON ...` without `s`). 4893 aliasToTableQName.put( 4894 usingQName.toLowerCase(Locale.ROOT), usingQName); 4895 } 4896 } 4897 4898 // 7) Walk ON condition + per-WHEN AND conditions to build 4899 // joinColumnRefs[] with LinkedHashSet dedup (slice 82 pattern). 4900 // Reject ON-side subqueries: not supported in this slice; users 4901 // can still use a USING subquery for complex source logic. 4902 if (containsAnySubqueryExpression(onCondition)) { 4903 throw new SemanticIRBuildException(Diagnostic.error( 4904 DiagnosticCode.MERGE_WHEN_CONDITION_HAS_SUBQUERY_NOT_SUPPORTED, 4905 "MERGE ON condition contains a subquery; slice 94 " 4906 + "admits scalar-only ON conditions", 4907 merge)); 4908 } 4909 rejectWindowFunctionInScope(onCondition, "MERGE ON condition"); 4910 LinkedHashSet<ColumnRef> joinRefsSet = new LinkedHashSet<>(); 4911 joinRefsSet.addAll(collectColumnRefs(onCondition, providerWithStar)); 4912 // Slice 95 — per-WHEN action WHERE refs (UPDATE WHERE, 4913 // UPDATE...DELETE WHERE, INSERT WHERE) accumulate here. 4914 // Slice 94 left these refs silently dropped; slice 95 routes 4915 // them through filterColumnRefs[] (slice-80 UPDATE WHERE 4916 // precedent) — distinct from joinColumnRefs[] which holds 4917 // ON + WHEN-AND match conditions. 4918 LinkedHashSet<ColumnRef> filterRefsSet = new LinkedHashSet<>(); 4919 4920 // 8) Per-WHEN clause loop. Validate type, dispatch to action 4921 // builder, accumulate joinColumnRefs and lineage edges. 4922 TargetRelation targetRel = null; 4923 List<String> targetColumnNames = new ArrayList<>(); 4924 // Stable-order map: target col spelling (lower-cased) → 4925 // verbatim spelling encountered first. Iterating WHEN clauses 4926 // in order naturally produces SET-LHS first, then INSERT 4927 // column-list, matching the plan v3 column ordering rule. 4928 Map<String, String> seenTargetCols = new LinkedHashMap<>(); 4929 // LineageEdge dedup spans the whole MERGE on 4930 // (target column lower-case, source ref lower-case key). 4931 Set<String> emittedEdgeKeys = new HashSet<>(); 4932 4933 if (merge.getWhenClauses() == null 4934 || merge.getWhenClauses().size() == 0) { 4935 throw new SemanticIRBuildException(Diagnostic.error( 4936 DiagnosticCode.MERGE_WHEN_NO_ACTION, 4937 "MERGE statement has no WHEN clauses", 4938 merge)); 4939 } 4940 // Slice 116 — providerWithCteForActionWhere decorates 4941 // providerWithStar with withCteContext so the predicate body's 4942 // inner SELECT's `FROM cte` refs route through 4943 // RelationKind.CTE (slice 110 documented this is required for 4944 // emitLineageForStatement to emit STATEMENT_OUTPUT → 4945 // STATEMENT_OUTPUT edges into the CTE body). Hoisted here once 4946 // (cteNameToStatementIndex is finalized by line 3856 well 4947 // before the per-WHEN loop; recomputing per-WHEN would be 4948 // wasteful — codex diff-review Q1 advisory). The decoration 4949 // is scoped to collectMergeActionWhere only; providerWithStar 4950 // elsewhere stays unchanged so the WHEN AND condition (line 4951 // ~4153) and per-action SET/INSERT walkers see the original 4952 // provider — they already have their own slice-94 subquery 4953 // rejects so no asymmetric resolution surfaces. 4954 final NameBindingProvider providerWithCteForActionWhere = 4955 cteNameToStatementIndex.isEmpty() 4956 ? providerWithStar 4957 : providerWithStar.withCteContext( 4958 cteNameToStatementIndex.keySet()); 4959 // Slice 118 — build the MERGE correlation scope once (target + 4960 // USING source + outer CTEs) and thread through every per-WHEN 4961 // action WHERE call so correlated predicate subqueries promote 4962 // outer-aliased refs into OUTER_REFERENCE relations instead of 4963 // rejecting them. Mirrors the slice-117 pattern (UPDATE 4964 // SET-RHS correlated scalars). The scope is null-safe — every 4965 // value type inside flows from already-computed buildMerge 4966 // state (targetTable / usingTable / aliasToSubIdx / 4967 // cteNameToStatementIndex). 4968 final EnclosingScope mergeCorrelationScope = 4969 buildMergeEnclosingScope(merge, cteNameToStatementIndex, 4970 aliasToSubIdx); 4971 for (int wi = 0; wi < merge.getWhenClauses().size(); wi++) { 4972 TMergeWhenClause when = merge.getWhenClauses().getElement(wi); 4973 // Slice 97 — BY SOURCE variants (SQL Server admits parser 4974 // types 7 / 8) are now admitted. PG 17+ syntax still parses 4975 // as type 2 (parser gap; slice 97 does not address). The 4976 // legacy MERGE_WHEN_NOT_MATCHED_BY_SOURCE_NOT_SUPPORTED 4977 // code stays declared-but-unreached (slice 71/72/82/86/95/96 4978 // precedent). 4979 boolean isNotMatchedBySource = 4980 when.getType() == TMergeWhenClause.not_matched_by_source 4981 || when.getType() 4982 == TMergeWhenClause.not_matched_by_source_with_condition; 4983 // Per-WHEN AND condition (matched_with_condition, 4984 // not_matched_with_condition, not_matched_by_target_with_condition, 4985 // not_matched_by_source_with_condition). 4986 TExpression whenCond = when.getCondition(); 4987 if (whenCond != null) { 4988 if (containsAnySubqueryExpression(whenCond)) { 4989 throw new SemanticIRBuildException(Diagnostic.error( 4990 DiagnosticCode.MERGE_WHEN_CONDITION_HAS_SUBQUERY_NOT_SUPPORTED, 4991 "MERGE WHEN AND condition contains a subquery; " 4992 + "slice 94 admits scalar-only WHEN " 4993 + "conditions", 4994 merge)); 4995 } 4996 rejectWindowFunctionInScope(whenCond, "MERGE WHEN AND condition"); 4997 List<ColumnRef> condRefs = 4998 collectColumnRefs(whenCond, providerWithStar); 4999 // Slice 97 — BY SOURCE branches forbid source-side 5000 // refs in the AND condition (no source row exists). 5001 if (isNotMatchedBySource) { 5002 rejectSourceRefsForBySource(condRefs, aliasToTableQName, 5003 aliasToSubIdx, "MERGE WHEN NOT MATCHED BY SOURCE " 5004 + "AND condition", merge); 5005 } 5006 joinRefsSet.addAll(condRefs); 5007 } 5008 // Dispatch to action. Slice 96 — DO NOTHING is a no-op 5009 // action (PG 15+): no SET/INSERT VALUES, no per-column 5010 // lineage (slice-81 DELETE precedent). Per-WHEN AND 5011 // condition refs were already collected into joinRefsSet 5012 // above. MERGE_DO_NOTHING_NOT_SUPPORTED stays declared- 5013 // but-unreached for API stability (slice 71/72/82/86/95 5014 // precedent). 5015 boolean isDoNothingAction = when.getDoNothingClause() != null; 5016 TMergeUpdateClause upd = when.getUpdateClause(); 5017 TMergeInsertClause ins = when.getInsertClause(); 5018 boolean isDeleteAction = when.getDeleteClause() != null; 5019 if (upd == null && ins == null && !isDeleteAction 5020 && !isDoNothingAction) { 5021 throw new SemanticIRBuildException(Diagnostic.error( 5022 DiagnosticCode.MERGE_WHEN_NO_ACTION, 5023 "MERGE WHEN clause #" + (wi + 1) 5024 + " has no UPDATE / INSERT / DELETE / " 5025 + "DO NOTHING action", 5026 merge)); 5027 } 5028 // Slice 95 — collect per-WHEN action WHERE refs into 5029 // filterRefsSet. Slice 116 — uncorrelated predicate-subquery 5030 // wrappers in those WHEREs now extract through the slice-23+ 5031 // pipeline via PredicateClauseContext.MERGE_WHEN_WHERE 5032 // (mirrors slice 110-114 lifts on UPDATE / DELETE / SELECT / 5033 // set-op branch / CTE-body WHEREs). Window functions still 5034 // reject via rejectWindowFunctionInScopeSkipping (slice 95 5035 // contract preserved). MERGE_UPDATE_DELETE_WHERE_NOT_SUPPORTED 5036 // remains declared-but-unreached (slice 71/72/82/86 5037 // precedent). The providerWithCteForActionWhere decoration 5038 // is hoisted ABOVE the per-WHEN loop (codex diff-review Q1 5039 // advisory) since cteNameToStatementIndex is finalized 5040 // before the loop. 5041 if (upd != null) { 5042 collectMergeActionWhere(upd.getUpdateWhereClause(), 5043 "MERGE WHEN action UPDATE WHERE", 5044 providerWithCteForActionWhere, filterRefsSet, 5045 stmts, lineage, cteNameToStatementIndex, merge, 5046 mergeCorrelationScope); 5047 collectMergeActionWhere(upd.getDeleteWhereClause(), 5048 "MERGE WHEN action DELETE WHERE", 5049 providerWithCteForActionWhere, filterRefsSet, 5050 stmts, lineage, cteNameToStatementIndex, merge, 5051 mergeCorrelationScope); 5052 } 5053 if (ins != null) { 5054 collectMergeActionWhere(ins.getInsertWhereClause(), 5055 "MERGE WHEN action INSERT WHERE", 5056 providerWithCteForActionWhere, filterRefsSet, 5057 stmts, lineage, cteNameToStatementIndex, merge, 5058 mergeCorrelationScope); 5059 } 5060 // Slice 97 — BY SOURCE forbids INSERT semantically. MSSQL 5061 // parser admits the shape, so Semantic IR rejects. 5062 if (isNotMatchedBySource && ins != null) { 5063 throw new SemanticIRBuildException(Diagnostic.error( 5064 DiagnosticCode.MERGE_NOT_MATCHED_BY_SOURCE_INSERT_NOT_VALID, 5065 "MERGE WHEN NOT MATCHED BY SOURCE THEN INSERT is " 5066 + "not a valid SQL Server action; INSERT only " 5067 + "applies when the source row has no target " 5068 + "match. Slice 97 admits UPDATE / DELETE on " 5069 + "BY SOURCE branches.", 5070 merge)); 5071 } 5072 if (upd != null) { 5073 // Slice 97 — pre-walk SET RHS refs for BY SOURCE 5074 // branches to reject source-side refs before lineage 5075 // emission (Q1 in plan-review: keep helper branch- 5076 // agnostic; double-collect cost is bounded since 5077 // BY SOURCE UPDATEs are small). 5078 if (isNotMatchedBySource) { 5079 rejectBySourceSetRhsRefs(upd, providerWithStar, 5080 aliasToTableQName, aliasToSubIdx, merge); 5081 } 5082 buildMergeUpdateAction(upd, targetQName, targetTable, 5083 providerWithStar, seenTargetCols, lineage, 5084 emittedEdgeKeys, aliasToTableQName, 5085 aliasToSubIdx, targetAliases, merge); 5086 } 5087 if (ins != null) { 5088 buildMergeInsertAction(ins, targetQName, targetTable, 5089 providerWithStar, seenTargetCols, lineage, 5090 emittedEdgeKeys, aliasToTableQName, 5091 aliasToSubIdx, targetAliases, merge); 5092 } 5093 // DELETE action: no per-column lineage (slice 81 contract). 5094 } 5095 5096 // 9) Build TargetRelation from accumulated target column spellings. 5097 for (String spelling : seenTargetCols.values()) { 5098 targetColumnNames.add(spelling); 5099 } 5100 targetRel = new TargetRelation( 5101 new RelationBinding(RelationKind.TABLE, targetQName), 5102 targetColumnNames); 5103 5104 // 9.5) Slice 98 — MSSQL MERGE OUTPUT projection. Reuses the 5105 // slice-85 buildReturningColumns walker with dmlKind="MERGE": 5106 // Pass 1.5's INSERT/DELETE pseudo-table mismatch check naturally 5107 // skips (MERGE is action-polymorphic — INSERTED and DELETED 5108 // may both legitimately appear). The walker handles $action via 5109 // a slice-98-specific short-circuit (derived OutputColumn, no 5110 // sources, no edges). mergeIdx = stmts.size() because the MERGE 5111 // StatementGraph is appended below; for USING-subquery shapes, 5112 // step 6 has already appended the extracted SELECT so the index 5113 // points at the upcoming MERGE position (slice-83 dynamic-index 5114 // pattern). Pass relations[] as fromSideRelations so unique 5115 // USING-alias matches resolve to the source qname (codex Q4). 5116 int mergeIdx = stmts.size(); 5117 String mergeTargetAlias = effectiveAliasOf(targetTable); 5118 if (mergeTargetAlias == null || mergeTargetAlias.isEmpty()) { 5119 mergeTargetAlias = targetQName; 5120 } 5121 List<OutputColumn> returningCols = buildReturningColumns( 5122 /*ret=*/ null, 5123 /*out=*/ merge.getOutputClause(), 5124 "MERGE", 5125 targetQName, 5126 mergeTargetAlias, 5127 targetTable, 5128 /*fromSideRelations=*/ relations, 5129 // Slice 124 — MERGE OUTPUT USING-source lineage unification 5130 // (symmetric follow-up to slice 123, which lifted DELETE + 5131 // UPDATE). aliasToSubIdx is already the MERGE combined 5132 // alias→stmt-index map: it holds the USING alias for both the 5133 // USING-(SELECT) subquery branch (alias→subIdx) and the 5134 // USING-as-CTE branch (alias→cteIdx); both add a SUBQUERY-kind 5135 // relation to relations[], so returningSubquerySourceIndex 5136 // promotes a MERGE OUTPUT ref bound to the USING source to the 5137 // canonical STATEMENT_OUTPUT(mergeIdx, ret) → 5138 // STATEMENT_OUTPUT(subOrCteIdx, col) edge instead of the 5139 // fictitious TABLE_COLUMN(<usingAlias>, col). TABLE-kind USING 5140 // adds nothing to aliasToSubIdx, and INSERTED/DELETED/target 5141 // refs are excluded by the gate, so those paths keep their 5142 // slice-85/98 TABLE_COLUMN edge. 5143 /*fromSideAliasToStmtIndex=*/ aliasToSubIdx, 5144 providerWithStar, 5145 mergeIdx, 5146 lineage, 5147 merge); 5148 5149 // 10) Emit StatementGraph. joinColumnRefs[] = ON + WHEN-AND refs. 5150 // Slice 95: filterColumnRefs[] = per-WHEN action WHERE refs 5151 // (UPDATE WHERE, UPDATE...DELETE WHERE, INSERT WHERE). 5152 // Slice 98: returningColumns[] = MERGE OUTPUT projection. 5153 List<ColumnRef> joinRefs = new ArrayList<>(joinRefsSet); 5154 List<ColumnRef> filterRefs = new ArrayList<>(filterRefsSet); 5155 StatementGraph mergeStmt = new StatementGraph( 5156 /*name=*/ null, 5157 "MERGE", 5158 relations, 5159 /*outputColumns=*/ Collections.<OutputColumn>emptyList(), 5160 returningCols, 5161 filterRefs, 5162 joinRefs, 5163 /*groupByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 5164 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 5165 /*orderByColumnRefs=*/ Collections.<ColumnRef>emptyList(), 5166 /*distinctOnColumnRefs=*/ Collections.<ColumnRef>emptyList(), 5167 /*distinct=*/ false, 5168 /*setOperator=*/ null, 5169 /*rowLimit=*/ null, 5170 targetRel); 5171 stmts.add(mergeStmt); 5172 5173 return new SemanticProgram(stmts, lineage); 5174 } 5175 5176 /** 5177 * Slice 95 — collect column refs from a per-WHEN action WHERE 5178 * predicate ({@code TMergeUpdateClause.updateWhereClause}, 5179 * {@code TMergeUpdateClause.deleteWhereClause}, or 5180 * {@code TMergeInsertClause.insertWhereClause}) into the supplied 5181 * {@code filterRefsSet}. 5182 * 5183 * <p>Slice 116 — lifts the slice-95 blanket subquery reject by 5184 * routing uncorrelated predicate-subquery wrappers (IN-SELECT / 5185 * EXISTS / NOT EXISTS / scalar comparison / ANY-ALL-SOME) through 5186 * the slice-23+ JOIN-ON extraction pipeline refactored by slice 5187 * 110 to take a {@link PredicateClauseContext}. The 5188 * {@link PredicateClauseContext#MERGE_WHEN_WHERE} constant reuses 5189 * the {@code SELECT_WHERE_*} DiagnosticCode family (slice 113/114 5190 * precedent — a MERGE-action WHERE IS a SELECT WHERE in shape) so 5191 * the enum count stays at 279; only the {@code clauseLabel} 5192 * differs so diagnostic messages can identify the MERGE-action 5193 * host context. 5194 * 5195 * <p>Each extracted wrapper lands as its own 5196 * {@code <predicate_subquery_<i>>} StatementGraph BEFORE the 5197 * MERGE statement (so {@code mergeIdx = stmts.size()} below in 5198 * {@code buildMerge} already accounts for them — slice-83 5199 * dynamic-index pattern, slice 110/111 precedent). Remaining 5200 * non-subquery refs flow into {@code filterRefsSet} via 5201 * {@link #collectColumnRefsSkipping}. Window functions in 5202 * non-subquery subtrees still reject via 5203 * {@link #rejectWindowFunctionInScopeSkipping} (slice 95 5204 * window-function contract preserved). 5205 * 5206 * <p>The supplied {@code provider} must carry 5207 * {@code withCteContext(cteMap.keySet())} so the predicate body's 5208 * inner SELECT's {@code FROM cte} refs route through 5209 * {@link RelationKind#CTE} — without that decoration, 5210 * {@code emitLineageForStatement} would lose the 5211 * STATEMENT_OUTPUT → STATEMENT_OUTPUT edge to the CTE body 5212 * (slice 110 documented gap for UPDATE WHERE — same applies here). 5213 * {@code buildMerge} composes the decoration once before the 5214 * per-WHEN loop. 5215 * 5216 * <p>Null-safe: returns immediately when the WHERE expression is 5217 * absent (slice-94 default; most WHEN clauses have no action 5218 * WHERE). 5219 */ 5220 private static void collectMergeActionWhere(TExpression expr, 5221 String label, 5222 NameBindingProvider provider, 5223 LinkedHashSet<ColumnRef> filterRefsSet, 5224 List<StatementGraph> stmts, 5225 List<LineageEdge> lineage, 5226 Map<String, Integer> cteMap, 5227 TMergeSqlStatement merge, 5228 EnclosingScope correlationScope) { 5229 if (expr == null) { 5230 return; 5231 } 5232 Set<TExpression> extractedRoots = Collections.<TExpression>emptySet(); 5233 if (containsAnySubqueryExpression(expr)) { 5234 extractedRoots = 5235 extractUncorrelatedPredicateSubqueriesFromClause( 5236 expr, provider, stmts, lineage, cteMap, 5237 PredicateClauseContext.MERGE_WHEN_WHERE, 5238 correlationScope); 5239 rejectAnyRemainingSubqueriesFromClause(expr, extractedRoots, 5240 PredicateClauseContext.MERGE_WHEN_WHERE); 5241 } 5242 rejectWindowFunctionInScopeSkipping(expr, label, extractedRoots); 5243 filterRefsSet.addAll(collectColumnRefsSkipping(expr, provider, 5244 extractedRoots)); 5245 } 5246 5247 /** 5248 * Slice 97 — reject any source-aliased column ref in a 5249 * WHEN NOT MATCHED BY SOURCE branch. SQL Server forbids 5250 * source-side references in this branch because there is no 5251 * matching source row when the action fires. 5252 * 5253 * <p>A ref is "source-aliased" iff its 5254 * {@code relationAlias.toLowerCase(Locale.ROOT)} appears in 5255 * either alias map (TABLE-kind or SUBQUERY-kind USING source). 5256 * Refs whose alias is unknown to both maps are assumed to be 5257 * target-bound (slice-94 alias-filter convention) and are 5258 * left alone. 5259 * 5260 * <p>Walks all refs so a source ref nested inside an arbitrary 5261 * function call (e.g. {@code COALESCE(s.code, t.code)}) is 5262 * caught — {@code collectColumnRefs} descends through arbitrary 5263 * scalar expressions (codex round-1 Q3 confirmed YES). 5264 */ 5265 private static void rejectSourceRefsForBySource(List<ColumnRef> refs, 5266 Map<String, String> aliasToTableQName, 5267 Map<String, Integer> aliasToSubIdx, 5268 String label, 5269 TMergeSqlStatement merge) { 5270 if (refs == null || refs.isEmpty()) { 5271 return; 5272 } 5273 for (ColumnRef r : refs) { 5274 String alias = r.getRelationAlias(); 5275 if (alias == null || alias.isEmpty()) { 5276 continue; 5277 } 5278 String key = alias.toLowerCase(Locale.ROOT); 5279 if (aliasToTableQName.containsKey(key) 5280 || aliasToSubIdx.containsKey(key)) { 5281 throw new SemanticIRBuildException(Diagnostic.error( 5282 DiagnosticCode.MERGE_NOT_MATCHED_BY_SOURCE_REFERENCES_SOURCE, 5283 label + " references USING source column '" 5284 + r + "'; WHEN NOT MATCHED BY SOURCE " 5285 + "branches must only reference target " 5286 + "columns or constants.", 5287 merge)); 5288 } 5289 } 5290 } 5291 5292 /** 5293 * Slice 97 — pre-walk the SET RHS of every assignment in a BY SOURCE 5294 * UPDATE action and reject any source-side ref. Called before 5295 * {@link #buildMergeUpdateAction} so the existing slice-94 helper 5296 * remains BY-SOURCE-agnostic. 5297 * 5298 * <p>Skips assignments that aren't shaped as a simple 5299 * {@code assignment_t} expression; those defects are caught by 5300 * {@link #buildMergeUpdateAction} with 5301 * {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED}. 5302 */ 5303 private static void rejectBySourceSetRhsRefs(TMergeUpdateClause upd, 5304 NameBindingProvider provider, 5305 Map<String, String> aliasToTableQName, 5306 Map<String, Integer> aliasToSubIdx, 5307 TMergeSqlStatement merge) { 5308 TResultColumnList sets = upd.getUpdateColumnList(); 5309 if (sets == null || sets.size() == 0) { 5310 return; 5311 } 5312 for (int i = 0; i < sets.size(); i++) { 5313 TResultColumn rc = sets.getResultColumn(i); 5314 TExpression assignment = (rc == null) ? null : rc.getExpr(); 5315 if (assignment == null 5316 || assignment.getExpressionType() != EExpressionType.assignment_t) { 5317 continue; 5318 } 5319 TExpression rhs = assignment.getRightOperand(); 5320 if (rhs == null) { 5321 continue; 5322 } 5323 // Subquery RHS would short-circuit later with 5324 // UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED; ignore here. 5325 if (containsAnySubqueryExpression(rhs)) { 5326 continue; 5327 } 5328 List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider); 5329 rejectSourceRefsForBySource(rhsRefs, aliasToTableQName, 5330 aliasToSubIdx, 5331 "MERGE WHEN NOT MATCHED BY SOURCE UPDATE SET " 5332 + "assignment #" + (i + 1) + " RHS", 5333 merge); 5334 } 5335 } 5336 5337 /** 5338 * Slice 94 — process one WHEN MATCHED THEN UPDATE SET action. 5339 * Each {@code TResultColumn} carries an assignment_t TExpression 5340 * whose leftOperand is the SET LHS (target column reference) and 5341 * whose rightOperand is the value expression. We emit one 5342 * {@link LineageEdge} per (target col, RHS source ref) pair. 5343 */ 5344 private static void buildMergeUpdateAction(TMergeUpdateClause upd, 5345 String targetQName, 5346 TTable targetTable, 5347 NameBindingProvider provider, 5348 Map<String, String> seenTargetCols, 5349 List<LineageEdge> lineage, 5350 Set<String> emittedEdgeKeys, 5351 Map<String, String> aliasToTableQName, 5352 Map<String, Integer> aliasToSubIdx, 5353 Set<String> targetAliases, 5354 TMergeSqlStatement merge) { 5355 TResultColumnList sets = upd.getUpdateColumnList(); 5356 if (sets == null || sets.size() == 0) { 5357 return; 5358 } 5359 for (int i = 0; i < sets.size(); i++) { 5360 TResultColumn rc = sets.getResultColumn(i); 5361 TExpression assignment = (rc == null) ? null : rc.getExpr(); 5362 if (assignment == null 5363 || assignment.getExpressionType() != EExpressionType.assignment_t) { 5364 throw new SemanticIRBuildException(Diagnostic.error( 5365 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 5366 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 5367 + " is not a simple column-value assignment_t", 5368 merge)); 5369 } 5370 TExpression lhs = assignment.getLeftOperand(); 5371 TExpression rhs = assignment.getRightOperand(); 5372 if (lhs == null || rhs == null) { 5373 throw new SemanticIRBuildException(Diagnostic.error( 5374 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 5375 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 5376 + " is missing an operand", 5377 merge)); 5378 } 5379 if (lhs.getExpressionType() == EExpressionType.list_t) { 5380 throw new SemanticIRBuildException(Diagnostic.error( 5381 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 5382 "MERGE WHEN MATCHED UPDATE SET tuple assignment " 5383 + "'(a, b) = ...' is not supported", 5384 merge)); 5385 } 5386 if (lhs.getExpressionType() != EExpressionType.simple_object_name_t) { 5387 throw new SemanticIRBuildException(Diagnostic.error( 5388 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 5389 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 5390 + " LHS is expressionType=" + lhs.getExpressionType() 5391 + "; slice 94 admits simple column references only", 5392 merge)); 5393 } 5394 TObjectName targetCol = lhs.getObjectOperand(); 5395 if (targetCol == null) { 5396 throw new SemanticIRBuildException(Diagnostic.error( 5397 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 5398 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 5399 + " LHS has no TObjectName operand", 5400 merge)); 5401 } 5402 // Codex round-1 diff Q3 NO fix: SET LHS qualifier must be 5403 // either the target alias or the target qualified name. 5404 // A foreign qualifier (e.g. `s.name` on a SET LHS pointing 5405 // at source `s`) silently treated as a target column would 5406 // produce a wrong target column spelling. 5407 String rawSpelling = targetCol.toString(); 5408 String colSpelling = validateAndStripSetLhsQualifier( 5409 rawSpelling, targetTable, targetQName, merge); 5410 // Subquery / window on RHS — reuse existing codes per 5411 // plan v3 §B (codex round-1 Q2 NO fix). 5412 if (containsAnySubqueryExpression(rhs)) { 5413 throw new SemanticIRBuildException(Diagnostic.error( 5414 DiagnosticCode.UPDATE_SET_HAS_SUBQUERY_NOT_SUPPORTED, 5415 "MERGE WHEN MATCHED UPDATE SET assignment #" + (i + 1) 5416 + " right-hand side contains a subquery; " 5417 + "slice 94 admits scalar-only RHS expressions", 5418 merge)); 5419 } 5420 rejectWindowFunctionInScope(rhs, "MERGE WHEN MATCHED UPDATE SET RHS"); 5421 5422 String lowerKey = colSpelling.toLowerCase(Locale.ROOT); 5423 if (!seenTargetCols.containsKey(lowerKey)) { 5424 seenTargetCols.put(lowerKey, colSpelling); 5425 } 5426 // Per-WHEN action lineage: TABLE_COLUMN(target,col) ← <RHS ref> 5427 List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider); 5428 for (ColumnRef src : rhsRefs) { 5429 emitMergeLineageEdge(targetQName, colSpelling, src, 5430 lineage, emittedEdgeKeys, aliasToTableQName, 5431 aliasToSubIdx, targetAliases); 5432 } 5433 } 5434 } 5435 5436 /** 5437 * Slice 94 — process one WHEN NOT MATCHED THEN INSERT (cols) VALUES (exprs) 5438 * action. Emits one {@link LineageEdge} per (insert col, source ref) 5439 * pair, plus arity validation between the explicit column list and 5440 * VALUES list. If the column list is omitted, we cannot derive 5441 * target column names — the slice rejects defensively. 5442 */ 5443 private static void buildMergeInsertAction(TMergeInsertClause ins, 5444 String targetQName, 5445 TTable targetTable, 5446 NameBindingProvider provider, 5447 Map<String, String> seenTargetCols, 5448 List<LineageEdge> lineage, 5449 Set<String> emittedEdgeKeys, 5450 Map<String, String> aliasToTableQName, 5451 Map<String, Integer> aliasToSubIdx, 5452 Set<String> targetAliases, 5453 TMergeSqlStatement merge) { 5454 TResultColumnList values = ins.getValuelist(); 5455 gudusoft.gsqlparser.nodes.TObjectNameList colList = ins.getColumnList(); 5456 if (values == null || values.size() == 0) { 5457 throw new SemanticIRBuildException(Diagnostic.error( 5458 DiagnosticCode.MERGE_INSERT_DEFAULT_VALUES_NOT_SUPPORTED, 5459 "MERGE WHEN NOT MATCHED INSERT has no VALUES list " 5460 + "(DEFAULT VALUES / row-type forms not supported)", 5461 merge)); 5462 } 5463 // If an explicit column list is present, validate arity. 5464 if (colList != null && colList.size() > 0) { 5465 if (colList.size() != values.size()) { 5466 throw new SemanticIRBuildException(Diagnostic.error( 5467 DiagnosticCode.INSERT_COLUMN_COUNT_MISMATCH, 5468 "MERGE WHEN NOT MATCHED INSERT column list has " 5469 + colList.size() + " column(s) but VALUES " 5470 + "list has " + values.size(), 5471 merge)); 5472 } 5473 } 5474 for (int i = 0; i < values.size(); i++) { 5475 TResultColumn rc = values.getResultColumn(i); 5476 TExpression rhs = (rc == null) ? null : rc.getExpr(); 5477 if (rhs == null) { 5478 throw new SemanticIRBuildException(Diagnostic.error( 5479 DiagnosticCode.MERGE_INSERT_DEFAULT_VALUES_NOT_SUPPORTED, 5480 "MERGE WHEN NOT MATCHED INSERT VALUES item #" 5481 + (i + 1) + " has no expression", 5482 merge)); 5483 } 5484 if (containsAnySubqueryExpression(rhs)) { 5485 throw new SemanticIRBuildException(Diagnostic.error( 5486 DiagnosticCode.MERGE_INSERT_VALUES_HAS_SUBQUERY_NOT_SUPPORTED, 5487 "MERGE WHEN NOT MATCHED INSERT VALUES item #" 5488 + (i + 1) + " contains a subquery; slice 94 " 5489 + "admits scalar-only VALUES expressions", 5490 merge)); 5491 } 5492 rejectWindowFunctionInScope(rhs, "MERGE INSERT VALUES"); 5493 5494 // Target column name. If the explicit column list is 5495 // omitted, slice 94 does not synthesize positional target 5496 // column names (the catalog is required to map by 5497 // position; the current builder does not consume catalog 5498 // ordering). We still emit lineage edges from a synth 5499 // "__merge_insert_pos_<i>__" target column so source refs 5500 // are observable; users with an explicit column list get 5501 // the verbatim spelling. 5502 String colSpelling; 5503 if (colList != null && colList.size() > 0) { 5504 TObjectName col = colList.getObjectName(i); 5505 // INSERT column list is conventionally bare (no 5506 // qualifier), but Oracle / SQL Server allow 5507 // target-qualified spellings; strip them and reject 5508 // foreign qualifiers (codex round-1 diff Q3 NO fix). 5509 colSpelling = (col == null) ? ("__merge_insert_pos_" + i + "__") 5510 : validateAndStripSetLhsQualifier(col.toString(), 5511 targetTable, targetQName, merge); 5512 } else { 5513 colSpelling = "__merge_insert_pos_" + i + "__"; 5514 } 5515 String lowerKey = colSpelling.toLowerCase(Locale.ROOT); 5516 if (!seenTargetCols.containsKey(lowerKey)) { 5517 seenTargetCols.put(lowerKey, colSpelling); 5518 } 5519 List<ColumnRef> rhsRefs = collectColumnRefs(rhs, provider); 5520 for (ColumnRef src : rhsRefs) { 5521 emitMergeLineageEdge(targetQName, colSpelling, src, 5522 lineage, emittedEdgeKeys, aliasToTableQName, 5523 aliasToSubIdx, targetAliases); 5524 } 5525 } 5526 } 5527 5528 /** 5529 * Slice 94 — emit a single MERGE per-WHEN action lineage edge: 5530 * {@code TABLE_COLUMN(targetQName, colSpelling) ← <src ref>}. 5531 * Deduplicates on a lower-case key so the same (target column, 5532 * source ref) pair appearing in multiple WHEN clauses produces 5533 * one edge (codex round-2 Q4 confirmed YES on this dedup 5534 * strategy). 5535 */ 5536 private static void emitMergeLineageEdge(String targetQName, 5537 String colSpelling, 5538 ColumnRef src, 5539 List<LineageEdge> lineage, 5540 Set<String> emittedEdgeKeys, 5541 Map<String, String> aliasToTableQName, 5542 Map<String, Integer> aliasToSubIdx, 5543 Set<String> targetAliases) { 5544 if (src == null || colSpelling == null || colSpelling.isEmpty()) { 5545 return; 5546 } 5547 String srcAlias = src.getRelationAlias(); 5548 String srcCol = src.getColumnName(); 5549 if (srcCol == null || srcCol.isEmpty()) { 5550 return; 5551 } 5552 if (srcAlias == null) srcAlias = ""; 5553 String aliasKey = srcAlias.toLowerCase(Locale.ROOT); 5554 // Codex round-1 diff Q1 BLOCKING fix: skip only if the alias 5555 // is a target alias, NOT if the alias resolves to a same-named 5556 // table. Self-merge (USING with same name as target) must 5557 // distinguish target from USING by alias identity, not by 5558 // resolved table name. 5559 if (targetAliases.contains(aliasKey)) { 5560 return; 5561 } 5562 // Map alias → LineageRef (TABLE_COLUMN or STATEMENT_OUTPUT). 5563 LineageRef toRef; 5564 if (aliasToSubIdx.containsKey(aliasKey)) { 5565 toRef = LineageRef.statementOutput( 5566 aliasToSubIdx.get(aliasKey), srcCol); 5567 } else if (aliasToTableQName.containsKey(aliasKey)) { 5568 toRef = LineageRef.tableColumn( 5569 aliasToTableQName.get(aliasKey), srcCol); 5570 } else { 5571 // Unknown alias — skip to avoid emitting a bogus edge. 5572 // The ref still surfaces on joinColumnRefs[] (ON / WHEN-AND). 5573 return; 5574 } 5575 // Codex round-1 diff Q2 NO fix: dedup on resolved LineageRef 5576 // identity (not raw alias), so `s.name` (alias) and 5577 // `managers.name` (qualified name) coming from the SAME 5578 // resolved source produce ONE edge. The key embeds the toRef's 5579 // canonical form: STATEMENT_OUTPUT(idx,col) or 5580 // TABLE_COLUMN(qname,col) — both are lower-cased. 5581 String resolvedKey; 5582 if (toRef.getKind() == LineageRef.Kind.STATEMENT_OUTPUT) { 5583 resolvedKey = "STMT_OUT::" + toRef.getStatementIndex() + "::" 5584 + (toRef.getOutputName() == null ? "" : toRef.getOutputName()); 5585 } else { 5586 resolvedKey = "TBL_COL::" 5587 + (toRef.getQualifiedName() == null ? "" : toRef.getQualifiedName()) 5588 + "::" 5589 + (toRef.getColumnName() == null ? "" : toRef.getColumnName()); 5590 } 5591 String key = (targetQName + "::" + colSpelling + "::" 5592 + resolvedKey).toLowerCase(Locale.ROOT); 5593 if (emittedEdgeKeys.add(key)) { 5594 lineage.add(new LineageEdge( 5595 LineageRef.tableColumn(targetQName, colSpelling), 5596 toRef)); 5597 } 5598 } 5599 5600 /** 5601 * Slice 94 — validate the SET LHS / INSERT column-list spelling 5602 * and strip a leading target qualifier. Codex round-1 diff Q3 NO 5603 * fix: previously the helper returned a foreign-qualified spelling 5604 * unchanged, which silently produced a wrong target column (e.g. 5605 * {@code "s.name"} would land in the target columns list verbatim 5606 * instead of being rejected). 5607 * 5608 * <p>Admit rules: 5609 * <ul> 5610 * <li>Unqualified bare name: return unchanged.</li> 5611 * <li>Qualified by target alias or qualified name: strip.</li> 5612 * <li>Qualified by anything else: reject as 5613 * {@link DiagnosticCode#UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED} 5614 * (the same code used for slice-80 UPDATE-LHS shape rejects, 5615 * message text discriminates by mentioning the foreign 5616 * qualifier).</li> 5617 * </ul> 5618 */ 5619 private static String validateAndStripSetLhsQualifier(String spelling, 5620 TTable targetTable, 5621 String targetQName, 5622 TMergeSqlStatement merge) { 5623 if (spelling == null) return spelling; 5624 int dot = spelling.indexOf('.'); 5625 if (dot <= 0) return spelling; 5626 String qualifier = spelling.substring(0, dot); 5627 String bare = spelling.substring(dot + 1); 5628 String targetAlias = effectiveAliasOf(targetTable); 5629 if (targetAlias != null 5630 && qualifier.equalsIgnoreCase(targetAlias)) { 5631 return bare; 5632 } 5633 if (qualifier.equalsIgnoreCase(targetQName)) { 5634 return bare; 5635 } 5636 throw new SemanticIRBuildException(Diagnostic.error( 5637 DiagnosticCode.UPDATE_TUPLE_ASSIGNMENT_NOT_SUPPORTED, 5638 "MERGE SET LHS / INSERT column qualifier '" + qualifier 5639 + "' does not match the target table; slice 94 " 5640 + "admits target-qualified or unqualified target " 5641 + "column references only", 5642 merge)); 5643 } 5644 5645 /** 5646 * Slice 84 — process one FROM-side source table for joined 5647 * {@link #buildDelete}. Mirrors slice-82 {@link #buildUpdateRelation}. 5648 * Applies the slice-84 reject contract for nested-join wrappers, 5649 * then appends a TABLE-kind {@link RelationSource} unless the 5650 * table is the target (reference-identity filter — clean IR 5651 * semantics: relations[] models read-side sources only). For 5652 * subquery sources (already extracted in step 4.7), publishes a 5653 * SUBQUERY-kind {@link RelationSource} so the inScope-enhanced 5654 * provider can route {@code sub.col} references. 5655 * 5656 * <p>Null-driver guard: probed PG 5657 * {@code DELETE FROM e USING (t1 JOIN t2 ON …)} returns 5658 * {@code refJoin[0].getTable() == null}. Silent skip mirrors 5659 * slice-82's null guard for the analogous UPDATE case; 5660 * documented as a known limitation (parenthesized JOIN-in-USING 5661 * is opaque to {@code relations[]} though WHERE refs still bind 5662 * via Resolver2). 5663 */ 5664 private static void buildDeleteRelation(TTable t, TTable targetTable, 5665 List<RelationSource> relations, 5666 TDeleteSqlStatement delete, 5667 Map<String, Integer> cteNameToStatementIndex) { 5668 if (t == null) { 5669 return; // defensive — parenthesized JOIN-in-USING surfaces null 5670 } 5671 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 5672 // Slice 84 — admit FROM-side subqueries. The inner SELECT 5673 // has already been extracted as its own StatementGraph by 5674 // extractDeleteFromSubqueries. Publish a SUBQUERY-kind 5675 // RelationSource so the inScope-enhanced provider routes 5676 // `sub.col` references correctly. Mirrors slice-83 5677 // buildUpdateRelation's subquery branch. 5678 String subAlias = effectiveAliasOf(t); 5679 if (subAlias != null && !subAlias.isEmpty()) { 5680 relations.add(new RelationSource(subAlias, 5681 new RelationBinding(RelationKind.SUBQUERY, subAlias))); 5682 } 5683 return; 5684 } 5685 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 5686 // Defensive: TTable wrapping a TJoin. Not reached by any 5687 // observed parser path on supported dialects (slice-82 5688 // precedent — parenthesized JOIN-in-USING surfaces a null 5689 // driver, not a join-typed TTable). Distinct DiagnosticCode 5690 // per slice-80's message-text-discrimination contract. 5691 throw new SemanticIRBuildException(Diagnostic.error( 5692 DiagnosticCode.DELETE_FROM_NESTED_JOIN_NOT_SUPPORTED, 5693 "DELETE FROM source is a nested join wrapper; " 5694 + "slice 84 admits simple table / subquery " 5695 + "FROM sources only", 5696 delete)); 5697 } 5698 // Reference-identity filter: target's own TTable instance is 5699 // excluded from relations[]. Different TTable instances with 5700 // the same qualified name (e.g. MSSQL `DELETE FROM t FROM t 5701 // spqh JOIN sp` where target identity A and FROM-driver 5702 // identity B share name "t") both stay — the catalog-miss 5703 // WARN walker's pass-1-target-then-pass-2-relations ordering 5704 // (slice 83) deduplicates by qualified name. 5705 if (t == targetTable) { 5706 return; 5707 } 5708 TObjectName tName = t.getTableName(); 5709 if (tName == null) { 5710 return; // defensive 5711 } 5712 // Slice 106 — FROM-side CTE detection. When the FROM-side table 5713 // is an objectname-typed reference whose bare name matches a 5714 // declared CTE in this DELETE's outer WITH clause, emit a 5715 // SUBQUERY-kind RelationSource pointing at the CTE statement 5716 // (mirrors slice-105 buildUpdateRelation). The slice-77 catalog- 5717 // miss WARN walker filters to RelationKind.TABLE so CTE-bound 5718 // relations are naturally skipped, even when the catalog also 5719 // declares the same name (slice-105 §G / §X precedent). 5720 // 5721 // Explicit objectname guard (codex round-1 NICE Q3): subquery / 5722 // join table types are handled by the early returns above; this 5723 // guard documents the contract and makes the branch resilient 5724 // if a future TTable type is added. 5725 if (cteNameToStatementIndex != null 5726 && !cteNameToStatementIndex.isEmpty() 5727 && t.getTableType() 5728 == gudusoft.gsqlparser.ETableSource.objectname) { 5729 String bareName = tName.toString(); 5730 if (bareName != null && !bareName.isEmpty()) { 5731 String bareNameLower = bareName.toLowerCase(Locale.ROOT); 5732 if (cteNameToStatementIndex.containsKey(bareNameLower)) { 5733 String cteAlias = effectiveAliasOf(t); 5734 if (cteAlias == null || cteAlias.isEmpty()) { 5735 cteAlias = bareName; 5736 } 5737 relations.add(new RelationSource(cteAlias, 5738 new RelationBinding(RelationKind.SUBQUERY, cteAlias))); 5739 return; 5740 } 5741 } 5742 } 5743 relations.add(new RelationSource(effectiveAliasOf(t), 5744 new RelationBinding(RelationKind.TABLE, tName.toString()))); 5745 } 5746 5747 /** 5748 * Slice 84 — process one {@link TJoinItem} for joined 5749 * {@link #buildDelete}. Mirrors slice-82 {@link #buildUpdateJoinItem}. 5750 * Applies the slice-84 reject contract for USING / NATURAL / 5751 * subquery-in-ON, processes the right-side table via 5752 * {@link #buildDeleteRelation}, and collects ON-clause column 5753 * refs into {@code joinRefs} via the shared 5754 * {@link #collectColumnRefs} helper. 5755 */ 5756 private static void buildDeleteJoinItem(TJoinItem item, TTable targetTable, 5757 NameBindingProvider provider, 5758 List<RelationSource> relations, 5759 java.util.LinkedHashSet<ColumnRef> joinRefs, 5760 TDeleteSqlStatement delete, 5761 Map<String, Integer> cteNameToStatementIndex) { 5762 if (item == null) return; 5763 if (item.getUsingColumns() != null && item.getUsingColumns().size() > 0) { 5764 throw new SemanticIRBuildException(Diagnostic.error( 5765 DiagnosticCode.DELETE_FROM_JOIN_USING_NOT_SUPPORTED, 5766 "DELETE FROM join uses USING(...); slice 84 admits " 5767 + "JOIN ON / CROSS JOIN / comma-FROM only", 5768 item)); 5769 } 5770 if (isNaturalJoinType(item.getJoinType())) { 5771 throw new SemanticIRBuildException(Diagnostic.error( 5772 DiagnosticCode.DELETE_FROM_JOIN_NATURAL_NOT_SUPPORTED, 5773 "DELETE FROM uses NATURAL JOIN; slice 84 admits " 5774 + "JOIN ON / CROSS JOIN / comma-FROM only", 5775 item)); 5776 } 5777 // Right-side table: apply the same source-shape rejects + 5778 // identity filter as the driver table. Slice 106 — threads 5779 // cteNameToStatementIndex so right-side CTE refs (MSSQL 5780 // `FROM target t JOIN cte ON …`) get SUBQUERY-kind emission. 5781 buildDeleteRelation(item.getTable(), targetTable, relations, delete, 5782 cteNameToStatementIndex); 5783 // ON-clause refs: subquery rejects with slice-84 code; 5784 // window function reuses CLAUSE_WINDOW_FUNCTION_LEAK via the 5785 // shared helper. CROSS JOIN has no ON; skip the walk entirely. 5786 TExpression onCond = item.getOnCondition(); 5787 if (onCond == null) return; 5788 if (containsAnySubqueryExpression(onCond)) { 5789 throw new SemanticIRBuildException(Diagnostic.error( 5790 DiagnosticCode.DELETE_JOIN_ON_HAS_SUBQUERY_NOT_SUPPORTED, 5791 "DELETE FROM JOIN ON condition contains a subquery; " 5792 + "slice 84 admits scalar predicates only", 5793 item)); 5794 } 5795 rejectWindowFunctionInScope(onCond, "DELETE FROM JOIN ON"); 5796 joinRefs.addAll(collectColumnRefs(onCond, provider)); 5797 } 5798 5799 /** 5800 * Slice 84 — extract every FROM-side subquery in 5801 * {@code delete.getReferenceJoins()} as its own 5802 * {@link StatementGraph} appended to {@code stmts} before the 5803 * DELETE itself. Walks both the driver TTable of each TJoin AND 5804 * each JoinItem's right table. Returns an alias → stmts-index 5805 * map so the consuming DELETE can (a) build its in-scope column 5806 * map via {@link #buildDeleteInScopeMap}, and (b) bind 5807 * {@code sub.col} references in WHERE / ON via the 5808 * inScope-enhanced provider. 5809 * 5810 * <p>Mirrors slice-83 {@link #extractUpdateFromSubqueries} but 5811 * walks {@code delete.getReferenceJoins()} instead of 5812 * {@code update.getJoins()}. Reuses the SELECT-side 5813 * {@link #processDirectSubqueryTable} verbatim, forwarding the 5814 * outer-WITH {@code cteNameToStatementIndex} + 5815 * {@code ctePublishedColumns} (slice 106) so a nested SELECT in 5816 * an extracted FROM-subquery body can resolve outer-CTE refs. 5817 * Pre-slice-106 the maps were always empty because slice 81 5818 * rejected top-level WITH on DELETE 5819 * ({@link DiagnosticCode#DELETE_CTE_NOT_SUPPORTED}). 5820 * 5821 * <p>No mutation-guard wrapper: buildDelete owns fresh local 5822 * lists and exceptions propagate cleanly to the caller. 5823 */ 5824 private static Map<String, Integer> extractDeleteFromSubqueries( 5825 TDeleteSqlStatement delete, 5826 NameBindingProvider provider, 5827 List<StatementGraph> stmts, 5828 List<LineageEdge> lineage, 5829 Map<String, Integer> cteNameToStatementIndex, 5830 Map<String, List<String>> ctePublishedColumns) { 5831 Map<String, Integer> aliasToIndex = new HashMap<>(); 5832 TJoinList refJoins = delete.getReferenceJoins(); 5833 if (refJoins == null) return aliasToIndex; 5834 // Slice 106 — forward the outer-WITH CTE maps so a nested SELECT 5835 // inside an extracted FROM-subquery body can resolve outer-WITH 5836 // CTE references. Resolver2 wires CTEScope; the maps are 5837 // forwarded for parity with the SELECT / MERGE / UPDATE call 5838 // sites and so the §N test for 5839 // `USING (SELECT … FROM cte) sub` produces the expected 5840 // cross-stmt lineage edge to the CTE body. 5841 Map<String, Integer> cteMap = cteNameToStatementIndex == null 5842 ? Collections.<String, Integer>emptyMap() 5843 : cteNameToStatementIndex; 5844 Map<String, List<String>> ctePublished = ctePublishedColumns == null 5845 ? Collections.<String, List<String>>emptyMap() 5846 : ctePublishedColumns; 5847 for (int ji = 0; ji < refJoins.size(); ji++) { 5848 TJoin join = refJoins.getJoin(ji); 5849 // Driver table — may be a subquery (PG / SF / BQ / RS 5850 // `DELETE FROM t USING (SELECT …) sub` shape). 5851 processDirectSubqueryTable(join.getTable(), provider, 5852 stmts, lineage, cteMap, ctePublished, aliasToIndex); 5853 TJoinItemList items = join.getJoinItems(); 5854 if (items == null) continue; 5855 for (int i = 0; i < items.size(); i++) { 5856 TJoinItem item = items.getJoinItem(i); 5857 if (item == null) continue; 5858 // Right-side table of a JoinItem — may be a subquery 5859 // (MSSQL / PG `DELETE FROM t FROM x JOIN (SELECT …) 5860 // sub ON …` shape). 5861 processDirectSubqueryTable(item.getTable(), provider, 5862 stmts, lineage, cteMap, ctePublished, aliasToIndex); 5863 } 5864 } 5865 return aliasToIndex; 5866 } 5867 5868 /** 5869 * Slice 84 — build an effective-alias-keyed in-scope map publishing 5870 * each extracted DELETE FROM-subquery's output column names. 5871 * Mirrors slice-83 {@link #buildUpdateInScopeMap} but walks 5872 * {@code delete.getReferenceJoins()}. 5873 * 5874 * <p>Base-table FROM-side relations do not need an entry: their 5875 * column resolution stays on the Resolver2 catalog path 5876 * (probed correct for PG / MSSQL DELETE — see slice-84 plan 5877 * §Codex Q4 + Q11). 5878 */ 5879 private static Map<String, List<String>> buildDeleteInScopeMap( 5880 TDeleteSqlStatement delete, 5881 Map<String, Integer> subqueryAliasToIndex, 5882 List<StatementGraph> stmts, 5883 Map<String, Integer> cteNameToStatementIndex, 5884 Map<String, List<String>> ctePublishedColumns) { 5885 Map<String, List<String>> result = new HashMap<>(); 5886 boolean haveSubq = subqueryAliasToIndex != null 5887 && !subqueryAliasToIndex.isEmpty(); 5888 boolean haveCte = cteNameToStatementIndex != null 5889 && !cteNameToStatementIndex.isEmpty(); 5890 if (!haveSubq && !haveCte) { 5891 return result; 5892 } 5893 TJoinList refJoins = delete.getReferenceJoins(); 5894 if (refJoins == null) return result; 5895 for (int ji = 0; ji < refJoins.size(); ji++) { 5896 TJoin join = refJoins.getJoin(ji); 5897 addDeleteRelationToInScopeMap(join.getTable(), 5898 subqueryAliasToIndex, stmts, result, 5899 cteNameToStatementIndex, ctePublishedColumns); 5900 TJoinItemList items = join.getJoinItems(); 5901 if (items == null) continue; 5902 for (int i = 0; i < items.size(); i++) { 5903 TJoinItem item = items.getJoinItem(i); 5904 if (item == null) continue; 5905 addDeleteRelationToInScopeMap(item.getTable(), 5906 subqueryAliasToIndex, stmts, result, 5907 cteNameToStatementIndex, ctePublishedColumns); 5908 } 5909 } 5910 return result; 5911 } 5912 5913 private static void addDeleteRelationToInScopeMap(TTable t, 5914 Map<String, Integer> subqueryAliasToIndex, 5915 List<StatementGraph> stmts, 5916 Map<String, List<String>> result, 5917 Map<String, Integer> cteNameToStatementIndex, 5918 Map<String, List<String>> ctePublishedColumns) { 5919 if (t == null) return; 5920 // Slice 106 — CTE-as-FROM-relation in-scope publication. When 5921 // the FROM-side table is an objectname-typed reference whose 5922 // bare name matches a declared outer CTE, publish the CTE's 5923 // own column names against the FROM-side effective alias so 5924 // WHERE / ON / RETURNING refs against the CTE alias bind 5925 // correctly. Mirrors slice-105 addUpdateRelationToInScopeMap. 5926 if (cteNameToStatementIndex != null 5927 && !cteNameToStatementIndex.isEmpty() 5928 && ctePublishedColumns != null 5929 && t.getTableType() 5930 == gudusoft.gsqlparser.ETableSource.objectname) { 5931 TObjectName tName = t.getTableName(); 5932 if (tName != null) { 5933 String bare = tName.toString(); 5934 if (bare != null && !bare.isEmpty()) { 5935 String bareLower = bare.toLowerCase(Locale.ROOT); 5936 if (cteNameToStatementIndex.containsKey(bareLower)) { 5937 String aliasKey = effectiveAliasLowerCaseOrNull(t); 5938 if (aliasKey == null) aliasKey = bareLower; 5939 List<String> cols = ctePublishedColumns.get(bareLower); 5940 if (cols != null) { 5941 result.put(aliasKey, cols); 5942 } 5943 return; 5944 } 5945 } 5946 } 5947 } 5948 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) { 5949 return; 5950 } 5951 if (subqueryAliasToIndex == null) { 5952 return; 5953 } 5954 String key = effectiveAliasLowerCaseOrNull(t); 5955 if (key == null) return; 5956 Integer idx = subqueryAliasToIndex.get(key); 5957 if (idx == null) return; 5958 result.put(key, outputColumnNames(stmts.get(idx))); 5959 } 5960 5961 /** 5962 * Slice 92 — returns {@code true} when the MySQL DELETE statement is a 5963 * self-reference single-target form ({@code DELETE T1 FROM T1 [WHERE …]}) 5964 * that is semantically equivalent to {@code DELETE FROM T1 [WHERE …]}. 5965 * 5966 * <p>The check requires ALL of the following (codex plan-review Q1+Q5 5967 * BLOCKING fix — checking only {@code joins[0]} is insufficient because 5968 * {@code DELETE T1 FROM T2} also has {@code joins.size==1} and 5969 * {@code joins[0].table=="T1"==targetQName}, yet the FROM clause points 5970 * to a different table): 5971 * <ol> 5972 * <li>{@code joins.size == 1} — exactly one MySQL target list entry.</li> 5973 * <li>{@code getReferenceJoins().size() == 1} — exactly one FROM clause 5974 * table.</li> 5975 * <li>{@code joins[0]} has no JoinItems — must be a plain table, not a 5976 * JOIN chain.</li> 5977 * <li>{@code refJoins[0]} has no JoinItems — same constraint.</li> 5978 * <li>{@code joins[0].table.name.toLowerCase() == targetQName.toLowerCase()}. 5979 * </li> 5980 * <li>{@code refJoins[0].table.name.toLowerCase() == targetQName.toLowerCase()}. 5981 * </li> 5982 * </ol> 5983 */ 5984 private static boolean isMysqlSelfReferenceDelete( 5985 TDeleteSqlStatement delete, String targetQName) { 5986 if (delete.joins == null || delete.joins.size() != 1) return false; 5987 TJoinList ref = delete.getReferenceJoins(); 5988 if (ref == null || ref.size() != 1) return false; 5989 TJoin join0 = delete.joins.getJoin(0); 5990 if (join0.getJoinItems() != null && join0.getJoinItems().size() > 0) { 5991 return false; 5992 } 5993 TJoin ref0 = ref.getJoin(0); 5994 if (ref0.getJoinItems() != null && ref0.getJoinItems().size() > 0) { 5995 return false; 5996 } 5997 TTable joinTable = join0.getTable(); 5998 if (joinTable == null || joinTable.getTableName() == null) return false; 5999 TTable refTable = ref0.getTable(); 6000 if (refTable == null || refTable.getTableName() == null) return false; 6001 String lowerTarget = targetQName.toLowerCase(java.util.Locale.ROOT); 6002 String joinName = joinTable.getTableName().toString() 6003 .toLowerCase(java.util.Locale.ROOT); 6004 String refName = refTable.getTableName().toString() 6005 .toLowerCase(java.util.Locale.ROOT); 6006 6007 // Codex diff-review P1 fix: MySQL allows the alias in the DELETE 6008 // target list instead of the table name: 6009 // DELETE t FROM T1 AS t WHERE t.id = 1 6010 // In this form joins[0].table.name = "t" (the alias used in the 6011 // delete-list) while targetQName = "T1" (from getTargetTable() 6012 // which the parser resolves to the real table). Accept the target 6013 // table's alias as a valid joins[0] match alongside the table name. 6014 String targetAlias = null; 6015 if (delete.getTargetTable() != null 6016 && delete.getTargetTable().getAliasClause() != null 6017 && delete.getTargetTable().getAliasClause().getAliasName() != null) { 6018 String a = delete.getTargetTable().getAliasClause() 6019 .getAliasName().toString(); 6020 if (a != null && !a.isEmpty()) { 6021 targetAlias = a.toLowerCase(java.util.Locale.ROOT); 6022 } 6023 } 6024 boolean joinMatchesTarget = joinName.equals(lowerTarget) 6025 || (targetAlias != null && joinName.equals(targetAlias)); 6026 // refJoins[0].table must always be the real table name (= targetQName). 6027 return joinMatchesTarget && refName.equals(lowerTarget); 6028 } 6029 6030 /** 6031 * Per-result-column metadata about an extracted scalar-subquery 6032 * projection (slice 11). {@link #statementIndex} points to the 6033 * inner body statement; {@link #innerOutputName} is the inner 6034 * SELECT's single projected output name (used to wire the 6035 * STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edge). 6036 */ 6037 private static final class ScalarInfo { 6038 final int statementIndex; 6039 final String innerOutputName; 6040 ScalarInfo(int statementIndex, String innerOutputName) { 6041 this.statementIndex = statementIndex; 6042 this.innerOutputName = innerOutputName; 6043 } 6044 } 6045 6046 /** 6047 * Walk the consuming SELECT's FROM list. For every {@link TTable} of 6048 * type {@link gudusoft.gsqlparser.ETableSource#subquery}, recursively 6049 * build the inner statement, append it to {@code stmts}, emit its own 6050 * lineage edges, and record alias→statementIndex. The returned map is 6051 * scoped to this single consuming statement so duplicate aliases 6052 * across different scopes do not collide. 6053 * 6054 * <p>Slice 17: extraction now walks BOTH sides of every JOIN 6055 * ({@code TJoin.getTable()} for the left, {@code joinItems[i].getTable()} 6056 * for each right) and recurses into nested FROM-subquery bodies. Each 6057 * recursive level pre-extracts its own children before calling 6058 * {@code buildSelectStatement}, preserving the 6059 * {@code BodyIndexes}-required ordering (innermost body before 6060 * its consumer). FROM-subquery bodies still recurse with 6061 * {@code allowScalarProjectionSubqueries=false} (slice-15 invariant 6062 * pinned by {@code Slice15Test.scalarProjectionInsideFromSubqueryBodyStillRejected}). 6063 * 6064 * <p>Slice 18 lifts CTE bodies (the non-set-op CTE-body branch in 6065 * {@link #build} now invokes this extractor with 6066 * {@code allowFromSubqueries=true}). Still rejected: subqueries with 6067 * no alias, FROM-subqueries inside a scalar body / set-op branch / 6068 * set-op CTE body (each enforced by the caller's 6069 * {@code allowFromSubqueries=false}), and predicate subqueries inside 6070 * the FROM-subquery body's WHERE / JOIN ON / GROUP BY (slice-17 6071 * helper {@link #rejectSubqueriesInFromSubqueryBodyClauses}). 6072 */ 6073 private static Map<String, Integer> extractFromSubqueriesAsStatements( 6074 TSelectSqlStatement consumer, 6075 NameBindingProvider consumerProvider, 6076 List<StatementGraph> stmts, 6077 List<LineageEdge> lineage, 6078 Map<String, Integer> cteNameToStatementIndex, 6079 Map<String, List<String>> ctePublishedColumns) { 6080 Map<String, Integer> aliasToIndex = new HashMap<>(); 6081 if (consumer.joins == null) return aliasToIndex; 6082 // Slice 17 mutation-free preflight: walk the entire direct 6083 // FROM/JOIN list once and reject before any mutation of 6084 // stmts/lineage. Catches comma-FROM, anonymous subqueries, 6085 // unsupported join shapes, and ALL same-level alias collisions 6086 // (base AND subquery, since rejectDuplicateAliases inside 6087 // buildRelations only catches them later, after this level's 6088 // subquery body has already landed in stmts). 6089 preflightDirectFromList(consumer); 6090 6091 // Slice 17: walk both sides of every join and process each 6092 // direct subquery via the same helper so left/right can't drift. 6093 for (TJoin join : consumer.joins) { 6094 processDirectSubqueryTable(join.getTable(), 6095 consumerProvider, stmts, lineage, 6096 cteNameToStatementIndex, ctePublishedColumns, aliasToIndex); 6097 TJoinItemList items = join.getJoinItems(); 6098 if (items == null) continue; 6099 for (int i = 0; i < items.size(); i++) { 6100 TJoinItem item = items.getJoinItem(i); 6101 if (item == null) continue; 6102 processDirectSubqueryTable(item.getTable(), 6103 consumerProvider, stmts, lineage, 6104 cteNameToStatementIndex, ctePublishedColumns, aliasToIndex); 6105 } 6106 } 6107 return aliasToIndex; 6108 } 6109 6110 /** 6111 * Slice 17 mutation-free preflight for the consumer's direct 6112 * FROM/JOIN list. Validates structural invariants BEFORE any 6113 * subquery body is appended to {@code stmts} so a deferred 6114 * failure (e.g. on the second of two siblings) doesn't strand 6115 * earlier-sibling output in the program. 6116 * 6117 * <p>Slice 62 (codex plan-review round 1): the comma-FROM 6118 * reject was removed here. The preflight runs only for 6119 * {@code allowFromSubqueries=true} paths (outer SELECT, CTE 6120 * body, FROM-subquery body recursion) — exactly the paths 6121 * that admit comma-FROM under slice 62. Synthetic body 6122 * contexts (scalar / set-op-branch / set-op-CTE / predicate) 6123 * do not run this preflight; they reach the gated reject in 6124 * {@link #buildRelations} (and predicate bodies hit the 6125 * earlier slice-62 reject inside 6126 * {@link #preflightPredicateSubqueryShape}). 6127 */ 6128 private static void preflightDirectFromList(TSelectSqlStatement consumer) { 6129 if (consumer.joins == null) return; 6130 Set<String> seenSubqueryAliases = new HashSet<>(); 6131 Set<String> seenAllAliases = new HashSet<>(); 6132 for (TJoin join : consumer.joins) { 6133 preflightOneTable(join.getTable(), seenSubqueryAliases, seenAllAliases); 6134 TJoinItemList items = join.getJoinItems(); 6135 if (items == null) continue; 6136 for (int i = 0; i < items.size(); i++) { 6137 TJoinItem item = items.getJoinItem(i); 6138 if (item == null) continue; 6139 rejectUnsupportedJoinShape(item); 6140 preflightOneTable(item.getTable(), seenSubqueryAliases, seenAllAliases); 6141 } 6142 } 6143 } 6144 6145 /** 6146 * Slice 17: validate one direct FROM/JOIN-list TTable in the 6147 * mutation-free preflight. Effective alias is the SQL-written alias 6148 * if present, else the slice-74 synthetic alias for unaliased 6149 * FROM-subqueries (position-keyed), else the table name (matches 6150 * {@link #buildRelation}). 6151 * 6152 * <p>Slice 74: removed the {@code FROM_SUBQUERY_ALIAS_REQUIRED} reject 6153 * for anonymous subqueries; the slot is now filled by 6154 * {@link FromSubqueryNaming#synthAliasFor}. Two unaliased subqueries 6155 * at the same source location are theoretically impossible (the 6156 * parser would have to emit the same start token for both), but if 6157 * it ever happens the {@code DUPLICATE_FROM_SUBQUERY_ALIAS} branch 6158 * below catches it the same way as a literal user-written duplicate. 6159 * 6160 * <p>Still rejects: duplicate subquery aliases (whether user-written 6161 * or synthetic by collision), and any cross-kind alias collision 6162 * (base alias colliding with a subquery alias). 6163 */ 6164 private static void preflightOneTable(TTable t, 6165 Set<String> seenSubqueryAliases, 6166 Set<String> seenAllAliases) { 6167 if (t == null) return; 6168 boolean isSub = t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery; 6169 String effective = effectiveAliasOf(t); 6170 if (effective == null || effective.isEmpty()) return; 6171 String lower = effective.toLowerCase(Locale.ROOT); 6172 if (isSub && !seenSubqueryAliases.add(lower)) { 6173 throw new SemanticIRBuildException( 6174 Diagnostic.error(DiagnosticCode.DUPLICATE_FROM_SUBQUERY_ALIAS, 6175 "duplicate FROM-clause subquery alias '" + effective + "'", (TParseTreeNode) null)); 6176 } 6177 if (!seenAllAliases.add(lower)) { 6178 throw new SemanticIRBuildException( 6179 Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS, 6180 "duplicate relation alias '" + effective 6181 + "' is not supported (would make ColumnRef ambiguous)", (TParseTreeNode) null)); 6182 } 6183 } 6184 6185 /** 6186 * Slice 17: extract one direct subquery TTable as its own 6187 * StatementGraph. Recurses into the inner SELECT first 6188 * (innermost body lands in {@code stmts} BEFORE its consumer, as 6189 * {@code BodyIndexes} requires). Skips non-subquery tables (base 6190 * relations are bound later by {@code buildRelations}). 6191 */ 6192 private static void processDirectSubqueryTable( 6193 TTable t, 6194 NameBindingProvider consumerProvider, 6195 List<StatementGraph> stmts, 6196 List<LineageEdge> lineage, 6197 Map<String, Integer> cteNameToStatementIndex, 6198 Map<String, List<String>> ctePublishedColumns, 6199 Map<String, Integer> aliasToIndex) { 6200 if (t == null) return; 6201 // Slice 136: a PIVOT / UNPIVOT source can itself be a FROM-subquery 6202 // (`FROM (SELECT ...) [alias] PIVOT(...)`). Unwrap the pivoted_table to 6203 // its underlying source relation so the inner SELECT is extracted and 6204 // registered HERE — that lands the source-subquery alias in the 6205 // pre-pass's aliasToIndex (== build()'s outerSubqueryAliasToIndex) 6206 // BEFORE emitLineageForStatement runs, so the pivot→subquery cross-stmt 6207 // edges resolve. A base-table pivot source falls through the 6208 // non-subquery early-return below unchanged. 6209 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 6210 && t.getPivotedTable() != null 6211 && !t.getPivotedTable().getRelations().isEmpty()) { 6212 t = t.getPivotedTable().getRelations().get(0); 6213 if (t == null) return; 6214 } 6215 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery) return; 6216 // Alias presence/uniqueness already validated by the preflight. 6217 // Slice 74: anonymous (unaliased) subqueries get a synth name 6218 // from FromSubqueryNaming via effectiveAliasOf so the alias used 6219 // for the aliasToIndex map and inner-stmt name is non-null. 6220 String alias = effectiveAliasOf(t); 6221 String aliasLower = alias.toLowerCase(Locale.ROOT); 6222 TSelectSqlStatement inner = t.getSubquery(); 6223 if (inner == null) { 6224 throw new SemanticIRBuildException( 6225 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_NO_INNER_SELECT, 6226 "FROM-clause subquery '" + alias + "' has no inner SELECT", (TParseTreeNode) null)); 6227 } 6228 // Slice 17 leak guard: predicate subqueries inside the 6229 // FROM-subquery body's WHERE / JOIN ON / GROUP BY would 6230 // otherwise slip past `allowScalarProjectionSubqueries=false` 6231 // (which only guards buildOutputColumns) and leak inner refs 6232 // into the body's filter/join/group ref lists. 6233 rejectSubqueriesInFromSubqueryBodyClauses(inner, alias); 6234 // Recurse into the inner's own FROM-subqueries first so each 6235 // deeper body lands in stmts BEFORE the body that consumes it. 6236 // The recursive call uses `consumerProvider` because the inner 6237 // sees the same CTE-name set as the outer (CTEs are visible 6238 // through FROM-subquery bodies — pinned by 6239 // Slice5Test.cteVisibleInsideFromSubquery). 6240 // Slice 60: thread ctePublishedColumns down unchanged. The 6241 // inner's siblings get registered into innerSubAliasToIndex 6242 // here; below we build the per-level innerInScope BEFORE 6243 // calling buildSelectStatement. 6244 Map<String, Integer> innerSubAliasToIndex = 6245 extractFromSubqueriesAsStatements(inner, consumerProvider, 6246 stmts, lineage, cteNameToStatementIndex, 6247 ctePublishedColumns); 6248 // Slice 60 (codex diff-review): build the inner FROM-subquery 6249 // body's effective-alias-keyed in-scope map by walking the 6250 // inner SELECT's FROM list. Sibling isolation is preserved 6251 // because `innerSubAliasToIndex` contains ONLY this body's 6252 // own children — ancestor siblings are never visited by the 6253 // walk because they're not in the inner's FROM list. 6254 Map<String, List<String>> innerInScope = buildEffectiveAliasInScopeMap( 6255 inner, consumerProvider, ctePublishedColumns, 6256 innerSubAliasToIndex, stmts); 6257 NameBindingProvider innerProviderWithStar = consumerProvider 6258 .withInScopeRelationColumns(innerInScope); 6259 // Slice 120 — switch from the 7-arg buildSelectStatement to the 6260 // 14-arg buildSelectStatementImpl so the FROM-subquery body's 6261 // WHERE clause can extract uncorrelated predicate subqueries 6262 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 6263 // ANY-ALL-SOME) as their own statements (mirrors the slice-114 6264 // CTE-body lift). JOIN-ON predicate subqueries stay rejected (the 6265 // two flags are independent per the slice-113 split) — the 6266 // slice-17 leak guard rejectSubqueriesInFromSubqueryBodyClauses 6267 // above still fires for the body's JOIN-ON / GROUP-BY clauses. 6268 // allowFromSubqueries=true so its buildRelations accepts 6269 // already-extracted subquery aliases; allowScalarProjectionSubqueries 6270 // =false (slice-15 invariant). The snapshot/rollback wrapper 6271 // mirrors the slice-114 CTE-body call site: if the build appends 6272 // predicate bodies and then a later reject fires, stmts/lineage 6273 // truncate back to the pre-call boundary so a partial extraction 6274 // does not leak into the program. processDirectSubqueryTable is 6275 // shared by the SELECT / UPDATE (slice 83) / DELETE (slice 84) 6276 // FROM-subquery extractors, so this single site lifts all three. 6277 int fromBodyStmtsSnapshot = stmts.size(); 6278 int fromBodyLineageSnapshot = lineage.size(); 6279 StatementGraph innerStmt; 6280 try { 6281 if (isPivotSelect(inner)) { 6282 // Slice 138: a nested PIVOT / UNPIVOT in a FROM-subquery body 6283 // (`FROM (SELECT ... FROM base PIVOT(...)) t`). The outer-SELECT 6284 // pivot router in buildSelectStatementImpl is gated to 6285 // `name == null`, so a pivot body extracted here (name == alias) 6286 // would otherwise fall to the normal path and reject with 6287 // TABLE_BINDING_UNRESOLVED ("null(piviot_table)"). Route it to 6288 // buildPivotSelect with the subquery alias as the statement name 6289 // so the extracted statement becomes a proper pivot StatementGraph; 6290 // the outer query then references it as a SUBQUERY-kind relation 6291 // and the slice-136 emitLineageForStatement call below wires the 6292 // pivot output → source edges (base-table TABLE_COLUMN, or — when 6293 // the pivot's own source is a subquery — STATEMENT_OUTPUT via the 6294 // innerSubAliasToIndex already populated by the recursion above). 6295 // PIVOT extra-clause / chained / set-op-branch nesting stay 6296 // deferred via buildPivotSelect's existing rejects. 6297 innerStmt = buildPivotSelect(inner, innerProviderWithStar, alias); 6298 } else { 6299 innerStmt = buildSelectStatementImpl(inner, innerProviderWithStar, alias, 6300 /*hasOuterCteListAlreadyProcessed=*/ false, 6301 /*allowFromSubqueries=*/ true, 6302 /*allowScalarProjectionSubqueries=*/ false, 6303 /*allowWindowProjection=*/ true, 6304 /*allowJoinOnPredicateSubqueries=*/ false, 6305 /*stmtsForExtraction=*/ stmts, 6306 /*lineageForExtraction=*/ lineage, 6307 /*cteMapForExtraction=*/ cteNameToStatementIndex, 6308 /*isPredicateBody=*/ false, 6309 /*whereClauseContext=*/ PredicateClauseContext.FROM_SUBQUERY_BODY_WHERE, 6310 /*allowWherePredicateSubqueries=*/ true); 6311 } 6312 } catch (RuntimeException ex) { 6313 while (stmts.size() > fromBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 6314 while (lineage.size() > fromBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 6315 throw ex; 6316 } 6317 int idx = stmts.size(); 6318 stmts.add(innerStmt); 6319 aliasToIndex.put(aliasLower, idx); 6320 // Emit lineage with the inner's own subquery alias map (so 6321 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edges target the inner's 6322 // children, not the outer's). Scalar map stays empty because 6323 // FROM-subquery bodies still reject scalar projections 6324 // (slice-15 invariant). 6325 emitLineageForStatement(innerStmt, idx, lineage, 6326 cteNameToStatementIndex, 6327 innerSubAliasToIndex, 6328 Collections.<Integer, ScalarInfo>emptyMap()); 6329 } 6330 6331 /** 6332 * Slice 17 leak guard: reject subqueries inside a FROM-subquery 6333 * body's JOIN ON / GROUP BY clauses. Mirrors 6334 * {@link #rejectSubqueriesInScalarBodyClauses} (slice 11) — the 6335 * {@code allowScalarProjectionSubqueries=false} flag only guards 6336 * {@code buildOutputColumns}, so without this helper a SQL like 6337 * {@code SELECT id FROM (SELECT id FROM e JOIN d ON e.x = d.x 6338 * AND EXISTS (...)) sub} would leak the EXISTS subquery's refs into 6339 * the body's join column refs via {@code collectColumnRefs}. 6340 * HAVING / ORDER BY subqueries are caught by the slice-9 / 10 6341 * deep-scan rejecters inside {@code buildSelectStatementImpl}. 6342 * 6343 * <p>Slice 120 — the WHERE branch was removed: uncorrelated WHERE-side 6344 * predicate subqueries in a FROM-subquery body are now extracted as 6345 * their own statements by {@code buildSelectStatementImpl} via 6346 * {@link PredicateClauseContext#FROM_SUBQUERY_BODY_WHERE} (see 6347 * {@link #processDirectSubqueryTable}). {@code FROM_SUBQUERY_INNER_SUBQUERY_IN_WHERE} 6348 * stays declared-but-unreached for public-API stability (slice 6349 * 71/72/82/86/95/101/.../114 retain-for-documentation precedent). 6350 */ 6351 private static void rejectSubqueriesInFromSubqueryBodyClauses( 6352 TSelectSqlStatement inner, String fromAlias) { 6353 if (inner.joins != null) { 6354 for (TJoin join : inner.joins) { 6355 TJoinItemList items = join.getJoinItems(); 6356 if (items == null) continue; 6357 for (int i = 0; i < items.size(); i++) { 6358 TJoinItem item = items.getJoinItem(i); 6359 TExpression onCond = item == null ? null : item.getOnCondition(); 6360 if (onCond != null && containsAnySubqueryExpression(onCond)) { 6361 throw new SemanticIRBuildException( 6362 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_INNER_SUBQUERY_IN_JOIN_ON, 6363 "FROM-clause subquery '" + fromAlias 6364 + "' has a subquery in a JOIN ON clause; not supported yet " 6365 + "(would leak inner refs)", (TParseTreeNode) null)); 6366 } 6367 } 6368 } 6369 } 6370 TGroupBy groupBy = inner.getGroupByClause(); 6371 if (groupBy != null) { 6372 TGroupByItemList items = groupBy.getItems(); 6373 if (items != null && containsAnySubquery(items)) { 6374 throw new SemanticIRBuildException( 6375 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_INNER_SUBQUERY_IN_GROUP_BY, 6376 "FROM-clause subquery '" + fromAlias 6377 + "' has a subquery in a GROUP BY clause; not supported yet " 6378 + "(would leak inner refs)", (TParseTreeNode) null)); 6379 } 6380 } 6381 } 6382 6383 /** 6384 * Walk the consuming SELECT's result-column list. For every 6385 * top-level {@link EExpressionType#subquery_t} projection, build 6386 * the inner SELECT as its own {@link StatementGraph} (mirroring 6387 * slice 5 FROM-subquery extraction), append it to {@code stmts}, 6388 * emit its own lineage edges, and record 6389 * {@code resultColumnOrdinal → ScalarInfo} so the consumer's 6390 * {@code emitLineageForStatement} can wire the 6391 * STATEMENT_OUTPUT → STATEMENT_OUTPUT edge. 6392 * 6393 * <p>Slice 11 disallows: scalar subqueries with no outer alias, 6394 * inner SELECTs that project more than one column, inner columns 6395 * with no alias and no direct column name, scalar subqueries 6396 * whose inner WHERE/JOIN/GROUP BY contains a subquery (predicate 6397 * leak guard), correlated scalar subqueries (inner refs that 6398 * resolve to outer aliases), and nested scalar subqueries. 6399 * 6400 * <p>Slice 14 lifted correlated TABLE-bound; slice 15 lifted CTE- 6401 * and SUBQUERY-bound. Slice 20 lifts <i>nested</i> scalar 6402 * projections inside a scalar body when the 6403 * {@code allowRecursiveScalarSubqueryExtraction} flag is true (passed 6404 * by the outer-build and CTE-body call sites). Set-op-branch call 6405 * sites pass false to keep the slice-12 / slice-16 boundary that 6406 * branch scalar bodies must not host another scalar projection. 6407 * 6408 * <p>Slice 20 wraps the body in a snapshot/rollback so a deeper 6409 * level's failure does not leak appended scalar-body statements at 6410 * shallower levels into {@code stmts}/{@code lineage}. The wrapper 6411 * mirrors slice-16's {@code buildSetOpProgram} and slice-17/18's 6412 * extract wrappers (§14.18 process lesson #21). 6413 */ 6414 private static Map<Integer, ScalarInfo> extractScalarSubqueriesAsStatements( 6415 TSelectSqlStatement consumer, 6416 NameBindingProvider consumerProvider, 6417 List<StatementGraph> stmts, 6418 List<LineageEdge> lineage, 6419 Map<String, Integer> cteNameToStatementIndex, 6420 EnclosingScope enclosingScope, 6421 boolean allowRecursiveScalarSubqueryExtraction) { 6422 // Slice 20: SET-OP-WIDE-style transactional rollback. A failure 6423 // anywhere inside the loop (or inside a recursive call) truncates 6424 // both lists back to the pre-extraction size. The wrapper closes 6425 // the class of "mutation-free check fires after partial mutation" 6426 // (codex round-3..5 finding on slice 16); the recursive scalar 6427 // extraction surfaced it here. 6428 int stmtsSnapshot = stmts.size(); 6429 int lineageSnapshot = lineage.size(); 6430 try { 6431 return extractScalarSubqueriesAsStatementsInternal(consumer, 6432 consumerProvider, stmts, lineage, 6433 cteNameToStatementIndex, enclosingScope, 6434 allowRecursiveScalarSubqueryExtraction); 6435 } catch (RuntimeException ex) { 6436 while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1); 6437 while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1); 6438 throw ex; 6439 } 6440 } 6441 6442 /** 6443 * Internal body of {@link #extractScalarSubqueriesAsStatements}. 6444 * Wrapped with snapshot/rollback by the public entry point; do not 6445 * call directly from non-wrapper sites. 6446 */ 6447 private static Map<Integer, ScalarInfo> extractScalarSubqueriesAsStatementsInternal( 6448 TSelectSqlStatement consumer, 6449 NameBindingProvider consumerProvider, 6450 List<StatementGraph> stmts, 6451 List<LineageEdge> lineage, 6452 Map<String, Integer> cteNameToStatementIndex, 6453 EnclosingScope enclosingScope, 6454 boolean allowRecursiveScalarSubqueryExtraction) { 6455 Map<Integer, ScalarInfo> ordinalToInfo = new HashMap<>(); 6456 TResultColumnList rcl = consumer.getResultColumnList(); 6457 if (rcl == null || rcl.size() == 0) return ordinalToInfo; 6458 6459 // Reject duplicate output aliases when a scalar projection is 6460 // present (codex impl-review round-1 SHOULD 1). Lineage refs are 6461 // keyed by (statementIndex, outputName); two outputs sharing 6462 // the same name would collapse their lineage chains and 6463 // silently merge the scalar dependency with another column's 6464 // dependency. The slice-11 boundary is the cleanest place to 6465 // enforce this since the issue is most acute when a scalar 6466 // body's STATEMENT_OUTPUT → STATEMENT_OUTPUT edge is in play. 6467 boolean hasScalar = false; 6468 for (int i = 0; i < rcl.size(); i++) { 6469 TResultColumn rc = rcl.getResultColumn(i); 6470 if (rc != null && rc.getExpr() != null 6471 && rc.getExpr().getExpressionType() == EExpressionType.subquery_t) { 6472 hasScalar = true; 6473 break; 6474 } 6475 } 6476 if (hasScalar) { 6477 Set<String> seenOutputNames = new HashSet<>(); 6478 for (int i = 0; i < rcl.size(); i++) { 6479 TResultColumn rc = rcl.getResultColumn(i); 6480 if (rc == null) continue; 6481 String alias = rc.getColumnAlias(); 6482 String colName = rc.getColumnNameOnly(); 6483 String name = (alias != null && !alias.isEmpty()) 6484 ? alias 6485 : colName; 6486 if (name == null || name.isEmpty()) continue; 6487 String lower = name.toLowerCase(Locale.ROOT); 6488 if (!seenOutputNames.add(lower)) { 6489 throw new SemanticIRBuildException( 6490 Diagnostic.error(DiagnosticCode.DUPLICATE_OUTPUT_NAME, 6491 "duplicate output name '" + name 6492 + "' in a SELECT containing a scalar subquery projection; " 6493 + "lineage refs are keyed by output name and would collide", rc)); 6494 } 6495 } 6496 } 6497 6498 for (int i = 0; i < rcl.size(); i++) { 6499 TResultColumn rc = rcl.getResultColumn(i); 6500 if (rc == null || rc.getExpr() == null) continue; 6501 if (rc.getExpr().getExpressionType() != EExpressionType.subquery_t) { 6502 continue; 6503 } 6504 String outerAlias = rc.getColumnAlias(); 6505 if (outerAlias == null || outerAlias.isEmpty()) { 6506 throw new SemanticIRBuildException( 6507 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_ALIAS_REQUIRED, 6508 "scalar subquery projection must have an alias", rc)); 6509 } 6510 TSelectSqlStatement inner = rc.getExpr().getSubQuery(); 6511 if (inner == null) { 6512 throw new SemanticIRBuildException( 6513 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_NO_INNER_SELECT, 6514 "scalar subquery projection '" + outerAlias 6515 + "' has no inner SELECT", rc)); 6516 } 6517 // Pre-recursion validation (codex round-2 MUST 2): inspect 6518 // the inner SELECT's projected column count and naming 6519 // BEFORE recursive build so the rejection message is 6520 // scalar-specific instead of bubbling up from 6521 // effectiveOutputName. 6522 TResultColumnList innerRcl = inner.getResultColumnList(); 6523 if (innerRcl == null || innerRcl.size() == 0) { 6524 throw new SemanticIRBuildException( 6525 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT, 6526 "scalar subquery '" + outerAlias 6527 + "' must project exactly one column, got 0", rc)); 6528 } 6529 if (innerRcl.size() != 1) { 6530 throw new SemanticIRBuildException( 6531 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_COLUMN_COUNT, 6532 "scalar subquery '" + outerAlias 6533 + "' must project exactly one column, got " 6534 + innerRcl.size(), rc)); 6535 } 6536 TResultColumn innerCol = innerRcl.getResultColumn(0); 6537 String innerAlias = innerCol.getColumnAlias(); 6538 String innerColName = innerCol.getColumnNameOnly(); 6539 boolean innerHasName = 6540 (innerAlias != null && !innerAlias.isEmpty()) 6541 || (innerColName != null && !innerColName.isEmpty()); 6542 if (!innerHasName && !isConstantExpression(innerCol.getExpr())) { 6543 throw new SemanticIRBuildException( 6544 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_PROJECTION_UNNAMED, 6545 "scalar subquery '" + outerAlias 6546 + "' inner projection has no alias and no column name; " 6547 + "add an explicit alias inside the subquery", rc)); 6548 } 6549 // Pre-recursion deep-scan (codex round-3 MUST 5, round-4 6550 // MUST 1): reject nested predicate subqueries in the 6551 // scalar body's JOIN ON / GROUP BY before collectColumnRefs 6552 // can descend. Slice 121: the WHERE branch is no longer 6553 // rejected here (allowWherePredicateSubqueries=true) — the 6554 // scalar body's WHERE-side uncorrelated predicate subqueries 6555 // are extracted as their own statements by the 6556 // buildSelectStatementImpl call below via 6557 // PredicateClauseContext.SCALAR_BODY_WHERE. 6558 rejectSubqueriesInScalarBodyClauses(inner, outerAlias, 6559 /*allowWherePredicateSubqueries=*/ true); 6560 6561 // Slice 20: branch on allowRecursiveScalarSubqueryExtraction. 6562 // - true (outer / CTE-body call sites): build the inner's 6563 // own enclosing scope chained to this caller's; recursively 6564 // extract the inner's scalar projections; then build the 6565 // inner with allowScalarProjectionSubqueries=true; 6566 // compute scalarName AFTER the recursive extraction so 6567 // the digit suffix matches the post-extraction stmts.size() 6568 // (slice-16 codex round-1 MUST 2 lesson). 6569 // - false (set-op-branch call site): keep the slice-12 / 6570 // slice-16 boundary — no recursive extraction; the inner 6571 // scalar map stays empty; the inner builds with 6572 // allowScalarProjectionSubqueries=false; promotion still 6573 // uses the caller's enclosing scope so OUTER_REFERENCE-of-* 6574 // correlation works at the branch level. 6575 EnclosingScope innerEnclosing; 6576 Map<Integer, ScalarInfo> innerScalarMap; 6577 String scalarName; 6578 if (allowRecursiveScalarSubqueryExtraction) { 6579 innerEnclosing = buildEnclosingScope(inner, 6580 cteNameToStatementIndex, 6581 Collections.<String, Integer>emptyMap(), 6582 enclosingScope); 6583 innerScalarMap = extractScalarSubqueriesAsStatements(inner, 6584 consumerProvider, stmts, lineage, 6585 cteNameToStatementIndex, innerEnclosing, 6586 /*allowRecursiveScalarSubqueryExtraction=*/ true); 6587 scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">"; 6588 } else { 6589 innerEnclosing = enclosingScope; 6590 innerScalarMap = Collections.<Integer, ScalarInfo>emptyMap(); 6591 scalarName = SCALAR_BODY_PREFIX + stmts.size() + ">"; 6592 } 6593 // Slice 121 — switch from the 7-arg buildSelectStatement to 6594 // the 14-arg buildSelectStatementImpl so the scalar body's 6595 // WHERE clause can extract uncorrelated predicate subqueries 6596 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 6597 // ANY-ALL-SOME) as their own <predicate_subquery_<i>> 6598 // statements (mirrors the slice-113 set-op branch / slice-120 6599 // FROM-subquery body lift). allowJoinOnPredicateSubqueries=false 6600 // keeps JOIN-ON predicate subqueries rejected (the two flags 6601 // are independent per the slice-113 split; the slice-11 leak 6602 // guard above still fires for the body's JOIN-ON / GROUP-BY). 6603 // allowScalarProjectionSubqueries stays gated on 6604 // allowRecursiveScalarSubqueryExtraction (slice-15/16 invariant). 6605 // stmts/lineage/cteMap are threaded for extraction; the 6606 // enclosing extractScalarSubqueriesAsStatements wrapper is the 6607 // snapshot/rollback boundary (no per-site try/catch needed — 6608 // slice-113 enclosed-by-buildSetOpProgram precedent). 6609 // preBuildScalarSize == the digit baked into scalarName above 6610 // (nothing mutates stmts between the scalarName assignment and 6611 // here), so `idx != preBuildScalarSize` below is exactly "the 6612 // build appended predicate bodies" without re-parsing the name. 6613 int preBuildScalarSize = stmts.size(); 6614 StatementGraph innerStmt = buildSelectStatementImpl(inner, consumerProvider, 6615 scalarName, 6616 /*hasOuterCteListAlreadyProcessed=*/ false, 6617 /*allowFromSubqueries=*/ false, 6618 /*allowScalarProjectionSubqueries=*/ allowRecursiveScalarSubqueryExtraction, 6619 /*allowWindowProjection=*/ false, 6620 /*allowJoinOnPredicateSubqueries=*/ false, 6621 /*stmtsForExtraction=*/ stmts, 6622 /*lineageForExtraction=*/ lineage, 6623 /*cteMapForExtraction=*/ cteNameToStatementIndex, 6624 /*isPredicateBody=*/ false, 6625 /*whereClauseContext=*/ PredicateClauseContext.SCALAR_BODY_WHERE, 6626 /*allowWherePredicateSubqueries=*/ true); 6627 // Slice 14: instead of rejecting correlated scalar 6628 // subqueries, promote outer-scope refs into synthesised 6629 // OUTER_REFERENCE relations on the inner statement. 6630 // Non-TABLE-bound outer refs (CTE / SUBQUERY) and 6631 // unknown aliases still throw. (This projection path uses the 6632 // innerEnclosing scope for promotion; the UPDATE SET-RHS scalar 6633 // path in extractScalarSubqueriesFromUpdateSetRhs instead uses 6634 // withTolerantOuterBinding because UPDATE has no enclosing-scope 6635 // promotion — slices 117/119. The two are equivalent for 6636 // uncorrelated bodies; do not cross-port one onto the other.) 6637 // promoteCorrelatedRefsToOuterReference returns a new 6638 // StatementGraph and does NOT append to stmts, so reading 6639 // idx after it is equivalent to reading it right after the 6640 // build (it only reflects predicate bodies the build appended). 6641 innerStmt = promoteCorrelatedRefsToOuterReference( 6642 innerStmt, outerAlias, innerEnclosing); 6643 int idx = stmts.size(); 6644 // Slice 121 — if the WHERE predicate-body extraction appended 6645 // statements during the build, the scalar body's final index 6646 // exceeds the value baked into scalarName (computed before the 6647 // build). Rebuild with the corrected <scalar_subquery_<idx>> 6648 // name so the slice-12/16 name↔index invariant survives 6649 // (slice-113 set-op-branch withRenamedTo precedent). LineageRefs 6650 // are idx-based, not name-based, so the rename is cosmetic. 6651 if (idx != preBuildScalarSize) { 6652 innerStmt = withRenamedTo(innerStmt, SCALAR_BODY_PREFIX + idx + ">"); 6653 } 6654 stmts.add(innerStmt); 6655 String innerOutName = effectiveOutputName(innerCol); 6656 ordinalToInfo.put(i, new ScalarInfo(idx, innerOutName)); 6657 // Slice 20: pass the chained subquery alias map so 6658 // OUTER_REFERENCE-of-SUBQUERY refs in deeply nested scalar 6659 // bodies resolve through ancestor FROM-subquery aliases. 6660 // Pass innerScalarMap (non-empty when recursive extraction 6661 // is allowed) so the inner's own STATEMENT_OUTPUT → 6662 // STATEMENT_OUTPUT edges land. 6663 emitLineageForStatement(innerStmt, idx, lineage, 6664 cteNameToStatementIndex, 6665 innerEnclosing.flattenSubqueryAliasToIndex(), 6666 innerScalarMap); 6667 } 6668 return ordinalToInfo; 6669 } 6670 6671 /** 6672 * Reject nested predicate subqueries inside a scalar body's 6673 * WHERE / JOIN ON / GROUP BY clauses. Scalar bodies are slice-11 6674 * scope; the pre-existing builder leaks predicate-subquery refs 6675 * into {@code filterColumnRefs}/etc. elsewhere, but the scalar-body 6676 * recursion path is guarded so slice-11 outputs stay clean. 6677 */ 6678 private static void rejectSubqueriesInScalarBodyClauses( 6679 TSelectSqlStatement inner, String outerAlias, 6680 boolean allowWherePredicateSubqueries) { 6681 // Slice 121 — the WHERE branch is gated. The projection scalar 6682 // path passes {@code allowWherePredicateSubqueries=true} so its 6683 // WHERE-side uncorrelated predicate subqueries are instead 6684 // extracted as their own {@code <predicate_subquery_<i>>} 6685 // statements by {@code buildSelectStatementImpl} via 6686 // {@link PredicateClauseContext#SCALAR_BODY_WHERE} (see 6687 // {@link #extractScalarSubqueriesAsStatementsInternal}). The 6688 // UPDATE SET-RHS scalar path 6689 // ({@link #extractScalarSubqueriesFromUpdateSetRhs}) keeps passing 6690 // {@code false} so its WHERE subqueries still reject with 6691 // {@code SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE} (the code stays 6692 // reached). JOIN-ON and GROUP-BY always reject (slice 11/23/26). 6693 TWhereClause where = inner.getWhereClause(); 6694 if (!allowWherePredicateSubqueries 6695 && where != null && containsAnySubquery(where)) { 6696 throw new SemanticIRBuildException( 6697 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE, 6698 "scalar subquery '" + outerAlias 6699 + "' has a subquery in its WHERE clause; not supported yet " 6700 + "(would leak inner refs)", (TParseTreeNode) null)); 6701 } 6702 if (inner.joins != null) { 6703 for (TJoin join : inner.joins) { 6704 TJoinItemList items = join.getJoinItems(); 6705 if (items == null) continue; 6706 for (int i = 0; i < items.size(); i++) { 6707 TJoinItem item = items.getJoinItem(i); 6708 TExpression onCond = item == null ? null : item.getOnCondition(); 6709 if (onCond != null && containsAnySubqueryExpression(onCond)) { 6710 throw new SemanticIRBuildException( 6711 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_JOIN_ON, 6712 "scalar subquery '" + outerAlias 6713 + "' has a subquery in a JOIN ON clause; not supported yet " 6714 + "(would leak inner refs)", (TParseTreeNode) null)); 6715 } 6716 } 6717 } 6718 } 6719 TGroupBy groupBy = inner.getGroupByClause(); 6720 if (groupBy != null) { 6721 TGroupByItemList items = groupBy.getItems(); 6722 if (items != null && containsAnySubquery(items)) { 6723 throw new SemanticIRBuildException( 6724 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_INNER_SUBQUERY_IN_GROUP_BY, 6725 "scalar subquery '" + outerAlias 6726 + "' has a subquery in a GROUP BY clause; not supported yet " 6727 + "(would leak inner refs)", (TParseTreeNode) null)); 6728 } 6729 } 6730 // HAVING / ORDER BY subqueries are caught by the slice-9 / 10 6731 // deep-scan rejecters that fire during buildSelectStatement. 6732 } 6733 6734 /** 6735 * True iff any descendant of {@code root} is a {@link TExpression} 6736 * with {@code subquery_t} type or a non-null 6737 * {@link TExpression#getSubQuery()}. Mirrors the slice 9/10 pattern. 6738 */ 6739 private static boolean containsAnySubquery(gudusoft.gsqlparser.nodes.TParseTreeNode root) { 6740 final boolean[] found = {false}; 6741 root.acceptChildren(new TParseTreeVisitor() { 6742 @Override 6743 public void preVisit(TExpression e) { 6744 if (found[0]) return; 6745 if (e.getExpressionType() == EExpressionType.subquery_t 6746 || e.getSubQuery() != null) { 6747 found[0] = true; 6748 } 6749 } 6750 }); 6751 return found[0]; 6752 } 6753 6754 /** Same as {@link #containsAnySubquery} but also checks the root expression itself. */ 6755 private static boolean containsAnySubqueryExpression(TExpression root) { 6756 if (root.getExpressionType() == EExpressionType.subquery_t 6757 || root.getSubQuery() != null) { 6758 return true; 6759 } 6760 return containsAnySubquery(root); 6761 } 6762 6763 /** 6764 * Slice 119 — collect every {@code subquery_t} TExpression node that 6765 * is a direct (top-level) subquery in the compound expression {@code 6766 * root}, without descending into any found subquery's own body. 6767 * 6768 * <p>Uses {@link TExpression#acceptChildren} with a depth counter to 6769 * handle all expression subtypes (arithmetic, CASE, function args) 6770 * without manual branch enumeration. The depth counter increments on 6771 * every {@code subquery_t} preVisit and decrements on every postVisit, 6772 * so nested subqueries inside a found body (e.g. an EXISTS inside a 6773 * scalar's WHERE) are tracked but NOT added to the result set. 6774 * 6775 * <p>Result list is in traversal (preVisit) order for deterministic 6776 * statement-graph numbering across identical SQL texts. 6777 */ 6778 private static List<TExpression> collectNestedSubqueryExpressions(TExpression root) { 6779 if (root == null) return Collections.<TExpression>emptyList(); 6780 // Defensive: root itself is a subquery_t — callers of the 6781 // mixed-expression path should have already taken the slice-115 6782 // top-level branch, but guard anyway. 6783 if (root.getExpressionType() == EExpressionType.subquery_t) { 6784 return Collections.singletonList(root); 6785 } 6786 final List<TExpression> ordered = new ArrayList<>(); 6787 final int[] depth = {0}; 6788 root.acceptChildren(new TParseTreeVisitor() { 6789 @Override 6790 public void preVisit(TExpression e) { 6791 if (e.getExpressionType() == EExpressionType.subquery_t) { 6792 if (depth[0] == 0) ordered.add(e); // top-level only 6793 depth[0]++; 6794 } 6795 } 6796 @Override 6797 public void postVisit(TExpression e) { 6798 if (e.getExpressionType() == EExpressionType.subquery_t) { 6799 depth[0]--; 6800 } 6801 } 6802 }); 6803 return ordered; 6804 } 6805 6806 /** 6807 * Slice-14 enclosing-scope map for correlation promotion. Holds the 6808 * full outer {@link RelationSource} per alias visible from the 6809 * enclosing scope so {@link #promoteCorrelatedRefsToOuterReference} 6810 * can read the outer's binding kind and qualifiedName. 6811 * 6812 * <p>The map is keyed by lower-cased alias for case-insensitive 6813 * lookup. The synthesised OUTER_REFERENCE RelationSource's alias is 6814 * NOT taken from this map's value — it is taken from the inner 6815 * ref's spelling (see §4.3 of the slice-14 plan), because 6816 * {@link gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider} 6817 * records the inner ref's alias verbatim and case-sensitive 6818 * alias-equality elsewhere relies on that spelling. 6819 * 6820 * <p>Slice-15 adds {@link #subqueryAliasToIndex}: when a SUBQUERY-bound 6821 * outer alias is referenced from an inner correlated scalar, the 6822 * inner statement's lineage emission needs to look up the outer's 6823 * FROM-subquery body by alias to wire a STATEMENT_OUTPUT → 6824 * STATEMENT_OUTPUT edge. The map mirrors the outer's alias→index 6825 * map but is filtered to alias keys also present in 6826 * {@link #aliasLowerToOuter} as SUBQUERY entries so the two maps 6827 * cannot drift apart. 6828 * 6829 * <p>Slice-20 generalises slice-14/15's flat map into a chain of 6830 * ancestor scopes (the {@link #parent} field). Lookups walk innermost 6831 * → outermost via {@link #lookupAlias(String)}. The 6832 * {@link #flattenSubqueryAliasToIndex()} helper produces an innermost- 6833 * wins flattened map for {@code emitLineageForStatement} consumers 6834 * that resolve OUTER_REFERENCE-of-SUBQUERY through ancestor 6835 * FROM-subquery aliases. Worst-case asymptotic per build for a 6836 * scalar chain of depth D with K siblings per level is 6837 * {@code O(K · D²)}; in practice D ≤ 4-5 for human-written SQL so the 6838 * constant factor is negligible. If a real-world benchmark surfaces 6839 * the flatten as a hot path, the obvious fix is a memo keyed on 6840 * {@code EnclosingScope} identity — deferred until measured. 6841 */ 6842 private static final class EnclosingScope { 6843 final Map<String, RelationSource> aliasLowerToOuter; 6844 final Map<String, Integer> subqueryAliasToIndex; 6845 /** Slice-20 chain: parent enclosing scope; null at the root. */ 6846 final EnclosingScope parent; 6847 6848 EnclosingScope(Map<String, RelationSource> aliasLowerToOuter, 6849 Map<String, Integer> subqueryAliasToIndex, 6850 EnclosingScope parent) { 6851 this.aliasLowerToOuter = aliasLowerToOuter; 6852 this.subqueryAliasToIndex = subqueryAliasToIndex; 6853 this.parent = parent; 6854 } 6855 6856 static EnclosingScope empty() { 6857 return new EnclosingScope( 6858 Collections.<String, RelationSource>emptyMap(), 6859 Collections.<String, Integer>emptyMap(), 6860 /*parent=*/ null); 6861 } 6862 6863 /** 6864 * Walk the chain innermost → outermost; first match wins 6865 * (shadowing). Defensive cycle guard via identity-keyed visited 6866 * set: the chain is a DAG by construction (parent links never 6867 * loop back), but the guard makes the invariant explicit. 6868 */ 6869 RelationSource lookupAlias(String aliasLower) { 6870 EnclosingScope cur = this; 6871 Set<EnclosingScope> visited = Collections.newSetFromMap( 6872 new IdentityHashMap<EnclosingScope, Boolean>()); 6873 while (cur != null && visited.add(cur)) { 6874 RelationSource r = cur.aliasLowerToOuter.get(aliasLower); 6875 if (r != null) return r; 6876 cur = cur.parent; 6877 } 6878 return null; 6879 } 6880 6881 /** 6882 * Innermost-wins flatten of the SUBQUERY alias → body-index chain. 6883 * Ancestors contribute their own FROM-subquery alias maps; if both 6884 * a parent and a child define the same alias, the child wins 6885 * (innermost shadows outermost). Cycle guard mirrors 6886 * {@link #lookupAlias(String)}. 6887 */ 6888 Map<String, Integer> flattenSubqueryAliasToIndex() { 6889 if (parent == null) return subqueryAliasToIndex; 6890 Deque<EnclosingScope> stack = new ArrayDeque<>(); 6891 Set<EnclosingScope> visited = Collections.newSetFromMap( 6892 new IdentityHashMap<EnclosingScope, Boolean>()); 6893 EnclosingScope cur = this; 6894 while (cur != null && visited.add(cur)) { 6895 stack.push(cur); 6896 cur = cur.parent; 6897 } 6898 // Stack top = outermost. Pop outermost first, emit, then 6899 // innermost overwrites via put(). Result: innermost wins. 6900 Map<String, Integer> out = new LinkedHashMap<>(); 6901 while (!stack.isEmpty()) { 6902 EnclosingScope s = stack.pop(); 6903 out.putAll(s.subqueryAliasToIndex); 6904 } 6905 return out; 6906 } 6907 } 6908 6909 /** 6910 * Build an {@link EnclosingScope} for the consuming SELECT by walking 6911 * its {@link TSelectSqlStatement#tables} list (FROM relations) and 6912 * classifying each entry. Mirrors how 6913 * {@link Resolver2NameBindingProvider#bindRelation} would classify 6914 * the same TTable but produces a full RelationSource per alias so 6915 * the slice-14 promotion can read both kind and qualifiedName. 6916 * 6917 * <p>Slice 20: the {@code parent} parameter chains the new scope to an 6918 * enclosing one so a doubly-nested scalar body can resolve grandparent 6919 * aliases. Top-level callers pass {@code null}; recursive scalar 6920 * extraction passes the caller's enclosing scope. 6921 * 6922 * <p>Classification (precedence on collision: CTE name beats 6923 * base-table name): 6924 * <ul> 6925 * <li>{@code TTable.getTableType() == subquery} AND alias matches 6926 * a key in {@code subqueryAliasToIndex} → SUBQUERY-bound.</li> 6927 * <li>{@code TTable.getName().toLowerCase()} matches a key in 6928 * {@code cteNameToStatementIndex} → CTE-bound.</li> 6929 * <li>Otherwise → TABLE-bound.</li> 6930 * </ul> 6931 */ 6932 private static EnclosingScope buildEnclosingScope(TSelectSqlStatement consumer, 6933 Map<String, Integer> cteNameToStatementIndex, 6934 Map<String, Integer> subqueryAliasToIndex, 6935 EnclosingScope parent) { 6936 if (consumer == null || consumer.tables == null || consumer.tables.size() == 0) { 6937 // Even an empty FROM contributes an empty scope so the chain's 6938 // shape reflects nesting depth uniformly. 6939 return new EnclosingScope( 6940 Collections.<String, RelationSource>emptyMap(), 6941 Collections.<String, Integer>emptyMap(), 6942 parent); 6943 } 6944 Map<String, RelationSource> map = new LinkedHashMap<>(); 6945 Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>(); 6946 for (int i = 0; i < consumer.tables.size(); i++) { 6947 TTable t = consumer.tables.getTable(i); 6948 if (t == null) continue; 6949 // Slice 74: route anonymous FROM-subqueries through 6950 // effectiveAliasOf so the synth name (instead of the 6951 // literal "subquery" returned by t.getName()) flows into the 6952 // OUTER_REFERENCE scope chain. 6953 String aliasOrName = effectiveAliasOf(t); 6954 if (aliasOrName == null || aliasOrName.isEmpty()) continue; 6955 String lower = aliasOrName.toLowerCase(Locale.ROOT); 6956 if (map.containsKey(lower)) continue; // first-occurrence wins (defensive) 6957 6958 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 6959 if (subqueryAliasToIndex != null && subqueryAliasToIndex.containsKey(lower)) { 6960 map.put(lower, new RelationSource(aliasOrName, 6961 new RelationBinding(RelationKind.SUBQUERY, aliasOrName))); 6962 // Slice 15: keep a parallel filtered map of alias→index 6963 // so OUTER_REFERENCE-of-SUBQUERY emit-side dispatch can 6964 // resolve the outer body's statement index. 6965 filteredSubqueryAliasToIndex.put(lower, subqueryAliasToIndex.get(lower)); 6966 } 6967 continue; 6968 } 6969 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) { 6970 continue; // function / rowList / etc. — not modelled 6971 } 6972 String name = t.getName(); 6973 if (name == null || name.isEmpty()) continue; 6974 String nameLower = name.toLowerCase(Locale.ROOT); 6975 if (cteNameToStatementIndex != null 6976 && cteNameToStatementIndex.containsKey(nameLower)) { 6977 map.put(lower, new RelationSource(aliasOrName, 6978 new RelationBinding(RelationKind.CTE, name))); 6979 } else { 6980 map.put(lower, new RelationSource(aliasOrName, 6981 new RelationBinding(RelationKind.TABLE, name))); 6982 } 6983 } 6984 return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent); 6985 } 6986 6987 /** 6988 * Slice 117 — sibling to {@link #buildEnclosingScope} for UPDATE 6989 * SET-RHS scalar-subquery bodies. The UPDATE outer scope is the 6990 * target table (TABLE-bound) plus any FROM-side relations walked 6991 * via {@code update.getJoins()} (TABLE / CTE / SUBQUERY-classified). 6992 * 6993 * <p>The target is added FIRST so on alias collisions with a 6994 * FROM-side relation (e.g. {@code UPDATE t ... FROM other t}) the 6995 * target wins. The {@code first-occurrence wins} rule mirrors 6996 * {@link #buildEnclosingScope}. 6997 * 6998 * <p>Used only by 6999 * {@link #extractScalarSubqueriesFromUpdateSetRhsInternal}. 7000 */ 7001 private static EnclosingScope buildUpdateEnclosingScope( 7002 TUpdateSqlStatement update, 7003 Map<String, Integer> cteNameToStatementIndex, 7004 Map<String, Integer> subqueryAliasToIndex, 7005 EnclosingScope parent) { 7006 Map<String, RelationSource> map = new LinkedHashMap<>(); 7007 Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>(); 7008 if (update == null) { 7009 return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent); 7010 } 7011 // 1) Target — always TABLE-bound. effectiveAliasOf falls back to 7012 // the table's own name when no alias is present. 7013 TTable target = update.getTargetTable(); 7014 if (target != null 7015 && target.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 7016 String targetAlias = effectiveAliasOf(target); 7017 String targetName = target.getName(); 7018 if (targetAlias != null && !targetAlias.isEmpty() 7019 && targetName != null && !targetName.isEmpty()) { 7020 String aliasLower = targetAlias.toLowerCase(Locale.ROOT); 7021 map.put(aliasLower, new RelationSource(targetAlias, 7022 new RelationBinding(RelationKind.TABLE, targetName))); 7023 } 7024 } 7025 // 2) FROM-side joins. 7026 TJoinList joins = update.getJoins(); 7027 if (joins != null) { 7028 for (TJoin join : joins) { 7029 addUpdateRelationToEnclosingScope(join.getTable(), map, 7030 filteredSubqueryAliasToIndex, 7031 cteNameToStatementIndex, subqueryAliasToIndex); 7032 TJoinItemList items = join.getJoinItems(); 7033 if (items == null) continue; 7034 for (int i = 0; i < items.size(); i++) { 7035 TJoinItem item = items.getJoinItem(i); 7036 if (item == null) continue; 7037 addUpdateRelationToEnclosingScope(item.getTable(), map, 7038 filteredSubqueryAliasToIndex, 7039 cteNameToStatementIndex, subqueryAliasToIndex); 7040 } 7041 } 7042 } 7043 return new EnclosingScope(map, filteredSubqueryAliasToIndex, parent); 7044 } 7045 7046 /** 7047 * Slice 117 — classify one FROM-side TTable for 7048 * {@link #buildUpdateEnclosingScope}. SUBQUERY-typed tables go to 7049 * {@link RelationKind#SUBQUERY} if their alias was registered in 7050 * {@code subqueryAliasToIndex} (slice-83 extraction map); objectname 7051 * tables go to {@link RelationKind#CTE} if their bare name is a 7052 * declared CTE, otherwise {@link RelationKind#TABLE}. First- 7053 * occurrence wins. Function / rowList sources are silently skipped 7054 * (not modelled). 7055 */ 7056 private static void addUpdateRelationToEnclosingScope(TTable t, 7057 Map<String, RelationSource> map, 7058 Map<String, Integer> filteredSubqueryAliasToIndex, 7059 Map<String, Integer> cteNameToStatementIndex, 7060 Map<String, Integer> subqueryAliasToIndex) { 7061 if (t == null) return; 7062 String aliasOrName = effectiveAliasOf(t); 7063 if (aliasOrName == null || aliasOrName.isEmpty()) return; 7064 String lower = aliasOrName.toLowerCase(Locale.ROOT); 7065 if (map.containsKey(lower)) return; // first-occurrence wins 7066 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 7067 if (subqueryAliasToIndex != null 7068 && subqueryAliasToIndex.containsKey(lower)) { 7069 map.put(lower, new RelationSource(aliasOrName, 7070 new RelationBinding(RelationKind.SUBQUERY, aliasOrName))); 7071 filteredSubqueryAliasToIndex.put(lower, 7072 subqueryAliasToIndex.get(lower)); 7073 } 7074 return; 7075 } 7076 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) { 7077 return; // function / rowList / etc. — not modelled 7078 } 7079 String name = t.getName(); 7080 if (name == null || name.isEmpty()) return; 7081 String nameLower = name.toLowerCase(Locale.ROOT); 7082 if (cteNameToStatementIndex != null 7083 && cteNameToStatementIndex.containsKey(nameLower)) { 7084 map.put(lower, new RelationSource(aliasOrName, 7085 new RelationBinding(RelationKind.CTE, name))); 7086 } else { 7087 map.put(lower, new RelationSource(aliasOrName, 7088 new RelationBinding(RelationKind.TABLE, name))); 7089 } 7090 } 7091 7092 /** 7093 * Slice 118 — sibling to {@link #buildUpdateEnclosingScope} for MERGE 7094 * per-WHEN action WHERE correlated predicate subqueries. Builds the 7095 * enclosing scope's relation map covering MERGE's target table, USING 7096 * source, and any outer CTEs declared on the MERGE itself. Used only 7097 * by {@link #collectMergeActionWhere}; produced once per MERGE in 7098 * {@link #buildMerge} and threaded through the predicate-subquery 7099 * extractor. 7100 * 7101 * <p>Classification mirrors {@code buildMerge} step 6: 7102 * <ul> 7103 * <li>{@code merge.getTargetTable()} — TABLE-bound (qualifiedName = 7104 * target's table name). First-occurrence wins.</li> 7105 * <li>{@code merge.getUsingTable()}: 7106 * <ul> 7107 * <li>SUBQUERY-typed → SUBQUERY-bound with index pulled from 7108 * {@code aliasToSubIdx}.</li> 7109 * <li>objectname-typed AND name in 7110 * {@code cteNameToStatementIndex} → CTE-bound (slice-101 7111 * USING-as-CTE).</li> 7112 * <li>Else objectname → TABLE-bound.</li> 7113 * </ul></li> 7114 * </ul> 7115 */ 7116 private static EnclosingScope buildMergeEnclosingScope( 7117 TMergeSqlStatement merge, 7118 Map<String, Integer> cteNameToStatementIndex, 7119 Map<String, Integer> aliasToSubIdx) { 7120 Map<String, RelationSource> map = new LinkedHashMap<>(); 7121 Map<String, Integer> filteredSubqueryAliasToIndex = new LinkedHashMap<>(); 7122 if (merge == null) { 7123 return new EnclosingScope(map, filteredSubqueryAliasToIndex, 7124 /*parent=*/ null); 7125 } 7126 // 1) Target — always TABLE-bound. effectiveAliasOf falls back to 7127 // the table's own name when no alias is present. 7128 TTable target = merge.getTargetTable(); 7129 if (target != null 7130 && target.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 7131 String targetAlias = effectiveAliasOf(target); 7132 String targetName = target.getName(); 7133 if (targetAlias != null && !targetAlias.isEmpty() 7134 && targetName != null && !targetName.isEmpty()) { 7135 String aliasLower = targetAlias.toLowerCase(Locale.ROOT); 7136 map.put(aliasLower, new RelationSource(targetAlias, 7137 new RelationBinding(RelationKind.TABLE, targetName))); 7138 } 7139 } 7140 // 2) USING source. 7141 TTable using = merge.getUsingTable(); 7142 if (using != null) { 7143 String usingAlias = effectiveAliasOf(using); 7144 // Fall back to the USING source's bare name when no alias is 7145 // present — matches buildMerge's `usingAlias` initialisation. 7146 if (usingAlias == null || usingAlias.isEmpty()) { 7147 usingAlias = (using.getName() == null 7148 || using.getName().toString().isEmpty()) 7149 ? "__merge_using__" 7150 : using.getName().toString(); 7151 } 7152 String usingAliasLower = usingAlias.toLowerCase(Locale.ROOT); 7153 // First-occurrence wins (defensive — target's alias could 7154 // theoretically shadow if user named USING source identically; 7155 // matches buildUpdateEnclosingScope behaviour). 7156 if (!map.containsKey(usingAliasLower)) { 7157 if (using.getTableType() 7158 == gudusoft.gsqlparser.ETableSource.subquery) { 7159 if (aliasToSubIdx != null 7160 && aliasToSubIdx.containsKey(usingAliasLower)) { 7161 map.put(usingAliasLower, new RelationSource(usingAlias, 7162 new RelationBinding(RelationKind.SUBQUERY, 7163 usingAlias))); 7164 filteredSubqueryAliasToIndex.put(usingAliasLower, 7165 aliasToSubIdx.get(usingAliasLower)); 7166 } 7167 } else if (using.getTableType() 7168 == gudusoft.gsqlparser.ETableSource.objectname) { 7169 String usingName = using.getName(); 7170 if (usingName != null && !usingName.isEmpty()) { 7171 String usingNameLower = 7172 usingName.toLowerCase(Locale.ROOT); 7173 Integer cteIdx = (cteNameToStatementIndex == null) 7174 ? null 7175 : cteNameToStatementIndex.get(usingNameLower); 7176 if (cteIdx != null) { 7177 // Slice 101 USING-as-CTE branch — model as 7178 // SUBQUERY-bound so the promoter classifies 7179 // outerKind=SUBQUERY (mirrors the slice-117 7180 // SUBQUERY classifier for UPDATE FROM-CTE 7181 // sources, also via aliasToSubIdx). 7182 map.put(usingAliasLower, new RelationSource(usingAlias, 7183 new RelationBinding(RelationKind.SUBQUERY, 7184 usingAlias))); 7185 // The MERGE caller pre-populates aliasToSubIdx 7186 // with the CTE's statement index for both 7187 // usingAlias AND bare CTE name. Use whichever 7188 // exists (aliasToSubIdx is keyed on lower-cased 7189 // aliases — matches buildMerge step 6). 7190 if (aliasToSubIdx != null 7191 && aliasToSubIdx.containsKey(usingAliasLower)) { 7192 filteredSubqueryAliasToIndex.put(usingAliasLower, 7193 aliasToSubIdx.get(usingAliasLower)); 7194 } 7195 } else { 7196 map.put(usingAliasLower, new RelationSource(usingAlias, 7197 new RelationBinding(RelationKind.TABLE, usingName))); 7198 } 7199 } 7200 } 7201 // function / rowList sources are silently skipped (not 7202 // modelled — same convention as buildUpdateEnclosingScope). 7203 } 7204 } 7205 return new EnclosingScope(map, filteredSubqueryAliasToIndex, 7206 /*parent=*/ null); 7207 } 7208 7209 /** 7210 * Slice 117 — precompute the lowercased set of inner local FROM 7211 * aliases for a SELECT statement, used by the tolerant-outer- 7212 * binding fallback in 7213 * {@link gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider#bindColumn}. 7214 * Walks {@code select.tables} (which includes both the FROM driver 7215 * and any JOIN sides) and collects each table's effective alias. 7216 * 7217 * <p>Computed BEFORE the inner build's {@code buildRelations} JOIN- 7218 * ON collector runs so the tolerant provider is already scope-aware 7219 * when the collector first calls {@code bindColumn} (codex round-5 7220 * ordering fix). 7221 */ 7222 private static Set<String> precomputeInnerLocalAliases( 7223 TSelectSqlStatement select) { 7224 Set<String> aliases = new HashSet<>(); 7225 if (select == null || select.tables == null) return aliases; 7226 for (int i = 0; i < select.tables.size(); i++) { 7227 TTable t = select.tables.getTable(i); 7228 if (t == null) continue; 7229 String alias = effectiveAliasOf(t); 7230 if (alias != null && !alias.isEmpty()) { 7231 aliases.add(alias.toLowerCase(Locale.ROOT)); 7232 } 7233 } 7234 return aliases; 7235 } 7236 7237 /** 7238 * Slice-14 correlation promotion (slice-15 extended). Walk every 7239 * column ref in the already-built inner statement; any ref whose 7240 * alias is NOT in the inner's local relations is "correlated." 7241 * Look it up in the enclosing scope: 7242 * 7243 * <ul> 7244 * <li>Found AND TABLE / CTE / SUBQUERY-bound → synthesise an 7245 * OUTER_REFERENCE RelationSource with {@code outerKind} 7246 * set to the resolved outer kind, and add to 7247 * {@code inner.relations}.</li> 7248 * <li>Found AND UNION / UNKNOWN-bound → throw defensively 7249 * (no current builder path produces these from a FROM 7250 * relation).</li> 7251 * <li>Not found anywhere → throw (Resolver2 should not give us 7252 * such a ref; defensive).</li> 7253 * </ul> 7254 * 7255 * <p>The synthesised RelationSource's alias is the inner ref's 7256 * spelling (case-sensitive equality is preserved). The 7257 * qualifiedName comes from the outer's existing binding: 7258 * - TABLE: outer's table name. 7259 * - CTE: outer's CTE name (NOT alias — see 7260 * {@code correlatedToCteBoundOuterWithCteAlias}). 7261 * - SUBQUERY: outer's alias (matching slice-14 SUBQUERY 7262 * convention, where {@code buildEnclosingScope} sets 7263 * {@code qualifiedName=aliasOrName}). 7264 * No lower-casing happens here — that happens at projector 7265 * emit-time per slice-1 convention. Multiple inner refs with 7266 * case-variant spellings inherit the same pre-existing slice-1 7267 * limitation. 7268 */ 7269 private static StatementGraph promoteCorrelatedRefsToOuterReference( 7270 StatementGraph innerStmt, String outerAlias, 7271 EnclosingScope enclosingScope) { 7272 Set<String> innerAliasesLower = new HashSet<>(); 7273 for (RelationSource r : innerStmt.getRelations()) { 7274 innerAliasesLower.add(r.getAlias().toLowerCase(Locale.ROOT)); 7275 } 7276 // Per alias-lower: outer's RelationSource (for binding) and the 7277 // inner ref's exact alias spelling (for the synthesised alias). 7278 LinkedHashMap<String, RelationSource> outerByLower = new LinkedHashMap<>(); 7279 LinkedHashMap<String, String> firstRefSpellingByLower = new LinkedHashMap<>(); 7280 7281 for (ColumnRef ref : collectAllInnerRefs(innerStmt)) { 7282 String aliasLower = ref.getRelationAlias().toLowerCase(Locale.ROOT); 7283 if (innerAliasesLower.contains(aliasLower)) continue; // local — not correlated 7284 if (outerByLower.containsKey(aliasLower)) continue; // already promoted 7285 7286 // Slice 20: chain-walking lookup. Walks the EnclosingScope 7287 // chain innermost → outermost; first match wins (shadowing 7288 // semantics). slice 14/15 used a single-level get(); slice 7289 // 20 generalises so inner-inner scalars can resolve 7290 // grandparent (and deeper) aliases. 7291 RelationSource outerRel = enclosingScope.lookupAlias(aliasLower); 7292 if (outerRel == null) { 7293 throw new SemanticIRBuildException( 7294 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS, 7295 "scalar subquery '" + outerAlias 7296 + "' references unknown alias '" + ref.getRelationAlias() 7297 + "' (column '" + ref.getRelationAlias() + "." 7298 + ref.getColumnName() 7299 + "'); not in inner relations or any enclosing scope", (TParseTreeNode) null)); 7300 } 7301 RelationKind outerKind = outerRel.getBinding().getKind(); 7302 if (outerKind != RelationKind.TABLE 7303 && outerKind != RelationKind.CTE 7304 && outerKind != RelationKind.SUBQUERY) { 7305 throw new SemanticIRBuildException( 7306 Diagnostic.error(DiagnosticCode.CORRELATED_SCALAR_SUBQUERY_UNKNOWN_OUTER_BINDING, 7307 "correlated scalar subquery '" + outerAlias 7308 + "' references outer alias '" + ref.getRelationAlias() 7309 + "' bound to a " + outerKind 7310 + "; only TABLE / CTE / SUBQUERY-bound outer correlations supported", (TParseTreeNode) null)); 7311 } 7312 outerByLower.put(aliasLower, outerRel); 7313 firstRefSpellingByLower.put(aliasLower, ref.getRelationAlias()); 7314 } 7315 7316 if (outerByLower.isEmpty()) return innerStmt; 7317 7318 List<RelationSource> augmented = new ArrayList<>(innerStmt.getRelations()); 7319 for (String key : outerByLower.keySet()) { 7320 RelationSource outerRel = outerByLower.get(key); 7321 augmented.add(new RelationSource( 7322 firstRefSpellingByLower.get(key), 7323 new RelationBinding(RelationKind.OUTER_REFERENCE, 7324 outerRel.getBinding().getQualifiedName(), 7325 outerRel.getBinding().getKind()))); 7326 } 7327 return rebuildStatementGraphWithRelations(innerStmt, augmented); 7328 } 7329 7330 /** 7331 * Collect every {@link ColumnRef} reachable from {@code innerStmt}'s 7332 * seven clause-bearing fields. Used by 7333 * {@link #promoteCorrelatedRefsToOuterReference} (slice-11 scalar 7334 * bodies) and the JOIN-ON EXISTS predicate-body correlation walker 7335 * (slice 23+); mirrors the old rejecter's clause coverage exactly 7336 * (output sources, filter, join, groupBy, having, orderBy, and 7337 * slice-73 distinctOn). 7338 */ 7339 private static List<ColumnRef> collectAllInnerRefs(StatementGraph innerStmt) { 7340 List<ColumnRef> all = new ArrayList<>(); 7341 for (OutputColumn out : innerStmt.getOutputColumns()) { 7342 all.addAll(out.getSources()); 7343 } 7344 all.addAll(innerStmt.getFilterColumnRefs()); 7345 all.addAll(innerStmt.getJoinColumnRefs()); 7346 all.addAll(innerStmt.getGroupByColumnRefs()); 7347 all.addAll(innerStmt.getHavingColumnRefs()); 7348 all.addAll(innerStmt.getOrderByColumnRefs()); 7349 all.addAll(innerStmt.getDistinctOnColumnRefs()); 7350 return all; 7351 } 7352 7353 /** 7354 * Copy a {@link StatementGraph} replacing only its relations list. 7355 * StatementGraph is otherwise immutable. 7356 */ 7357 private static StatementGraph rebuildStatementGraphWithRelations( 7358 StatementGraph stmt, List<RelationSource> relations) { 7359 return new StatementGraph( 7360 stmt.getName(), 7361 stmt.getKind(), 7362 relations, 7363 stmt.getOutputColumns(), 7364 stmt.getFilterColumnRefs(), 7365 stmt.getJoinColumnRefs(), 7366 stmt.getGroupByColumnRefs(), 7367 stmt.getHavingColumnRefs(), 7368 stmt.getOrderByColumnRefs(), 7369 stmt.getDistinctOnColumnRefs(), 7370 stmt.isDistinct(), 7371 stmt.getSetOperator(), 7372 stmt.getRowLimit()) 7373 // Slice 180 (R5): preserve the block span the legacy ctor 7374 // would otherwise drop (this copy only swaps relations). 7375 .withSourceSpan(stmt.getSourceSpan()); 7376 } 7377 7378 /** 7379 * Slice 16 preflight: reject any FROM-clause subquery directly on a 7380 * set-op branch's FROM/JOIN list, BEFORE 7381 * {@link #extractScalarSubqueriesAsStatements} can append scalar-body 7382 * statements to {@code stmts}/{@code lineage}. Without this preflight, 7383 * a branch with both a FROM-subquery and a scalar projection would 7384 * extract the scalar (mutating shared state) and then fail later in 7385 * {@code buildSelectStatement}, producing a confusing scalar-correlation 7386 * error message instead of the slice-12 FROM-subquery boundary. 7387 * 7388 * <p>Inspects only the branch's DIRECT FROM/JOIN entries — does NOT 7389 * recurse into result-column expressions or scalar-body inner SELECTs 7390 * (those are handled by the recursive scalar-body build's own 7391 * {@code allowFromSubqueries=false} guard). The error message contains 7392 * both {@code set-op branch} and {@code FROM-clause subquery} keywords 7393 * so the slice-12 boundary is surfaced explicitly. 7394 */ 7395 private static void rejectFromSubqueriesInSetOpBranch(TSelectSqlStatement br) { 7396 if (br.joins == null) return; 7397 for (TJoin join : br.joins) { 7398 TTable left = join.getTable(); 7399 if (left != null 7400 && left.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 7401 throw new SemanticIRBuildException( 7402 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM, 7403 "FROM-clause subquery directly in a set-op branch FROM is not " 7404 + "supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null)); 7405 } 7406 // Slice 140: a PIVOT/UNPIVOT branch whose SOURCE is a FROM-subquery 7407 // (`SELECT ... FROM (SELECT ...) PIVOT(...) UNION ALL ...`) is 7408 // deferred. The slice-138 `processDirectSubqueryTable` pre-extraction 7409 // is NOT called from the set-op branch path (set-op branches set 7410 // `allowFromSubqueries=false`), so routing such a shape to 7411 // `buildPivotSelect` would land in `buildRelation` on the inner 7412 // subquery TTable with empty `subqueryAliasToIndex` and emit a 7413 // less-informative second-step `TABLE_BINDING_UNRESOLVED` against 7414 // the inner subquery. Reuse the existing 7415 // `FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM` code with a discriminated 7416 // message; no new DiagnosticCode. 7417 if (left != null 7418 && left.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 7419 && left.getPivotedTable() != null 7420 && !left.getPivotedTable().getRelations().isEmpty() 7421 && left.getPivotedTable().getRelations().get(0) != null 7422 && left.getPivotedTable().getRelations().get(0).getTableType() 7423 == gudusoft.gsqlparser.ETableSource.subquery) { 7424 throw new SemanticIRBuildException( 7425 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_SET_OP_BRANCH_FROM, 7426 "FROM-clause subquery as a PIVOT source in a set-op branch is " 7427 + "not supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null)); 7428 } 7429 TJoinItemList items = join.getJoinItems(); 7430 if (items == null) continue; 7431 for (int i = 0; i < items.size(); i++) { 7432 TTable r = items.getJoinItem(i).getTable(); 7433 if (r != null 7434 && r.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 7435 throw new SemanticIRBuildException( 7436 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_ON_JOIN_SIDE_IN_SET_OP_BRANCH, 7437 "FROM-clause subquery on a JOIN side in a set-op branch is " 7438 + "not supported yet (set-op branch with FROM-clause subquery)", (TParseTreeNode) null)); 7439 } 7440 } 7441 } 7442 } 7443 7444 // ==================================================================== 7445 // Slice 12: Set operations (UNION / INTERSECT / MINUS / EXCEPT). 7446 // 7447 // Algorithm: each branch of the set-op tree is built as its own 7448 // synthetically-named StatementGraph; the outer set-op statement has 7449 // empty relations and per-output `STATEMENT_OUTPUT → STATEMENT_OUTPUT` 7450 // lineage edges to each branch's corresponding output. The flatten 7451 // walks the left-leaning AST iteratively (CLAUDE.md mandates no 7452 // recursion on leftStmt/rightStmt; would StackOverflow on 2000+ UNIONs). 7453 // 7454 // Internal-node modifiers (ORDER BY / row-limits) are rejected on every 7455 // set-op node visited (root + internal), not just the root, because 7456 // parenthesized inner combined nodes can carry those modifiers (per 7457 // /tmp/SetOpInnerModifierProbe: `(A UNION B ORDER BY id) UNION C` 7458 // attaches ORDER BY to the inner node in Oracle / PostgreSQL / MSSQL; 7459 // PostgreSQL maps inner FETCH FIRST → LIMIT). 7460 // ==================================================================== 7461 7462 /** 7463 * Build a set-op program: flatten branches, build each as its own 7464 * StatementGraph, construct the outer set-op statement, emit lineage. 7465 * Returns the outer set-op statement's index in {@code stmts}. 7466 * 7467 * @param setOp the {@link TSelectSqlStatement} carrying 7468 * {@code setOperatorType != none}. 7469 * @param setOpName non-null when the set-op is a CTE body (the outer 7470 * statement is named with the CTE name); null when the set-op is 7471 * the program's top-level outer. 7472 * @param hasOuterCteListAlreadyProcessed true when the caller already 7473 * processed the set-op root's CTE list (top-level dispatch); 7474 * false when this is a recursive context (set-op CTE body) where 7475 * a non-empty CTE list on the set-op root is rejected as a 7476 * nested WITH. 7477 */ 7478 private static int buildSetOpProgram(TSelectSqlStatement setOp, 7479 NameBindingProvider provider, 7480 List<StatementGraph> stmts, 7481 List<LineageEdge> lineage, 7482 Map<String, Integer> cteNameToStatementIndex, 7483 String setOpName, 7484 boolean hasOuterCteListAlreadyProcessed) { 7485 // Nested-WITH guard (rd-5 MUST 1): when called from a CTE body 7486 // context, the set-op root must not carry its own CTE list. 7487 if (!hasOuterCteListAlreadyProcessed 7488 && setOp.getCteList() != null 7489 && setOp.getCteList().size() > 0) { 7490 throw new SemanticIRBuildException( 7491 Diagnostic.error(DiagnosticCode.NESTED_WITH_NOT_SUPPORTED, 7492 "nested WITH/CTE inside a CTE body or subquery is not supported yet " 7493 + "(set-op CTE body has its own CTE list)", (TParseTreeNode) null)); 7494 } 7495 // Slice 21: ORDER BY is now collected from the outer set-op 7496 // (see buildSetOpOuterOrderByColumnRefs). 7497 // Slice 72: outer row-limit lifted via buildSetOpRowLimit. 7498 // The internal-node reject (parenthesized inner set-ops with 7499 // row-limit) fires inside flattenSetOpTreeIteratively. Compute 7500 // BEFORE the snapshot block so a defensive throw (Hive/Vertica/ 7501 // ANSI-DB2 guards, MSSQL null-valued TOrderBy slots) propagates 7502 // without leaving stmts/lineage partially populated. 7503 RowLimit setOpRowLimit = buildSetOpRowLimit(setOp); 7504 7505 // Slice 16: SET-OP-WIDE TRANSACTIONAL ROLLBACK (codex rounds 3-5 7506 // adversarial findings). Snapshot `stmts.size()` and 7507 // `lineage.size()` BEFORE any branch mutation. On any 7508 // SemanticIRBuildException thrown by the per-branch loop or 7509 // post-build validation, truncate both lists back to the snapshot 7510 // and rethrow. This addresses the full class of "mutation-free 7511 // branch validation that fires after earlier-branch mutations": 7512 // FROM-subquery preflight (round 3), column-count check (round 4), 7513 // multi-scalar-projection per-branch validation (round 5), branch 7514 // duplicate-output-names check (round 5), and any future check 7515 // that may join the same class. The pre-loop preflight below 7516 // remains for fast-fail with better error messages on common 7517 // shapes, but the rollback is the safety net. 7518 int stmtsSnapshot = stmts.size(); 7519 int lineageSnapshot = lineage.size(); 7520 try { 7521 return buildSetOpProgramInternal(setOp, provider, stmts, lineage, 7522 cteNameToStatementIndex, setOpName, 7523 hasOuterCteListAlreadyProcessed, setOpRowLimit); 7524 } catch (RuntimeException e) { 7525 while (stmts.size() > stmtsSnapshot) stmts.remove(stmts.size() - 1); 7526 while (lineage.size() > lineageSnapshot) lineage.remove(lineage.size() - 1); 7527 throw e; 7528 } 7529 } 7530 7531 /** 7532 * Internal body of {@link #buildSetOpProgram}. Wrapped with 7533 * snapshot/rollback by the public entry point; do not call directly. 7534 */ 7535 private static int buildSetOpProgramInternal(TSelectSqlStatement setOp, 7536 NameBindingProvider provider, 7537 List<StatementGraph> stmts, 7538 List<LineageEdge> lineage, 7539 Map<String, Integer> cteNameToStatementIndex, 7540 String setOpName, 7541 boolean hasOuterCteListAlreadyProcessed, 7542 RowLimit setOpRowLimit) { 7543 7544 SetOperator setOpKind = resolveSetOperator(setOp); 7545 List<TSelectSqlStatement> branches = flattenSetOpTreeIteratively(setOp, setOpKind); 7546 if (branches.size() < 2) { 7547 throw new SemanticIRBuildException( 7548 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COUNT_TOO_FEW, 7549 "set-op flatten produced " + branches.size() 7550 + " branches; expected at least 2", (TParseTreeNode) null)); 7551 } 7552 7553 // Slice 16: PRE-LOOP PREFLIGHT (codex round-3 + round-4 adversarial 7554 // findings — medium). Run all mutation-free branch validation across 7555 // EVERY branch BEFORE the main build loop runs. Without this, a 7556 // scalar-bearing earlier branch can append scalar-body statements to 7557 // `stmts`/`lineage` BEFORE a later branch's rejection fires, leaving 7558 // partial state on the rejection path. The slice-16 safety claim is 7559 // "no half-built scalar bodies leak when a branch is rejected"; that 7560 // claim only holds when ALL mutation-free branch checks are 7561 // set-op-wide. 7562 // 7563 // Checks bundled here (each is AST-only and side-effect-free): 7564 // - Defensive nested-set-op leaf check (slice 12). 7565 // - Direct branch FROM-subquery rejection (slice 16 round 3). 7566 // - Result-column-count compatibility across branches (slice 12, 7567 // moved here in slice 16 round 4 — uses AST 7568 // getResultColumnList().size() since BUILT outputColumns.size() 7569 // is unavailable pre-build; the post-loop check on BUILT 7570 // outputs stays as a defensive backup if AST-vs-built ever 7571 // diverges for a future shape). 7572 int expectedAstCols = -1; 7573 for (int i = 0; i < branches.size(); i++) { 7574 TSelectSqlStatement br = branches.get(i); 7575 if (br.getSetOperatorType() != null 7576 && br.getSetOperatorType() != ESetOperatorType.none) { 7577 throw new SemanticIRBuildException( 7578 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_IS_SET_OP, 7579 "set-op branch is itself a set operation; nested set " 7580 + "operations in branches are not supported yet", (TParseTreeNode) null)); 7581 } 7582 rejectFromSubqueriesInSetOpBranch(br); 7583 int astCols = br.getResultColumnList() == null 7584 ? 0 7585 : br.getResultColumnList().size(); 7586 if (i == 0) { 7587 expectedAstCols = astCols; 7588 } else if (astCols != expectedAstCols) { 7589 throw new SemanticIRBuildException( 7590 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COLUMN_COUNT_MISMATCH, 7591 "set-op branch column-count mismatch: branch[0] has " 7592 + expectedAstCols + " columns, branch[" + i + "] has " 7593 + astCols, (TParseTreeNode) null)); 7594 } 7595 } 7596 7597 int[] branchIdxs = new int[branches.size()]; 7598 for (int i = 0; i < branches.size(); i++) { 7599 TSelectSqlStatement br = branches.get(i); 7600 7601 // Slice 16: per-branch enclosing scope. allowFromSubqueries 7602 // stays false at the branch level so subqueryAliasToIndex 7603 // is empty (no OUTER_REFERENCE-of-SUBQUERY in branches). 7604 // CTE-aware so a branch's scalar can correlate to a CTE 7605 // visible in scope (top-level set-ops have all outer CTEs 7606 // indexed; CTE-body set-ops have only prior visible CTEs 7607 // indexed — the current CTE is registered AFTER this method 7608 // returns, by design, mirroring the non-set-op CTE body path). 7609 EnclosingScope branchEnclosing = buildEnclosingScope(br, 7610 cteNameToStatementIndex, 7611 Collections.<String, Integer>emptyMap(), 7612 /*parent=*/ null); 7613 // Slice 20: pass `false` so the slice-12 / slice-16 boundary 7614 // holds — branch scalar bodies must NOT host another scalar 7615 // projection. The branch's TOP-LEVEL scalar projection is 7616 // still allowed (slice 16); deeper recursion is not. 7617 Map<Integer, ScalarInfo> branchScalarMap = 7618 extractScalarSubqueriesAsStatements(br, provider, 7619 stmts, lineage, cteNameToStatementIndex, branchEnclosing, 7620 /*allowRecursiveScalarSubqueryExtraction=*/ false); 7621 7622 // Slice 16: compute branchName AFTER scalar extraction so the 7623 // digit suffix in `<set_op_branch_<idx>>` matches the branch's 7624 // final position in `stmts`. Scalar bodies appended by 7625 // extractScalarSubqueriesAsStatements come BEFORE the branch 7626 // in `stmts`, so pre-extraction `stmts.size()` would be wrong 7627 // by (number of scalar bodies in this branch) — breaking the 7628 // slice-12 invariant that branch synthetic names round-trip 7629 // to their statement index. 7630 // 7631 // Slice 113 — predicate bodies extracted from the branch's 7632 // WHERE clause via {@link PredicateClauseContext#SET_OP_BRANCH_WHERE} 7633 // also land in {@code stmts} BEFORE the branch, INSIDE the 7634 // {@code buildSelectStatementImpl} call below. So the 7635 // pre-build {@code stmts.size()} can still understate the 7636 // branch's final position. The slice-12/16 invariant is 7637 // preserved by computing a tentative name pre-build (best 7638 // guess used by inner consumers that need a non-null name), 7639 // then rebuilding the StatementGraph with the corrected 7640 // name AFTER the build via {@link #withRenamedTo} if any 7641 // predicate body was extracted. 7642 int preBuildStmtsSize = stmts.size(); 7643 String tentativeBranchName = SET_OP_BRANCH_PREFIX + preBuildStmtsSize + ">"; 7644 StatementGraph branchStmt; 7645 if (isPivotSelect(br)) { 7646 // Slice 140: a nested PIVOT / UNPIVOT in a set-op branch 7647 // (`SELECT ... FROM base PIVOT(...) UNION ALL ...`). The 7648 // slice-129 pivot router in {@link #buildSelectStatementImpl} 7649 // is gated to `name == null && !isPredicateBody`; a set-op 7650 // branch carries the synthetic `<set_op_branch_N>` name, so 7651 // the gate misses and the branch would otherwise fall to the 7652 // normal path which can't bind a `pivoted_table` (rejects 7653 // with the misleading TABLE_BINDING_UNRESOLVED 7654 // "null(piviot_table)"). Route it to `buildPivotSelect` with 7655 // the synthetic branch name so the branch becomes a proper 7656 // pivot StatementGraph. PIVOT extra-clause / chained / 7657 // malformed-UNPIVOT / bare-`*`-without-catalog rejects stay 7658 // deferred via `buildPivotSelect`'s own guards. The 7659 // FROM-subquery-source case is pre-flight-rejected by the 7660 // extended {@link #rejectFromSubqueriesInSetOpBranch} above, 7661 // so `buildPivotSelect.buildRelation` never lands on an 7662 // unresolvable subquery TTable here. No 7663 // `withRenamedTo` rebuild block is needed below because 7664 // `buildPivotSelect` does not extract predicate bodies during 7665 // its build (its `rejectPivotExtraClauses` fires on WHERE / 7666 // GROUP BY / HAVING / ORDER BY / QUALIFY / DISTINCT / 7667 // row-limit / set-op). 7668 // 7669 // Direct analogue of slice 138 (FROM-subquery body via 7670 // `processDirectSubqueryTable`) and slice 139 (CTE body 7671 // via the four CTE walkers). This is the FIFTH and final 7672 // SemanticIRBuilder routing site that previously missed 7673 // the pivot router. 7674 branchStmt = buildPivotSelect(br, provider, tentativeBranchName); 7675 } else { 7676 branchStmt = buildSelectStatementImpl(br, provider, tentativeBranchName, 7677 /*hasOuterCteListAlreadyProcessed=*/ false, 7678 /*allowFromSubqueries=*/ false, 7679 /*allowScalarProjectionSubqueries=*/ true, // ← slice-16 lift 7680 /*allowWindowProjection=*/ true, 7681 // Slice 113 keeps JOIN-ON predicate subqueries rejected 7682 // in set-op branches (slice 23 / 26 contract pinned by 7683 // existsInSetOpBranchJoinOnStillRejected / 7684 // lhsSubqueryInSetOpBranchRejected) — the lift is 7685 // WHERE-only. The two flags are now independent. 7686 /*allowJoinOnPredicateSubqueries=*/ false, 7687 /*stmtsForExtraction=*/ stmts, // ← slice-113 7688 /*lineageForExtraction=*/ lineage, // ← slice-113 7689 /*cteMapForExtraction=*/ cteNameToStatementIndex, // ← slice-113 7690 /*isPredicateBody=*/ false, 7691 /*whereClauseContext=*/ PredicateClauseContext.SET_OP_BRANCH_WHERE, 7692 /*allowWherePredicateSubqueries=*/ true); // ← slice-113 lift 7693 } 7694 int idx = stmts.size(); 7695 if (idx != preBuildStmtsSize) { 7696 // Slice 113 — predicate bodies were appended during 7697 // the branch build. Rebuild the StatementGraph with 7698 // the corrected name so the slice-12/16 invariant 7699 // (digit suffix == final position) survives. The 7700 // rebuild copies all 15 fields; no LineageRef is 7701 // affected because they are idx-based, not name-based 7702 // (codex round-1 Q4 resolution). 7703 String finalBranchName = SET_OP_BRANCH_PREFIX + idx + ">"; 7704 branchStmt = withRenamedTo(branchStmt, finalBranchName); 7705 } 7706 rejectDuplicateOutputNames(branchStmt, branchStmt.getName()); 7707 branchIdxs[i] = idx; 7708 stmts.add(branchStmt); 7709 // Branch's own per-output, filter, and join lineage. Pass 7710 // the branch's scalar map so STATEMENT_OUTPUT → 7711 // STATEMENT_OUTPUT edges to scalar bodies are emitted. 7712 // subqueryAliasToIndex stays empty (allowFromSubqueries=false 7713 // for branches, so no FROM-subquery aliases exist at this scope). 7714 emitLineageForStatement(branchStmt, idx, lineage, 7715 cteNameToStatementIndex, 7716 Collections.<String, Integer>emptyMap(), 7717 branchScalarMap); 7718 } 7719 7720 // Validate column-count alignment via BUILT statements. 7721 int expectedCols = stmts.get(branchIdxs[0]).getOutputColumns().size(); 7722 for (int i = 1; i < branches.size(); i++) { 7723 int n = stmts.get(branchIdxs[i]).getOutputColumns().size(); 7724 if (n != expectedCols) { 7725 throw new SemanticIRBuildException( 7726 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_COLUMN_COUNT_MISMATCH, 7727 "set-op branch column-count mismatch: branch[0] has " 7728 + expectedCols + " columns, branch[" + i + "] has " + n, (TParseTreeNode) null)); 7729 } 7730 } 7731 7732 // Build outer outputs from branch[0]'s built outputs. 7733 StatementGraph branch0 = stmts.get(branchIdxs[0]); 7734 List<OutputColumn> outerOutputs = new ArrayList<>(expectedCols); 7735 Set<String> seenOuter = new HashSet<>(); 7736 for (int i = 0; i < expectedCols; i++) { 7737 OutputColumn b0 = branch0.getOutputColumns().get(i); 7738 String name = b0.getName(); 7739 if (name == null || name.isEmpty()) { 7740 throw new SemanticIRBuildException( 7741 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_OUTPUT_NAME_UNUSABLE, 7742 "set-op output position " + i + " has no usable name in branch[0]; " 7743 + "add an alias to the SELECT-list expression", (TParseTreeNode) null)); 7744 } 7745 if (!seenOuter.add(name.toLowerCase(Locale.ROOT))) { 7746 throw new SemanticIRBuildException( 7747 Diagnostic.error(DiagnosticCode.SET_OP_DUPLICATE_OUTER_OUTPUT_NAME, 7748 "set-op outer output name '" + name + "' is duplicated " 7749 + "(branch[0] has duplicate output names)", null)); 7750 } 7751 outerOutputs.add(new OutputColumn(name, 7752 /*derived=*/ true, 7753 /*aggregate=*/ false, 7754 /*sources=*/ Collections.<ColumnRef>emptyList(), 7755 /*windowSpec=*/ null)); 7756 } 7757 7758 // Slice 21: collect outer ORDER BY refs from branches' base sources. 7759 // A throw here unwinds via the slice-16 snapshot/rollback wrapper 7760 // in buildSetOpProgram(), so partially-built branches/scalar-bodies 7761 // do not leak into stmts/lineage on rejection. 7762 List<ColumnRef> outerOrderByRefs = buildSetOpOuterOrderByColumnRefs( 7763 setOp, outerOutputs, stmts, branchIdxs); 7764 7765 StatementGraph outer = new StatementGraph(setOpName, "SELECT", 7766 /*relations=*/ Collections.<RelationSource>emptyList(), 7767 outerOutputs, 7768 /*filterColumnRefs=*/ Collections.<ColumnRef>emptyList(), 7769 /*joinColumnRefs=*/ Collections.<ColumnRef>emptyList(), 7770 /*groupByColumnRefs=*/Collections.<ColumnRef>emptyList(), 7771 /*havingColumnRefs=*/ Collections.<ColumnRef>emptyList(), 7772 /*orderByColumnRefs=*/outerOrderByRefs, 7773 /*distinctOnColumnRefs=*/Collections.<ColumnRef>emptyList(), 7774 /*distinct=*/ false, 7775 /*setOperator=*/ setOpKind, 7776 /*rowLimit=*/ setOpRowLimit); 7777 // Slice 180 (R5): the set-op outer block's span covers the whole 7778 // set-op statement. 7779 outer = outer.withSourceSpan(SourceSpan.of(setOp)); 7780 int outerIdx = stmts.size(); 7781 stmts.add(outer); 7782 7783 // Lineage: outer.outputs[i] → each branch.outputs[i] (in branch order). 7784 for (int i = 0; i < expectedCols; i++) { 7785 OutputColumn out = outer.getOutputColumns().get(i); 7786 for (int b = 0; b < branches.size(); b++) { 7787 StatementGraph branchStmt = stmts.get(branchIdxs[b]); 7788 String branchOutName = branchStmt.getOutputColumns().get(i).getName(); 7789 lineage.add(new LineageEdge( 7790 LineageRef.statementOutput(outerIdx, out.getName()), 7791 LineageRef.statementOutput(branchIdxs[b], branchOutName))); 7792 } 7793 } 7794 return outerIdx; 7795 } 7796 7797 /** 7798 * Reject row-limit clauses on an INTERNAL (non-root) set-op node. 7799 * Slice 9 / 12 rationale: with ORDER BY they decide which rows 7800 * survive, so the canonical-model exclusion of ORDER BY is only 7801 * sound when no row-limit is present. 7802 * 7803 * <p>Slice 21 split this from {@code rejectSetOpInternalOrderBy} 7804 * because the OUTER set-op node lifts its ORDER BY (collected via 7805 * {@link #buildSetOpOuterOrderByColumnRefs}). Slice 72 narrows the 7806 * row-limit guard the same way: the OUTER set-op node now lifts 7807 * row-limit metadata (collected via {@link #buildSetOpRowLimit}), 7808 * while parenthesized inner combined operations carrying a 7809 * row-limit (e.g. {@code (A UNION B LIMIT 3) UNION C}) remain 7810 * rejected because the intermediate limit is destroyed by the 7811 * outer set operation. 7812 */ 7813 private static void rejectSetOpRowLimit(TSelectSqlStatement node) { 7814 if (node.getLimitClause() != null) { 7815 throw new SemanticIRBuildException( 7816 Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED, 7817 "row-limit clause LIMIT on a non-root set-op node is not supported yet", (TParseTreeNode) null)); 7818 } 7819 if (node.getTopClause() != null) { 7820 throw new SemanticIRBuildException( 7821 Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED, 7822 "row-limit clause TOP on a non-root set-op node is not supported yet", (TParseTreeNode) null)); 7823 } 7824 if (node.getFetchFirstClause() != null) { 7825 throw new SemanticIRBuildException( 7826 Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED, 7827 "row-limit clause FETCH FIRST on a non-root set-op node is not supported yet", (TParseTreeNode) null)); 7828 } 7829 if (node.getOffsetClause() != null) { 7830 throw new SemanticIRBuildException( 7831 Diagnostic.error(DiagnosticCode.SET_OP_ROW_LIMIT_NOT_SUPPORTED, 7832 "row-limit clause OFFSET on a non-root set-op node is not supported yet", (TParseTreeNode) null)); 7833 } 7834 } 7835 7836 /** 7837 * Reject ORDER BY on an INTERNAL (non-root) set-op node. Slice 21 7838 * lifted ORDER BY on the OUTER (root) set-op, but parenthesized 7839 * inner combined nodes like 7840 * {@code (A UNION B ORDER BY id) UNION C} remain rejected: the 7841 * intermediate sort is destroyed by the outer set operation 7842 * (UNION does not preserve order), so the inner ORDER BY has no 7843 * observable effect. Lifting requires modelling intermediate sort 7844 * semantics — a future slice. 7845 */ 7846 private static void rejectSetOpInternalOrderBy(TSelectSqlStatement node) { 7847 if (node.getOrderbyClause() != null) { 7848 throw new SemanticIRBuildException( 7849 Diagnostic.error(DiagnosticCode.SET_OP_NON_ROOT_ORDER_BY_NOT_SUPPORTED, 7850 "ORDER BY on a non-root set-op node is not supported yet " 7851 + "(intermediate sort would be discarded by the outer set operation)", (TParseTreeNode) null)); 7852 } 7853 } 7854 7855 /** 7856 * Collect physical column refs for the outer set-op's ORDER BY 7857 * clause. Slice 21 lifts the slice-12 rejection on set-op outer 7858 * ORDER BY using the slice-9 single-SELECT pattern, generalised: 7859 * 7860 * <ul> 7861 * <li>Each sort-key item passes the same shape rejections as 7862 * {@link #buildOrderByColumnRefs} (ordinals, constants, 7863 * scalar / predicate subqueries, window functions, ORDER 7864 * SIBLINGS BY, RESET WHEN, in-clause OFFSET/FETCH). 7865 * <li>Each {@link TObjectName} reference dispatches via a 7866 * four-case fail-closed taxonomy: {@code column_alias} → 7867 * lookup via {@code toString()}; unqualified {@code column} 7868 * → lookup via {@code getColumnNameOnly()}; qualified 7869 * {@code column} → reject (set-op outer scope is the 7870 * unioned outputs, not branches' tables); other 7871 * {@code dbObjectType} → reject as unsupported. 7872 * <li>The lookup is positional against {@code outerOutputs} (= 7873 * branch[0].outputs by slice-12 design) — NOT per-branch 7874 * name search. Per-branch name search would mis-bind 7875 * swapped-name branches and silently accept names present 7876 * only in non-branch[0]. Slice-21 codex rounds 1-2 MUSTs. 7877 * <li>Each branch contributes its 7878 * {@code outputColumns[pos].sources} for the matched 7879 * position. Branches with empty sources at the matched 7880 * position (scalar / fully-derived projection) reject the 7881 * sort key with a tuned message — silent omission would 7882 * lose dependency information (slice-21 codex round 1 7883 * MUST 5). 7884 * <li>Each branch-local {@link ColumnRef} is normalised to its 7885 * catalog name via {@link RelationSource#getBinding()}'s 7886 * {@code qualifiedName}. The set-op outer's relations list 7887 * is empty, so branch-local aliases are not resolvable in 7888 * the owning statement; normalisation yields self-contained 7889 * refs (slice-21 codex round 1 MUST 4 + round 2 MUST 1). 7890 * <li>A per-item empty-refs guard rejects sort keys that 7891 * contributed zero physical refs (e.g. {@code ORDER BY 7892 * 1+0}, {@code ORDER BY UPPER('x')}). Mirrors slice-9 7893 * single-SELECT invariant. Operates on a per-item local 7894 * set, so duplicate cross-item refs ({@code ORDER BY id, 7895 * id}) survive global LinkedHashSet de-duplication 7896 * (slice-21 codex round 4 MUST 1). 7897 * </ul> 7898 * 7899 * <p>Like slice-9 ORDER BY for single-SELECT, this list does NOT 7900 * contribute to the canonical model — it is presentation metadata 7901 * only. The dlineage XML probe ({@code /tmp/SetOpOrderByLimitProbe}) 7902 * confirmed dlineage emits no parity edges for set-op outer 7903 * ORDER BY. 7904 */ 7905 private static List<ColumnRef> buildSetOpOuterOrderByColumnRefs( 7906 TSelectSqlStatement setOp, 7907 List<OutputColumn> outerOutputs, 7908 List<StatementGraph> stmts, 7909 int[] branchIdxs) { 7910 TOrderBy orderBy = setOp.getOrderbyClause(); 7911 if (orderBy == null) { 7912 return new ArrayList<>(); 7913 } 7914 if (orderBy.isSiblings()) { 7915 throw new SemanticIRBuildException( 7916 Diagnostic.error(DiagnosticCode.ORDER_SIBLINGS_BY_NOT_SUPPORTED, 7917 "ORDER SIBLINGS BY is not supported yet " 7918 + "(Oracle hierarchical ordering)", orderBy)); 7919 } 7920 if (orderBy.getResetWhenCondition() != null) { 7921 throw new SemanticIRBuildException( 7922 Diagnostic.error(DiagnosticCode.ORDER_BY_RESET_WHEN_NOT_SUPPORTED, 7923 "ORDER BY ... RESET WHEN is not supported yet " 7924 + "(Teradata window-style restart)", orderBy)); 7925 } 7926 // Slice 72: TOrderBy in-clause OFFSET/FETCH slots are admitted 7927 // for MSSQL set-op outer via buildSetOpRowLimit's TOrderBy 7928 // fallback (the MSSQL parser routes set-op outer OFFSET/FETCH 7929 // EXCLUSIVELY onto TOrderBy, not duplicated onto the SELECT 7930 // node as in single-SELECT). Removing the previous defensive 7931 // throws here so the slice-72 admit shapes aren't false- 7932 // rejected. The unused codes 7933 // ORDER_BY_FETCH_FIRST_NOT_SUPPORTED and 7934 // ORDER_BY_OFFSET_NOT_SUPPORTED stay as documentation of a 7935 // known reject taxonomy. 7936 TOrderByItemList items = orderBy.getItems(); 7937 if (items == null || items.size() == 0) { 7938 return new ArrayList<>(); 7939 } 7940 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 7941 for (int i = 0; i < items.size(); i++) { 7942 TOrderByItem item = items.getOrderByItem(i); 7943 if (item == null) continue; 7944 TExpression sortKey = item.getSortKey(); 7945 if (sortKey == null) continue; 7946 // Same shape rejections as slice-9 single-SELECT. 7947 rejectOrderByOrdinalOrConstant(sortKey); 7948 rejectOrderByScalarSubquery(sortKey); 7949 rejectOrderByWindowFunction(sortKey); 7950 // (NOT rejectOrderByAliasReference — alias refs are valid 7951 // at set-op outer scope; they ARE the branch-output names, 7952 // looked up positionally below.) 7953 7954 // Per-item local set so the empty-refs guard counts refs 7955 // FOUND for this sort key, not refs ADDED to the global 7956 // set after de-dup. Otherwise `ORDER BY id, id` would 7957 // false-reject the second item (slice-21 codex round 4 MUST 1). 7958 LinkedHashSet<ColumnRef> itemRefs = new LinkedHashSet<>(); 7959 collectSetOpOuterRefsForSortKey(sortKey, outerOutputs, 7960 stmts, branchIdxs, itemRefs); 7961 if (itemRefs.isEmpty()) { 7962 throw new SemanticIRBuildException( 7963 Diagnostic.error(DiagnosticCode.SET_OP_OUTER_ORDER_BY_NO_PHYSICAL_COLUMN_REFS, 7964 "ORDER BY sort key '" + sortKey 7965 + "' has no physical column references at set-op " 7966 + "outer (constant or non-column expressions are " 7967 + "not supported yet)", sortKey)); 7968 } 7969 refs.addAll(itemRefs); 7970 } 7971 return new ArrayList<>(refs); 7972 } 7973 7974 /** 7975 * Collect refs for one set-op outer ORDER BY sort key. Walks the 7976 * sort-key expression for {@link TObjectName} nodes and dispatches 7977 * each through {@link #processSetOpOrderByObjectName}. Includes a 7978 * top-level fast path for the common {@code ORDER BY x} case where 7979 * the entire sort key IS the {@link TObjectName}. 7980 * 7981 * <p>The visitor filters its dispatch to {@code column}, 7982 * {@code column_alias}, and {@code unknown} dbObjectTypes — these 7983 * are the shapes that represent sort-key column references. Other 7984 * TObjectName nodes (function names, schema qualifications) are 7985 * part of the surrounding expression structure and skipped 7986 * silently. The {@code unknown} case is included so the four-case 7987 * fail-closed taxonomy in {@link #processSetOpOrderByObjectName} 7988 * rejects vendor-typed unknown qualified refs (e.g. 7989 * {@code foo.id + id}, slice-21 codex round 2 MUST 2). 7990 */ 7991 private static void collectSetOpOuterRefsForSortKey( 7992 TExpression sortKey, 7993 final List<OutputColumn> outerOutputs, 7994 final List<StatementGraph> stmts, 7995 final int[] branchIdxs, 7996 final LinkedHashSet<ColumnRef> outRefs) { 7997 // Top-level fast path: the visitor's `acceptChildren` may not 7998 // visit the root TObjectName when the sort key is itself a 7999 // bare TObjectName. Mirrors slice-9 rejectOrderByAliasReference. 8000 if (sortKey.getExpressionType() == EExpressionType.simple_object_name_t) { 8001 TObjectName op = sortKey.getObjectOperand(); 8002 if (op != null) { 8003 processSetOpOrderByObjectName(op, outerOutputs, stmts, 8004 branchIdxs, outRefs); 8005 return; 8006 } 8007 } 8008 sortKey.acceptChildren(new TParseTreeVisitor() { 8009 @Override 8010 public void preVisit(TObjectName node) { 8011 EDbObjectType ot = node.getDbObjectType(); 8012 // Skip non-column-like TObjectNames (function names, 8013 // schema/server qualifications). The four-case 8014 // fail-closed taxonomy still runs for column / 8015 // column_alias / unknown to handle the slice-21 codex 8016 // round 2 MUST 2 partial-accept case (e.g. 8017 // `foo.id + id` rejects via `foo.id`'s `unknown` 8018 // dbObjectType). 8019 if (ot != EDbObjectType.column 8020 && ot != EDbObjectType.column_alias 8021 && ot != EDbObjectType.unknown) { 8022 return; 8023 } 8024 processSetOpOrderByObjectName(node, outerOutputs, stmts, 8025 branchIdxs, outRefs); 8026 } 8027 }); 8028 } 8029 8030 /** 8031 * Resolve one {@link TObjectName} sort-key reference at set-op 8032 * outer scope. Four-case fail-closed taxonomy (slice-21 codex 8033 * round 2 MUST 2): column_alias / unqualified column / qualified 8034 * column / other. 8035 */ 8036 private static void processSetOpOrderByObjectName( 8037 TObjectName node, 8038 List<OutputColumn> outerOutputs, 8039 List<StatementGraph> stmts, 8040 int[] branchIdxs, 8041 LinkedHashSet<ColumnRef> outRefs) { 8042 EDbObjectType ot = node.getDbObjectType(); 8043 String name; 8044 if (ot == EDbObjectType.column_alias) { 8045 // Aliases at set-op outer carry tableToken=alias-name (the 8046 // /tmp/SetOpQualifiedRefProbe finding); accept regardless. 8047 name = node.toString(); 8048 } else if (ot == EDbObjectType.column) { 8049 if (node.getTableToken() != null) { 8050 throw new SemanticIRBuildException( 8051 Diagnostic.error(DiagnosticCode.ORDER_BY_QUALIFIED_REFERENCE_NOT_SUPPORTED, 8052 "qualified column reference '" + node 8053 + "' in set-op outer ORDER BY not supported " 8054 + "(scope is the unioned outputs, not branches' tables)", node)); 8055 } 8056 name = node.getColumnNameOnly(); 8057 } else { 8058 throw new SemanticIRBuildException( 8059 Diagnostic.error(DiagnosticCode.ORDER_BY_OBJECT_REFERENCE_UNSUPPORTED, 8060 "unsupported ORDER BY object reference '" + node 8061 + "' (dbObjectType=" + ot + ") in set-op outer", node)); 8062 } 8063 if (name == null || name.isEmpty() || "*".equals(name)) { 8064 throw new SemanticIRBuildException( 8065 Diagnostic.error(DiagnosticCode.ORDER_BY_OBJECT_REFERENCE_NO_USABLE_NAME, 8066 "ORDER BY object reference '" + node + "' has no usable name", node)); 8067 } 8068 String key = name.toLowerCase(Locale.ROOT); 8069 int pos = -1; 8070 for (int i = 0; i < outerOutputs.size(); i++) { 8071 String outName = outerOutputs.get(i).getName(); 8072 if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) { 8073 pos = i; 8074 break; 8075 } 8076 } 8077 if (pos < 0) { 8078 throw new SemanticIRBuildException( 8079 Diagnostic.error(DiagnosticCode.ORDER_BY_NAME_NOT_MATCHED_IN_SET_OP_OUTPUT, 8080 "ORDER BY '" + name + "' does not match any set-op output " 8081 + "column (set-op outer column names come from branch[0])", node)); 8082 } 8083 for (int b = 0; b < branchIdxs.length; b++) { 8084 StatementGraph br = stmts.get(branchIdxs[b]); 8085 OutputColumn oc = br.getOutputColumns().get(pos); 8086 if (oc.getSources().isEmpty()) { 8087 throw new SemanticIRBuildException( 8088 Diagnostic.error(DiagnosticCode.SET_OP_ORDER_BY_BRANCH_OUTPUT_NO_SOURCES, 8089 "ORDER BY '" + name + "' references branch[" + b 8090 + "] output '" + oc.getName() 8091 + "' which has no physical sources " 8092 + "(derived/scalar projection); cannot " 8093 + "capture this dependency yet", node)); 8094 } 8095 for (ColumnRef cr : oc.getSources()) { 8096 outRefs.add(normaliseSetOpBranchRef(cr, br)); 8097 } 8098 } 8099 } 8100 8101 /** 8102 * Normalise a branch-local {@link ColumnRef} to a self-contained 8103 * ref using the underlying {@link RelationBinding#getQualifiedName()}. 8104 * 8105 * <p>Slice-21 invariant (codex round 2 MUST 1): the set-op outer's 8106 * {@code relations} list is empty. Branch-local aliases (like 8107 * {@code e} for {@code FROM employees e}) are not resolvable in 8108 * the outer statement, so {@code orderByColumnRefs} normalises to 8109 * the catalog name. Fail-closed if no matching RelationSource — 8110 * this would indicate corrupt branch lineage state. 8111 */ 8112 private static ColumnRef normaliseSetOpBranchRef(ColumnRef cr, 8113 StatementGraph branch) { 8114 String alias = cr.getRelationAlias(); 8115 String aliasKey = alias.toLowerCase(Locale.ROOT); 8116 for (RelationSource rs : branch.getRelations()) { 8117 if (rs.getAlias().toLowerCase(Locale.ROOT).equals(aliasKey)) { 8118 return new ColumnRef(rs.getBinding().getQualifiedName(), 8119 cr.getColumnName()); 8120 } 8121 } 8122 throw new SemanticIRBuildException( 8123 Diagnostic.error(DiagnosticCode.BRANCH_COLUMN_REF_UNKNOWN_RELATION, 8124 "internal: branch ColumnRef relationAlias '" + alias 8125 + "' does not match any RelationSource in the " 8126 + "branch's relations list", null)); 8127 } 8128 8129 /** 8130 * Reject duplicate output names within a single statement. 8131 * Lineage refs are keyed by {@code (statementIndex, outputName)}; two 8132 * outputs sharing a name silently merge their lineage chains. 8133 */ 8134 private static void rejectDuplicateOutputNames(StatementGraph stmt, String label) { 8135 Set<String> seen = new HashSet<>(); 8136 for (OutputColumn c : stmt.getOutputColumns()) { 8137 String name = c.getName(); 8138 if (name == null || name.isEmpty()) continue; 8139 if (!seen.add(name.toLowerCase(Locale.ROOT))) { 8140 throw new SemanticIRBuildException( 8141 Diagnostic.error(DiagnosticCode.SET_OP_BRANCH_DUPLICATE_OUTPUT_NAME, 8142 "set-op branch '" + label + "' has duplicate output name '" 8143 + name + "'; lineage refs are keyed by output name " 8144 + "and would collide", null)); 8145 } 8146 } 8147 } 8148 8149 /** 8150 * Map ({@link ESetOperatorType}, {@code isAll()}) to the IR 8151 * {@link SetOperator} enum. The exhaustive switch makes a future 8152 * {@code ESetOperatorType} value fail loudly at build time 8153 * (mirrors slice-8 {@code resolveDistinctFlag} pattern). 8154 */ 8155 private static SetOperator resolveSetOperator(TSelectSqlStatement setOp) { 8156 ESetOperatorType type = setOp.getSetOperatorType(); 8157 if (type == null) { 8158 throw new SemanticIRBuildException( 8159 Diagnostic.error(DiagnosticCode.SET_OP_ROOT_TYPE_NULL, 8160 "expected non-null set-op type on the set-op root", (TParseTreeNode) null)); 8161 } 8162 boolean all = setOp.isAll(); 8163 switch (type) { 8164 case union: return all ? SetOperator.UNION_ALL : SetOperator.UNION; 8165 case intersect: return all ? SetOperator.INTERSECT_ALL : SetOperator.INTERSECT; 8166 case minus: return all ? SetOperator.MINUS_ALL : SetOperator.MINUS; 8167 case except: return all ? SetOperator.EXCEPT_ALL : SetOperator.EXCEPT; 8168 case none: 8169 throw new SemanticIRBuildException( 8170 Diagnostic.error(DiagnosticCode.SET_OP_ROOT_TYPE_NONE, 8171 "expected non-none set operator type on the set-op root", (TParseTreeNode) null)); 8172 default: 8173 throw new SemanticIRBuildException( 8174 Diagnostic.error(DiagnosticCode.SET_OP_UNKNOWN_OPERATOR_TYPE, 8175 "unknown set operator type: " + type, (TParseTreeNode) null)); 8176 } 8177 } 8178 8179 /** 8180 * Iteratively flatten the left-leaning set-op tree into a list of 8181 * leaf SELECT statements (CLAUDE.md mandates no recursion on 8182 * {@code leftStmt}/{@code rightStmt}; would StackOverflow on 2000+ 8183 * UNIONs). 8184 * 8185 * <p>On every internal set-op node visited: 8186 * <ol> 8187 * <li>Reject row-limit modifiers ({@link #rejectSetOpRowLimit}) 8188 * on every node (root + internal). Slice 12 + 8189 * {@code /tmp/SetOpInnerModifierProbe}: parenthesized inner 8190 * combined nodes can carry row-limits in Oracle / PostgreSQL / 8191 * MSSQL.</li> 8192 * <li>Reject ORDER BY ({@link #rejectSetOpInternalOrderBy}) only 8193 * on INTERNAL (non-root) nodes. Slice 21 lifted ORDER BY on 8194 * the root via {@link #buildSetOpOuterOrderByColumnRefs}; an 8195 * internal {@code (A UNION B ORDER BY id) UNION C} sort is 8196 * still discarded by the outer set operation, so it has no 8197 * observable effect and remains rejected.</li> 8198 * <li>Reject mixed-operator and mixed-{@code ALL} chains by checking 8199 * the resolved kind matches the root's kind.</li> 8200 * <li>Hard-reject malformed AST (null left/right child).</li> 8201 * </ol> 8202 * 8203 * <p>Push order is right-then-left so leaves emerge in left-to-right 8204 * declaration order. 8205 */ 8206 private static List<TSelectSqlStatement> flattenSetOpTreeIteratively( 8207 TSelectSqlStatement root, SetOperator expected) { 8208 List<TSelectSqlStatement> leaves = new ArrayList<>(); 8209 Deque<TSelectSqlStatement> stack = new ArrayDeque<>(); 8210 stack.push(root); 8211 while (!stack.isEmpty()) { 8212 TSelectSqlStatement cur = stack.pop(); 8213 ESetOperatorType t = cur.getSetOperatorType(); 8214 if (t != null && t != ESetOperatorType.none) { 8215 // Slice 21: ORDER BY guard fires only on INTERNAL nodes. 8216 // The root (`cur == root`) lifts ORDER BY; the collection 8217 // happens in buildSetOpOuterOrderByColumnRefs. 8218 // Slice 72: row-limit guard ALSO fires only on INTERNAL 8219 // nodes. The root lifts via buildSetOpRowLimit (called 8220 // by buildSetOpProgram before this method). 8221 if (cur != root) { 8222 rejectSetOpRowLimit(cur); 8223 rejectSetOpInternalOrderBy(cur); 8224 } 8225 SetOperator curKind = resolveSetOperator(cur); 8226 if (curKind != expected) { 8227 throw new SemanticIRBuildException( 8228 Diagnostic.error(DiagnosticCode.MIXED_SET_OPERATORS_NOT_SUPPORTED, 8229 "mixed set operators in a single chain are not supported yet " 8230 + "(root=" + expected + ", inner=" + curKind + ")", (TParseTreeNode) null)); 8231 } 8232 if (cur.getLeftStmt() == null || cur.getRightStmt() == null) { 8233 throw new SemanticIRBuildException( 8234 Diagnostic.error(DiagnosticCode.MALFORMED_SET_OP_AST, 8235 "malformed set-op AST: null left/right child", (TParseTreeNode) null)); 8236 } 8237 stack.push(cur.getRightStmt()); 8238 stack.push(cur.getLeftStmt()); 8239 } else { 8240 leaves.add(cur); 8241 } 8242 } 8243 return leaves; 8244 } 8245 8246 private static Set<String> collectCteNames(TCTEList cteList) { 8247 if (cteList == null || cteList.size() == 0) return Collections.emptySet(); 8248 Set<String> names = new HashSet<>(); 8249 for (int i = 0; i < cteList.size(); i++) { 8250 String name = cteList.getCTE(i).getTableName().toString(); 8251 if (name != null && !name.isEmpty()) { 8252 names.add(name.toLowerCase(Locale.ROOT)); 8253 } 8254 } 8255 return names; 8256 } 8257 8258 /** 8259 * Slice 107 — return the first CTE name shared between the outer-WITH 8260 * and inner-WITH CTE lists on an INSERT (case-insensitive, lowercase 8261 * via {@code toLowerCase(Locale.ROOT)} matching the slice-15/103 8262 * duplicate-name walker convention), or {@code null} if the name sets 8263 * are disjoint. Used by {@code buildInsert} to keep the 8264 * shared-name case rejecting (PG/Oracle/Snowflake nested-WITH 8265 * inner-shadows-outer semantics not yet supported) while admitting 8266 * the disjoint case via flat-merge. 8267 * 8268 * <p>Pathological edge case (codex round-2 diff-review Q3): if one of 8269 * the two lists also contains an INTRA-list duplicate AND that 8270 * duplicated name happens to also appear in the other list, this 8271 * helper short-circuits with 8272 * INSERT_MIXED_OUTER_AND_INNER_WITH_NOT_SUPPORTED and masks the 8273 * more precise same-scope DUPLICATE_CTE_NAME the slice-103 walker 8274 * would have emitted. Accepted limitation — the diagnostic still 8275 * tells the user the shape is unsupported, and both codes point at 8276 * the same offending name. A future slice can pre-walk each list 8277 * for intra-list duplicates before the boundary check if a 8278 * customer reports confusion. 8279 */ 8280 private static String findFirstSharedCteName(TCTEList outer, TCTEList inner) { 8281 Set<String> outerNames = new HashSet<>(); 8282 for (int i = 0; i < outer.size(); i++) { 8283 outerNames.add(outer.getCTE(i).getTableName().toString().toLowerCase(Locale.ROOT)); 8284 } 8285 for (int i = 0; i < inner.size(); i++) { 8286 String name = inner.getCTE(i).getTableName().toString(); 8287 if (outerNames.contains(name.toLowerCase(Locale.ROOT))) { 8288 return name; 8289 } 8290 } 8291 return null; 8292 } 8293 8294 /** 8295 * Reject the case where a CTE body references a sibling CTE declared 8296 * <i>after</i> it. SQL chain semantics only allow left-to-right 8297 * references, but the bind-by-name provider would happily classify a 8298 * forward-declared CTE name as a base {@code TABLE} (because it's not 8299 * yet in {@code visibleSoFar}). Catching it here turns the silent 8300 * mislabeling into a clear error. 8301 */ 8302 private static void rejectForwardCteReferences(final TCTE cte, 8303 final Set<String> allCteNames, 8304 final Set<String> visibleSoFar) { 8305 TSelectSqlStatement body = cte.getSubquery(); 8306 if (body == null) return; 8307 final String selfName = cte.getTableName().toString().toLowerCase(Locale.ROOT); 8308 final List<String> forwards = new ArrayList<>(); 8309 body.acceptChildren(new TParseTreeVisitor() { 8310 @Override 8311 public void preVisit(TTable t) { 8312 String tname = bareName(t); 8313 if (tname == null) return; 8314 String lower = tname.toLowerCase(Locale.ROOT); 8315 if (allCteNames.contains(lower) 8316 && !visibleSoFar.contains(lower) 8317 && !lower.equals(selfName)) { 8318 forwards.add(tname); 8319 } 8320 } 8321 }); 8322 if (!forwards.isEmpty()) { 8323 throw new SemanticIRBuildException( 8324 Diagnostic.error(DiagnosticCode.CTE_FORWARD_REFERENCE, 8325 "CTE '" + cte.getTableName() + "' forward-references later CTE(s) " 8326 + forwards + "; only left-to-right CTE chains are supported", cte)); 8327 } 8328 } 8329 8330 private static String bareName(TTable t) { 8331 if (t == null) return null; 8332 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) return null; 8333 return t.getName(); 8334 } 8335 8336 /** 8337 * Reject {@code WITH RECURSIVE}. Slice 4 supports chained 8338 * (forward-referencing) CTEs; recursion is left for a later slice that 8339 * can model the fixpoint semantics. 8340 */ 8341 private static void rejectRecursiveCtes(TCTEList cteList) { 8342 if (cteList == null) return; 8343 for (int i = 0; i < cteList.size(); i++) { 8344 TCTE cte = cteList.getCTE(i); 8345 if (cte.isRecursive()) { 8346 throw new SemanticIRBuildException( 8347 Diagnostic.error(DiagnosticCode.CTE_WITH_RECURSIVE_NOT_SUPPORTED, 8348 "WITH RECURSIVE is not supported yet (CTE: " + cte.getTableName() + ")", cte)); 8349 } 8350 } 8351 } 8352 8353 /** 8354 * Slice 101 — walk the WITH clause on a MERGE statement and append 8355 * each CTE body to {@code stmts} as a preceding statement. Mirrors 8356 * the SELECT-side build() at lines ~516-653. 8357 * 8358 * <p>Returns a {@code cteNameToStatementIndex} map keyed by 8359 * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated 8360 * with each CTE's output column names so the {@code buildMerge} 8361 * USING-as-CTE branch can install them via 8362 * {@link NameBindingProvider#withInScopeRelationColumns}. 8363 * 8364 * <p>Rejects (chronological): 8365 * <ol> 8366 * <li>WITH RECURSIVE — reuses {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}. 8367 * Currently no admitting vendor (PG parser PARSE_FAILED, probe 8368 * 2026-05-17); defensive reject for forward compatibility.</li> 8369 * <li>CTE with explicit column list — rejects with new 8370 * {@link DiagnosticCode#MERGE_CTE_EXPLICIT_COLUMN_LIST_NOT_SUPPORTED}. 8371 * PG and MSSQL parsers admit this shape; slice 101 defers 8372 * because the inner CTE body output names ≠ user-visible CTE 8373 * column names.</li> 8374 * <li>Duplicate CTE name — reuses {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li> 8375 * <li>Forward CTE reference — reuses {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li> 8376 * </ol> 8377 * 8378 * <p>Set-op CTE bodies route through {@link #buildSetOpProgram}; 8379 * non-set-op CTE bodies route through {@link #buildSelectStatement}. 8380 * Each CTE's published columns are added to {@code ctePublishedColumnsOut} 8381 * after its body is built so a CTE cannot self-reference (mirrors 8382 * SELECT-side slice 60). 8383 */ 8384 private static Map<String, Integer> buildMergeCteList( 8385 TMergeSqlStatement merge, 8386 NameBindingProvider provider, 8387 List<StatementGraph> stmts, 8388 List<LineageEdge> lineage, 8389 Map<String, List<String>> ctePublishedColumnsOut) { 8390 TCTEList cteList = merge.getCteList(); 8391 Map<String, Integer> cteNameToStatementIndex = new HashMap<>(); 8392 if (cteList == null || cteList.size() == 0) { 8393 return cteNameToStatementIndex; 8394 } 8395 rejectRecursiveCtes(cteList); 8396 // Slice 102 — explicit-column-list shapes (PG/MSSQL `WITH cte(a, b) AS 8397 // (...) MERGE ...`) are admitted by rebuilding the body's 8398 // StatementGraph with the explicit-list names and rewriting outgoing 8399 // STATEMENT_OUTPUT lineage refs. The slice-101 upfront reject is 8400 // replaced by per-CTE rename application below. The slice-101 code 8401 // (MERGE_CTE_EXPLICIT_COLUMN_LIST_NOT_SUPPORTED) stays declared for 8402 // API stability. 8403 Set<String> allCteNames = collectCteNames(cteList); 8404 Set<String> visibleSoFar = new HashSet<>(); 8405 for (int i = 0; i < cteList.size(); i++) { 8406 TCTE cte = cteList.getCTE(i); 8407 String cteName = cte.getTableName().toString(); 8408 String cteNameLower = cteName.toLowerCase(Locale.ROOT); 8409 if (visibleSoFar.contains(cteNameLower)) { 8410 throw new SemanticIRBuildException( 8411 Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME, 8412 "duplicate CTE name '" + cteName 8413 + "' in WITH clause; CTE names must be unique", 8414 cte)); 8415 } 8416 rejectForwardCteReferences(cte, allCteNames, visibleSoFar); 8417 NameBindingProvider bodyProvider = 8418 provider.withCteContext(visibleSoFar); 8419 // Slice 102 — snapshot the lineage size BEFORE either branch so 8420 // the rename helper can rewrite outgoing STATEMENT_OUTPUT refs 8421 // in [lineageSize0, lineage.size()) without touching prior CTE 8422 // bodies' edges. Covers BOTH set-op and non-set-op branches 8423 // (codex round-1 plan-review BLOCKING). 8424 int lineageSize0 = lineage.size(); 8425 int bodyIdx; 8426 TSelectSqlStatement cteBody = cte.getSubquery(); 8427 if (cteBody != null 8428 && cteBody.getSetOperatorType() != null 8429 && cteBody.getSetOperatorType() != ESetOperatorType.none) { 8430 bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, 8431 lineage, cteNameToStatementIndex, cteName, 8432 /*hasOuterCteListAlreadyProcessed=*/ false); 8433 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8434 } else { 8435 int cteStmtsSize0 = stmts.size(); 8436 int cteLineageSize0 = lineage.size(); 8437 Map<String, Integer> cteSubqueryAliasToIndex; 8438 try { 8439 cteSubqueryAliasToIndex = 8440 extractFromSubqueriesAsStatements(cteBody, 8441 bodyProvider, stmts, lineage, 8442 cteNameToStatementIndex, 8443 ctePublishedColumnsOut); 8444 } catch (RuntimeException ex) { 8445 while (stmts.size() > cteStmtsSize0) { 8446 stmts.remove(stmts.size() - 1); 8447 } 8448 while (lineage.size() > cteLineageSize0) { 8449 lineage.remove(lineage.size() - 1); 8450 } 8451 throw ex; 8452 } 8453 EnclosingScope cteEnclosing = buildEnclosingScope(cteBody, 8454 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8455 /*parent=*/ null); 8456 Map<Integer, ScalarInfo> cteScalarMap = 8457 extractScalarSubqueriesAsStatements(cteBody, 8458 bodyProvider, stmts, lineage, 8459 cteNameToStatementIndex, cteEnclosing, 8460 /*allowRecursiveScalarSubqueryExtraction=*/ true); 8461 Map<String, List<String>> cteBodyInScope = 8462 buildEffectiveAliasInScopeMap(cteBody, bodyProvider, 8463 ctePublishedColumnsOut, 8464 cteSubqueryAliasToIndex, stmts); 8465 NameBindingProvider cteBodyProviderWithStar = bodyProvider 8466 .withInScopeRelationColumns(cteBodyInScope); 8467 // Slice 114 — switch to buildSelectStatementImpl with 8468 // snapshot/rollback (see the matching SELECT-side 8469 // CTE site for full rationale). 8470 int cteBodyStmtsSnapshot = stmts.size(); 8471 int cteBodyLineageSnapshot = lineage.size(); 8472 StatementGraph body; 8473 try { 8474 if (isPivotSelect(cteBody)) { 8475 // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body 8476 // (mirrors the SELECT-side buildSelectCteList branch). 8477 // The slice-129 pivot router in buildSelectStatementImpl 8478 // is gated to the OUTER SELECT (name == null), so a pivot 8479 // built here (name == cteName) would otherwise fall to the 8480 // normal path and reject with TABLE_BINDING_UNRESOLVED 8481 // ("null(piviot_table)"). The pivot CTE becomes its own 8482 // StatementGraph; the DML's CTE-as-relation path (fed from 8483 // ctePublishedColumns ← body.getOutputColumns()) carries 8484 // the cross-stmt lineage. All pivot deferrals preserved by 8485 // buildPivotSelect's own guards. 8486 body = buildPivotSelect(cteBody, 8487 cteBodyProviderWithStar, cteName); 8488 } else { 8489 body = buildSelectStatementImpl(cteBody, 8490 cteBodyProviderWithStar, cteName, 8491 /*hasOuterCteListAlreadyProcessed=*/ false, 8492 /*allowFromSubqueries=*/ true, 8493 /*allowScalarProjectionSubqueries=*/ true, 8494 /*allowWindowProjection=*/ true, 8495 /*allowJoinOnPredicateSubqueries=*/ false, 8496 /*stmtsForExtraction=*/ stmts, 8497 /*lineageForExtraction=*/ lineage, 8498 /*cteMapForExtraction=*/ cteNameToStatementIndex, 8499 /*isPredicateBody=*/ false, 8500 /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE, 8501 /*allowWherePredicateSubqueries=*/ true); 8502 } 8503 } catch (RuntimeException ex) { 8504 while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 8505 while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 8506 throw ex; 8507 } 8508 bodyIdx = stmts.size(); 8509 stmts.add(body); 8510 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8511 emitLineageForStatement(body, bodyIdx, lineage, 8512 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8513 cteScalarMap); 8514 } 8515 // Slice 102 — apply explicit-column-list rename if present. 8516 // Rebuilds stmts[bodyIdx] with renamed OutputColumns and 8517 // rewrites STATEMENT_OUTPUT(bodyIdx, oldName) refs in 8518 // lineage[lineageSize0..) to use the renamed name. Returns the 8519 // published column list (renamed if explicit list applied, 8520 // else inner names from the body). 8521 List<String> publishedCols = applyExplicitCteColumnListRename( 8522 cte, stmts, lineage, bodyIdx, lineageSize0, "MERGE"); 8523 ctePublishedColumnsOut.put(cteNameLower, publishedCols); 8524 visibleSoFar.add(cteNameLower); 8525 } 8526 return cteNameToStatementIndex; 8527 } 8528 8529 /** 8530 * Slice 105 — walk the WITH clause on an UPDATE statement and append 8531 * each CTE body to {@code stmts} as a preceding statement. Mirrors 8532 * the slice-101 MERGE walker {@link #buildMergeCteList} verbatim 8533 * except for the source of the CTE list and the 8534 * {@link #applyExplicitCteColumnListRename} {@code dmlKind} argument. 8535 * 8536 * <p>Returns a {@code cteNameToStatementIndex} map keyed by 8537 * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated 8538 * with each CTE's output column names so {@link #buildUpdateRelation} 8539 * + {@link #buildUpdateInScopeMap} can route FROM-side references to 8540 * the matching CTE as SUBQUERY-kind relations with the CTE's columns 8541 * published into the in-scope map. 8542 * 8543 * <p>The slice-103 SELECT-side CTE walker contract is reused via the 8544 * {@link #applyExplicitCteColumnListRename} helper with 8545 * {@code dmlKind="SELECT"} so the SELECT-side 8546 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} code 8547 * fires on arity mismatch (codex round-1 Q2 confirmed YES — UPDATE is 8548 * closer to ordinary SELECT than to MERGE for CTE rename semantics). 8549 * 8550 * <p>Rejects (chronological): 8551 * <ol> 8552 * <li>{@code WITH RECURSIVE} — {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}. 8553 * Currently no admitting vendor (Oracle PARSE_FAILED on outer-WITH-UPDATE).</li> 8554 * <li>Duplicate CTE name — {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li> 8555 * <li>Forward CTE reference — {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li> 8556 * <li>Explicit-column-list arity mismatch — handled by 8557 * {@link #applyExplicitCteColumnListRename} via 8558 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.</li> 8559 * </ol> 8560 */ 8561 private static Map<String, Integer> buildUpdateCteList( 8562 TUpdateSqlStatement update, 8563 NameBindingProvider provider, 8564 List<StatementGraph> stmts, 8565 List<LineageEdge> lineage, 8566 Map<String, List<String>> ctePublishedColumnsOut) { 8567 TCTEList cteList = update.getCteList(); 8568 Map<String, Integer> cteNameToStatementIndex = new HashMap<>(); 8569 if (cteList == null || cteList.size() == 0) { 8570 return cteNameToStatementIndex; 8571 } 8572 rejectRecursiveCtes(cteList); 8573 Set<String> allCteNames = collectCteNames(cteList); 8574 Set<String> visibleSoFar = new HashSet<>(); 8575 for (int i = 0; i < cteList.size(); i++) { 8576 TCTE cte = cteList.getCTE(i); 8577 String cteName = cte.getTableName().toString(); 8578 String cteNameLower = cteName.toLowerCase(Locale.ROOT); 8579 if (visibleSoFar.contains(cteNameLower)) { 8580 throw new SemanticIRBuildException( 8581 Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME, 8582 "duplicate CTE name '" + cteName 8583 + "' in WITH clause; CTE names must be unique", 8584 cte)); 8585 } 8586 rejectForwardCteReferences(cte, allCteNames, visibleSoFar); 8587 NameBindingProvider bodyProvider = 8588 provider.withCteContext(visibleSoFar); 8589 int lineageSize0 = lineage.size(); 8590 int bodyIdx; 8591 TSelectSqlStatement cteBody = cte.getSubquery(); 8592 if (cteBody != null 8593 && cteBody.getSetOperatorType() != null 8594 && cteBody.getSetOperatorType() != ESetOperatorType.none) { 8595 bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, 8596 lineage, cteNameToStatementIndex, cteName, 8597 /*hasOuterCteListAlreadyProcessed=*/ false); 8598 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8599 } else { 8600 int cteStmtsSize0 = stmts.size(); 8601 int cteLineageSize0 = lineage.size(); 8602 Map<String, Integer> cteSubqueryAliasToIndex; 8603 try { 8604 cteSubqueryAliasToIndex = 8605 extractFromSubqueriesAsStatements(cteBody, 8606 bodyProvider, stmts, lineage, 8607 cteNameToStatementIndex, 8608 ctePublishedColumnsOut); 8609 } catch (RuntimeException ex) { 8610 while (stmts.size() > cteStmtsSize0) { 8611 stmts.remove(stmts.size() - 1); 8612 } 8613 while (lineage.size() > cteLineageSize0) { 8614 lineage.remove(lineage.size() - 1); 8615 } 8616 throw ex; 8617 } 8618 EnclosingScope cteEnclosing = buildEnclosingScope(cteBody, 8619 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8620 /*parent=*/ null); 8621 Map<Integer, ScalarInfo> cteScalarMap = 8622 extractScalarSubqueriesAsStatements(cteBody, 8623 bodyProvider, stmts, lineage, 8624 cteNameToStatementIndex, cteEnclosing, 8625 /*allowRecursiveScalarSubqueryExtraction=*/ true); 8626 Map<String, List<String>> cteBodyInScope = 8627 buildEffectiveAliasInScopeMap(cteBody, bodyProvider, 8628 ctePublishedColumnsOut, 8629 cteSubqueryAliasToIndex, stmts); 8630 NameBindingProvider cteBodyProviderWithStar = bodyProvider 8631 .withInScopeRelationColumns(cteBodyInScope); 8632 // Slice 114 — switch to buildSelectStatementImpl with 8633 // snapshot/rollback (see the matching SELECT-side 8634 // CTE site for full rationale). 8635 int cteBodyStmtsSnapshot = stmts.size(); 8636 int cteBodyLineageSnapshot = lineage.size(); 8637 StatementGraph body; 8638 try { 8639 if (isPivotSelect(cteBody)) { 8640 // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body 8641 // (mirrors the SELECT-side buildSelectCteList branch). 8642 // The slice-129 pivot router in buildSelectStatementImpl 8643 // is gated to the OUTER SELECT (name == null), so a pivot 8644 // built here (name == cteName) would otherwise fall to the 8645 // normal path and reject with TABLE_BINDING_UNRESOLVED 8646 // ("null(piviot_table)"). The pivot CTE becomes its own 8647 // StatementGraph; the DML's CTE-as-relation path (fed from 8648 // ctePublishedColumns ← body.getOutputColumns()) carries 8649 // the cross-stmt lineage. All pivot deferrals preserved by 8650 // buildPivotSelect's own guards. 8651 body = buildPivotSelect(cteBody, 8652 cteBodyProviderWithStar, cteName); 8653 } else { 8654 body = buildSelectStatementImpl(cteBody, 8655 cteBodyProviderWithStar, cteName, 8656 /*hasOuterCteListAlreadyProcessed=*/ false, 8657 /*allowFromSubqueries=*/ true, 8658 /*allowScalarProjectionSubqueries=*/ true, 8659 /*allowWindowProjection=*/ true, 8660 /*allowJoinOnPredicateSubqueries=*/ false, 8661 /*stmtsForExtraction=*/ stmts, 8662 /*lineageForExtraction=*/ lineage, 8663 /*cteMapForExtraction=*/ cteNameToStatementIndex, 8664 /*isPredicateBody=*/ false, 8665 /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE, 8666 /*allowWherePredicateSubqueries=*/ true); 8667 } 8668 } catch (RuntimeException ex) { 8669 while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 8670 while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 8671 throw ex; 8672 } 8673 bodyIdx = stmts.size(); 8674 stmts.add(body); 8675 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8676 emitLineageForStatement(body, bodyIdx, lineage, 8677 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8678 cteScalarMap); 8679 } 8680 // Slice 105 — explicit column-list rename uses dmlKind="SELECT" 8681 // so the SELECT-side CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH 8682 // code fires (codex Q2 confirmed YES — UPDATE is closer to 8683 // ordinary SELECT than MERGE for CTE rename semantics). 8684 List<String> publishedCols = applyExplicitCteColumnListRename( 8685 cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT"); 8686 ctePublishedColumnsOut.put(cteNameLower, publishedCols); 8687 visibleSoFar.add(cteNameLower); 8688 } 8689 return cteNameToStatementIndex; 8690 } 8691 8692 /** 8693 * Slice 106 — walk the WITH clause on a DELETE statement and append 8694 * each CTE body to {@code stmts} as a preceding statement. Mirrors 8695 * the slice-105 UPDATE walker {@link #buildUpdateCteList} verbatim 8696 * except for the source of the CTE list ({@code delete.getCteList()}). 8697 * 8698 * <p>Returns a {@code cteNameToStatementIndex} map keyed by 8699 * lower-cased CTE name. {@code ctePublishedColumnsOut} is populated 8700 * with each CTE's output column names so {@link #buildDeleteRelation} 8701 * + {@link #buildDeleteInScopeMap} can route FROM-side references to 8702 * the matching CTE as SUBQUERY-kind relations with the CTE's columns 8703 * published into the in-scope map. 8704 * 8705 * <p>The slice-103 SELECT-side CTE walker contract is reused via the 8706 * {@link #applyExplicitCteColumnListRename} helper with 8707 * {@code dmlKind="SELECT"} so the SELECT-side 8708 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} code 8709 * fires on arity mismatch (slice-105 precedent: UPDATE/DELETE are 8710 * closer to ordinary SELECT than to MERGE for CTE rename semantics). 8711 * 8712 * <p>Rejects (chronological): 8713 * <ol> 8714 * <li>{@code WITH RECURSIVE} — 8715 * {@link DiagnosticCode#CTE_WITH_RECURSIVE_NOT_SUPPORTED}. 8716 * PG / MySQL admit the parse shape but slice 106 rejects at the 8717 * semantic layer (mirrors slice-105 boundary).</li> 8718 * <li>Duplicate CTE name — {@link DiagnosticCode#DUPLICATE_CTE_NAME}.</li> 8719 * <li>Forward CTE reference — {@link DiagnosticCode#CTE_FORWARD_REFERENCE}.</li> 8720 * <li>Explicit-column-list arity mismatch — handled by 8721 * {@link #applyExplicitCteColumnListRename} via 8722 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}.</li> 8723 * </ol> 8724 */ 8725 private static Map<String, Integer> buildDeleteCteList( 8726 TDeleteSqlStatement delete, 8727 NameBindingProvider provider, 8728 List<StatementGraph> stmts, 8729 List<LineageEdge> lineage, 8730 Map<String, List<String>> ctePublishedColumnsOut) { 8731 TCTEList cteList = delete.getCteList(); 8732 Map<String, Integer> cteNameToStatementIndex = new HashMap<>(); 8733 if (cteList == null || cteList.size() == 0) { 8734 return cteNameToStatementIndex; 8735 } 8736 rejectRecursiveCtes(cteList); 8737 Set<String> allCteNames = collectCteNames(cteList); 8738 Set<String> visibleSoFar = new HashSet<>(); 8739 for (int i = 0; i < cteList.size(); i++) { 8740 TCTE cte = cteList.getCTE(i); 8741 String cteName = cte.getTableName().toString(); 8742 String cteNameLower = cteName.toLowerCase(Locale.ROOT); 8743 if (visibleSoFar.contains(cteNameLower)) { 8744 throw new SemanticIRBuildException( 8745 Diagnostic.error(DiagnosticCode.DUPLICATE_CTE_NAME, 8746 "duplicate CTE name '" + cteName 8747 + "' in WITH clause; CTE names must be unique", 8748 cte)); 8749 } 8750 rejectForwardCteReferences(cte, allCteNames, visibleSoFar); 8751 NameBindingProvider bodyProvider = 8752 provider.withCteContext(visibleSoFar); 8753 int lineageSize0 = lineage.size(); 8754 int bodyIdx; 8755 TSelectSqlStatement cteBody = cte.getSubquery(); 8756 if (cteBody != null 8757 && cteBody.getSetOperatorType() != null 8758 && cteBody.getSetOperatorType() != ESetOperatorType.none) { 8759 bodyIdx = buildSetOpProgram(cteBody, bodyProvider, stmts, 8760 lineage, cteNameToStatementIndex, cteName, 8761 /*hasOuterCteListAlreadyProcessed=*/ false); 8762 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8763 } else { 8764 int cteStmtsSize0 = stmts.size(); 8765 int cteLineageSize0 = lineage.size(); 8766 Map<String, Integer> cteSubqueryAliasToIndex; 8767 try { 8768 cteSubqueryAliasToIndex = 8769 extractFromSubqueriesAsStatements(cteBody, 8770 bodyProvider, stmts, lineage, 8771 cteNameToStatementIndex, 8772 ctePublishedColumnsOut); 8773 } catch (RuntimeException ex) { 8774 while (stmts.size() > cteStmtsSize0) { 8775 stmts.remove(stmts.size() - 1); 8776 } 8777 while (lineage.size() > cteLineageSize0) { 8778 lineage.remove(lineage.size() - 1); 8779 } 8780 throw ex; 8781 } 8782 EnclosingScope cteEnclosing = buildEnclosingScope(cteBody, 8783 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8784 /*parent=*/ null); 8785 Map<Integer, ScalarInfo> cteScalarMap = 8786 extractScalarSubqueriesAsStatements(cteBody, 8787 bodyProvider, stmts, lineage, 8788 cteNameToStatementIndex, cteEnclosing, 8789 /*allowRecursiveScalarSubqueryExtraction=*/ true); 8790 Map<String, List<String>> cteBodyInScope = 8791 buildEffectiveAliasInScopeMap(cteBody, bodyProvider, 8792 ctePublishedColumnsOut, 8793 cteSubqueryAliasToIndex, stmts); 8794 NameBindingProvider cteBodyProviderWithStar = bodyProvider 8795 .withInScopeRelationColumns(cteBodyInScope); 8796 // Slice 114 — switch to buildSelectStatementImpl with 8797 // snapshot/rollback (see the matching SELECT-side 8798 // CTE site for full rationale). 8799 int cteBodyStmtsSnapshot = stmts.size(); 8800 int cteBodyLineageSnapshot = lineage.size(); 8801 StatementGraph body; 8802 try { 8803 if (isPivotSelect(cteBody)) { 8804 // Slice 139 — a nested PIVOT/UNPIVOT as a DML CTE body 8805 // (mirrors the SELECT-side buildSelectCteList branch). 8806 // The slice-129 pivot router in buildSelectStatementImpl 8807 // is gated to the OUTER SELECT (name == null), so a pivot 8808 // built here (name == cteName) would otherwise fall to the 8809 // normal path and reject with TABLE_BINDING_UNRESOLVED 8810 // ("null(piviot_table)"). The pivot CTE becomes its own 8811 // StatementGraph; the DML's CTE-as-relation path (fed from 8812 // ctePublishedColumns ← body.getOutputColumns()) carries 8813 // the cross-stmt lineage. All pivot deferrals preserved by 8814 // buildPivotSelect's own guards. 8815 body = buildPivotSelect(cteBody, 8816 cteBodyProviderWithStar, cteName); 8817 } else { 8818 body = buildSelectStatementImpl(cteBody, 8819 cteBodyProviderWithStar, cteName, 8820 /*hasOuterCteListAlreadyProcessed=*/ false, 8821 /*allowFromSubqueries=*/ true, 8822 /*allowScalarProjectionSubqueries=*/ true, 8823 /*allowWindowProjection=*/ true, 8824 /*allowJoinOnPredicateSubqueries=*/ false, 8825 /*stmtsForExtraction=*/ stmts, 8826 /*lineageForExtraction=*/ lineage, 8827 /*cteMapForExtraction=*/ cteNameToStatementIndex, 8828 /*isPredicateBody=*/ false, 8829 /*whereClauseContext=*/ PredicateClauseContext.CTE_BODY_WHERE, 8830 /*allowWherePredicateSubqueries=*/ true); 8831 } 8832 } catch (RuntimeException ex) { 8833 while (stmts.size() > cteBodyStmtsSnapshot) stmts.remove(stmts.size() - 1); 8834 while (lineage.size() > cteBodyLineageSnapshot) lineage.remove(lineage.size() - 1); 8835 throw ex; 8836 } 8837 bodyIdx = stmts.size(); 8838 stmts.add(body); 8839 cteNameToStatementIndex.put(cteNameLower, bodyIdx); 8840 emitLineageForStatement(body, bodyIdx, lineage, 8841 cteNameToStatementIndex, cteSubqueryAliasToIndex, 8842 cteScalarMap); 8843 } 8844 // Slice 106 — explicit column-list rename uses dmlKind="SELECT" 8845 // so the SELECT-side CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH 8846 // code fires (slice-105 precedent: UPDATE/DELETE are closer 8847 // to ordinary SELECT than MERGE for CTE rename semantics). 8848 List<String> publishedCols = applyExplicitCteColumnListRename( 8849 cte, stmts, lineage, bodyIdx, lineageSize0, "SELECT"); 8850 ctePublishedColumnsOut.put(cteNameLower, publishedCols); 8851 visibleSoFar.add(cteNameLower); 8852 } 8853 return cteNameToStatementIndex; 8854 } 8855 8856 /** 8857 * Slice 105 — combine the slice-83 subqueryAliasToIndex with the 8858 * slice-105 CTE-as-FROM-relation alias→cteIdx entries so 8859 * {@link #emitUpdateSubquerySourceEdges} produces cross-stmt 8860 * lineage edges for SET RHS references resolving to a CTE column. 8861 * 8862 * <p>Without this merge the visible {@link OutputColumn#getSources} 8863 * stays correct (CTE refs surface as {@link ColumnRef}s) but 8864 * {@code lineage[]} silently loses the canonical 8865 * {@code STATEMENT_OUTPUT(update,col) → STATEMENT_OUTPUT(cte,col)} 8866 * edge (codex round-2 Q5 silent-correctness bug). 8867 * 8868 * <p>Walks {@code update.getJoins()} the same way 8869 * {@link #buildUpdateRelation} does to keep the alias resolution 8870 * identical: CTE-bound FROM-side relations are detected by their 8871 * bare name (case-insensitive) and registered under their effective 8872 * alias. Subquery aliases stay keyed lowercase to match the 8873 * slice-83 contract. 8874 */ 8875 private static Map<String, Integer> buildUpdateCombinedAliasToSubIdx( 8876 TUpdateSqlStatement update, 8877 Map<String, Integer> subqueryAliasToIndex, 8878 Map<String, Integer> cteNameToStatementIndex) { 8879 Map<String, Integer> combined = new HashMap<>(); 8880 if (subqueryAliasToIndex != null) { 8881 combined.putAll(subqueryAliasToIndex); 8882 } 8883 if (cteNameToStatementIndex == null 8884 || cteNameToStatementIndex.isEmpty()) { 8885 return combined; 8886 } 8887 TJoinList joins = update.getJoins(); 8888 if (joins == null) return combined; 8889 for (TJoin join : joins) { 8890 addCteAliasToCombinedMap(join.getTable(), 8891 cteNameToStatementIndex, combined); 8892 TJoinItemList items = join.getJoinItems(); 8893 if (items == null) continue; 8894 for (int i = 0; i < items.size(); i++) { 8895 TJoinItem item = items.getJoinItem(i); 8896 if (item == null) continue; 8897 addCteAliasToCombinedMap(item.getTable(), 8898 cteNameToStatementIndex, combined); 8899 } 8900 } 8901 return combined; 8902 } 8903 8904 private static void addCteAliasToCombinedMap(TTable t, 8905 Map<String, Integer> cteNameToStatementIndex, 8906 Map<String, Integer> combined) { 8907 if (t == null) return; 8908 if (t.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) { 8909 return; 8910 } 8911 TObjectName tName = t.getTableName(); 8912 if (tName == null) return; 8913 String bare = tName.toString(); 8914 if (bare == null || bare.isEmpty()) return; 8915 String bareLower = bare.toLowerCase(Locale.ROOT); 8916 Integer cteIdx = cteNameToStatementIndex.get(bareLower); 8917 if (cteIdx == null) return; 8918 String aliasKey = effectiveAliasLowerCaseOrNull(t); 8919 if (aliasKey == null) aliasKey = bareLower; 8920 combined.put(aliasKey, cteIdx); 8921 } 8922 8923 /** 8924 * Slice 123 — DELETE analogue of 8925 * {@link #buildUpdateCombinedAliasToSubIdx}: combine the slice-84 8926 * {@code subqueryAliasToIndex} (USING-(SELECT) FROM-subqueries) with the 8927 * slice-106 CTE-as-FROM-relation alias→cteIdx entries so the 8928 * slice-85 {@link #buildReturningColumns} walker can promote a 8929 * RETURNING/OUTPUT source ref that resolves to a SUBQUERY-kind FROM-side 8930 * relation to a cross-stmt {@code STATEMENT_OUTPUT → STATEMENT_OUTPUT} 8931 * edge. 8932 * 8933 * <p>Walks {@code delete.getReferenceJoins()} (NOT {@code getJoins()}, 8934 * which catches MySQL self-ref / multi-target shapes that still reject) 8935 * the same way {@link #buildDeleteRelation} does so alias resolution is 8936 * identical. Subquery aliases stay keyed lowercase (slice-84 contract); 8937 * CTE-as-relation entries are keyed by their effective alias via the 8938 * shared {@link #addCteAliasToCombinedMap} helper. 8939 * 8940 * <p>The {@code putAll} then {@code put} order is collision-free: the two 8941 * entry sources are disjoint in valid SQL. {@code subqueryAliasToIndex} 8942 * keys are aliases of {@code (SELECT …) sub} FROM-subqueries (non-{@code 8943 * objectname} table sources), while {@code addCteAliasToCombinedMap} only 8944 * adds {@code objectname}-typed tables whose bare name matches a declared 8945 * CTE — a single alias cannot be both, and two FROM relations sharing one 8946 * alias is a SQL error the parser/resolver rejects. Mirrors the slice-105 8947 * {@link #buildUpdateCombinedAliasToSubIdx} contract. 8948 */ 8949 private static Map<String, Integer> buildDeleteCombinedAliasToSubIdx( 8950 TDeleteSqlStatement delete, 8951 Map<String, Integer> subqueryAliasToIndex, 8952 Map<String, Integer> cteNameToStatementIndex) { 8953 Map<String, Integer> combined = new HashMap<>(); 8954 if (subqueryAliasToIndex != null) { 8955 combined.putAll(subqueryAliasToIndex); 8956 } 8957 if (cteNameToStatementIndex == null 8958 || cteNameToStatementIndex.isEmpty()) { 8959 return combined; 8960 } 8961 TJoinList refJoins = delete.getReferenceJoins(); 8962 if (refJoins == null) return combined; 8963 for (int ji = 0; ji < refJoins.size(); ji++) { 8964 TJoin join = refJoins.getJoin(ji); 8965 addCteAliasToCombinedMap(join.getTable(), 8966 cteNameToStatementIndex, combined); 8967 TJoinItemList items = join.getJoinItems(); 8968 if (items == null) continue; 8969 for (int i = 0; i < items.size(); i++) { 8970 TJoinItem item = items.getJoinItem(i); 8971 if (item == null) continue; 8972 addCteAliasToCombinedMap(item.getTable(), 8973 cteNameToStatementIndex, combined); 8974 } 8975 } 8976 return combined; 8977 } 8978 8979 /** 8980 * Slice 102 / Slice 103 — when a WITH-clause CTE declares an explicit 8981 * column list ({@code WITH cte(a, b) AS (SELECT x, y FROM t)}), rebuild 8982 * {@code stmts[bodyIdx]} so its {@link OutputColumn} names match the 8983 * explicit list at each ordinal and rewrite outgoing 8984 * {@link LineageRef.Kind#STATEMENT_OUTPUT} refs in 8985 * {@code lineage[lineageSize0..lineage.size())} so the inner-projection 8986 * names are replaced by the explicit-list names. 8987 * 8988 * <p>Returns the published column list for the caller's 8989 * {@code ctePublishedColumns} map: the renamed list when an explicit list 8990 * is present; otherwise the body's inner names (matching pre-slice-102 8991 * behavior). Slice 103 reuses this helper from the outer SELECT CTE 8992 * walker via {@code dmlKind="SELECT"} (slice-100 cross-DML reuse 8993 * precedent). 8994 * 8995 * <p>Rejects: 8996 * <ul> 8997 * <li>Arity mismatch — explicit-list size != body output count → 8998 * {@link DiagnosticCode#MERGE_CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH} 8999 * when {@code dmlKind="MERGE"}, otherwise 9000 * {@link DiagnosticCode#CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH}. 9001 * Slice 103 cannot rename the MERGE-side code (it is pinned by 9002 * {@code Slice102Test.valueOfPinsResolveBothCodes} and adopting 9003 * it on the SELECT side would also miswire the message text); 9004 * the SELECT-side gets its own parallel code (codex round-1 9005 * plan-review BLOCKING).</li> 9006 * <li>Duplicate explicit name ({@code WITH cte(a, a) AS ...}) → 9007 * {@link DiagnosticCode#DUPLICATE_OUTPUT_NAME}. STATEMENT_OUTPUT 9008 * refs are keyed by output name; duplicates would collide 9009 * (codex round-2 plan-review advisory).</li> 9010 * </ul> 9011 * 9012 * <p>{@link OutputColumn} and {@link StatementGraph} are immutable; the 9013 * rebuild uses the slice-85 15-arg primary constructor copying every 9014 * field unchanged except {@code outputColumns}. {@link LineageEdge} and 9015 * {@link LineageRef} are immutable; the rewrite walker constructs new 9016 * instances and replaces them in the mutable {@code lineage} list via 9017 * {@link List#set}. 9018 */ 9019 private static List<String> applyExplicitCteColumnListRename( 9020 TCTE cte, 9021 List<StatementGraph> stmts, 9022 List<LineageEdge> lineage, 9023 int bodyIdx, 9024 int lineageSize0, 9025 String dmlKind) { 9026 StatementGraph body = stmts.get(bodyIdx); 9027 if (cte.getColumnList() == null || cte.getColumnList().size() == 0) { 9028 return outputColumnNames(body); 9029 } 9030 // Materialize the explicit list of renamed names (in declaration order). 9031 boolean isMerge = "MERGE".equals(dmlKind); 9032 String dmlLabel = isMerge ? "MERGE CTE" : "CTE"; 9033 String withClauseLabel = isMerge ? "MERGE WITH clause CTE" : "WITH clause CTE"; 9034 List<String> renamed = new ArrayList<>(cte.getColumnList().size()); 9035 Set<String> seenLower = new HashSet<>(); 9036 for (int k = 0; k < cte.getColumnList().size(); k++) { 9037 TObjectName col = cte.getColumnList().getObjectName(k); 9038 String name = (col == null) ? null : col.getColumnNameOnly(); 9039 if (name == null || name.isEmpty()) { 9040 // Defensive — parser normally fills these; if not, fall 9041 // back to a synthetic name so the constructor invariant 9042 // (non-empty name) holds, and the arity check still works. 9043 name = "col" + (k + 1); 9044 } 9045 String lower = name.toLowerCase(Locale.ROOT); 9046 if (!seenLower.add(lower)) { 9047 throw new SemanticIRBuildException(Diagnostic.error( 9048 DiagnosticCode.DUPLICATE_OUTPUT_NAME, 9049 "duplicate column name '" + name + "' in " + dmlLabel + " '" 9050 + cte.getTableName() 9051 + "' explicit column list; output names must " 9052 + "be unique within a CTE published column list", 9053 cte)); 9054 } 9055 renamed.add(name); 9056 } 9057 List<OutputColumn> bodyOutputs = body.getOutputColumns(); 9058 if (bodyOutputs.size() != renamed.size()) { 9059 DiagnosticCode arityCode = isMerge 9060 ? DiagnosticCode.MERGE_CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH 9061 : DiagnosticCode.CTE_EXPLICIT_COLUMN_LIST_ARITY_MISMATCH; 9062 throw new SemanticIRBuildException(Diagnostic.error( 9063 arityCode, 9064 withClauseLabel + " '" + cte.getTableName() 9065 + "' declares " + renamed.size() 9066 + " explicit column(s) but the body's SELECT " 9067 + "publishes " + bodyOutputs.size() + " column(s); " 9068 + "the explicit list must have exactly one entry " 9069 + "per body output column", 9070 cte)); 9071 } 9072 // Capture the old → new name mapping by ordinal BEFORE building the 9073 // new OutputColumns, so the lineage rewrite can look up the 9074 // substitution by old (inner) name. Codex round-1 diff-review 9075 // (non-blocking → upgraded to defensive guard): if the body has 9076 // duplicate inner output names (e.g. `SELECT id, id`), name-keyed 9077 // rewrite collapses both old refs to the last mapping and 9078 // produces wrong lineage. The IR contract already states output 9079 // names must be unique (see DUPLICATE_OUTPUT_NAME javadoc and the 9080 // line-4378 scalar-subquery guard) but is not enforced 9081 // generically. Reject here so explicit-rename paths cannot 9082 // silently break lineage. 9083 Set<String> seenInnerLower = new HashSet<>(); 9084 for (OutputColumn oc : bodyOutputs) { 9085 String n = oc.getName(); 9086 if (n == null || n.isEmpty()) continue; 9087 String lower = n.toLowerCase(Locale.ROOT); 9088 if (!seenInnerLower.add(lower)) { 9089 throw new SemanticIRBuildException(Diagnostic.error( 9090 DiagnosticCode.DUPLICATE_OUTPUT_NAME, 9091 dmlLabel + " '" + cte.getTableName() 9092 + "' body publishes duplicate inner column " 9093 + "name '" + n + "'; the explicit column " 9094 + "list rename requires unique inner names " 9095 + "because lineage refs are keyed by output " 9096 + "name and would collide", 9097 cte)); 9098 } 9099 } 9100 Map<String, String> oldToNewLower = new HashMap<>(); 9101 List<OutputColumn> newOutputs = new ArrayList<>(bodyOutputs.size()); 9102 for (int k = 0; k < bodyOutputs.size(); k++) { 9103 OutputColumn oc = bodyOutputs.get(k); 9104 String oldName = oc.getName(); 9105 String newName = renamed.get(k); 9106 if (oldName != null && !oldName.isEmpty()) { 9107 oldToNewLower.put(oldName.toLowerCase(Locale.ROOT), newName); 9108 } 9109 newOutputs.add(new OutputColumn(newName, oc.isDerived(), 9110 oc.isAggregate(), oc.getSources(), oc.getWindowSpec())); 9111 } 9112 // Rebuild the body's StatementGraph using the slice-85 15-arg primary 9113 // constructor — copies every field (including the slice-85 9114 // returningColumns slot per codex round-1 plan-review BLOCKING) 9115 // except outputColumns. 9116 StatementGraph renamedBody = new StatementGraph( 9117 body.getName(), body.getKind(), body.getRelations(), 9118 newOutputs, body.getReturningColumns(), 9119 body.getFilterColumnRefs(), body.getJoinColumnRefs(), 9120 body.getGroupByColumnRefs(), body.getHavingColumnRefs(), 9121 body.getOrderByColumnRefs(), body.getDistinctOnColumnRefs(), 9122 body.isDistinct(), body.getSetOperator(), body.getRowLimit(), 9123 body.getTarget()) 9124 // Slice 180 (R5): the rebuild via the legacy ctor would strip 9125 // the block span the original carried; preserve it so the 9126 // renamed body keeps its scope span. (The legacy ctor also 9127 // resets joinAnalysisFacts; that pre-existing facts-strip is 9128 // left as-is here — out of R5 scope — to avoid changing the 9129 // emitted JSON for these renamed bodies.) 9130 .withSourceSpan(body.getSourceSpan()); 9131 stmts.set(bodyIdx, renamedBody); 9132 // Rewrite outgoing STATEMENT_OUTPUT refs in the window. Both `from` 9133 // and `to` are checked because edges can place the body-output ref 9134 // on either side (producer-side: from=TABLE_COLUMN, to=STATEMENT_OUTPUT; 9135 // consumer-side from a deeper inner stmt: from=STATEMENT_OUTPUT, 9136 // to=STATEMENT_OUTPUT — neither shape today places bodyIdx on 9137 // `from` for THIS body, but the symmetric check is cheap and 9138 // future-proof). LineageRef and LineageEdge are immutable, so new 9139 // instances are constructed and `lineage.set` replaces in place. 9140 for (int idx = lineageSize0; idx < lineage.size(); idx++) { 9141 LineageEdge edge = lineage.get(idx); 9142 LineageRef from = edge.getFrom(); 9143 LineageRef to = edge.getTo(); 9144 LineageRef newFrom = maybeRewriteStatementOutputRef( 9145 from, bodyIdx, oldToNewLower); 9146 LineageRef newTo = maybeRewriteStatementOutputRef( 9147 to, bodyIdx, oldToNewLower); 9148 if (newFrom != from || newTo != to) { 9149 lineage.set(idx, new LineageEdge(newFrom, newTo)); 9150 } 9151 } 9152 return Collections.unmodifiableList(renamed); 9153 } 9154 9155 /** 9156 * Slice 113 — copy a {@link StatementGraph} with a new {@code name} 9157 * field. Every other field is preserved verbatim. Used by the 9158 * set-op branch loop to assign the synthetic 9159 * {@code <set_op_branch_<idx>>} name AFTER the branch build, in case 9160 * the branch's WHERE-side predicate-subquery extraction 9161 * (slice 113 via {@link PredicateClauseContext#SET_OP_BRANCH_WHERE}) 9162 * appended predicate-body statements to {@code stmts}, which would 9163 * otherwise leave the pre-computed digit suffix lagging behind the 9164 * branch's final position. 9165 * 9166 * <p>The rebuild is purely cosmetic on the {@link StatementGraph#getName()} 9167 * field. No {@link LineageRef} is affected because all lineage refs 9168 * are idx-based (see {@link LineageRef#statementOutput(int, String)}), 9169 * not name-based. {@code outputColumns}, {@code relations}, 9170 * {@code filterColumnRefs}, {@code joinColumnRefs} and every other 9171 * field are reused unchanged. 9172 */ 9173 private static StatementGraph withRenamedTo(StatementGraph s, String newName) { 9174 return new StatementGraph(newName, s.getKind(), 9175 s.getRelations(), s.getOutputColumns(), s.getReturningColumns(), 9176 s.getFilterColumnRefs(), s.getJoinColumnRefs(), 9177 s.getGroupByColumnRefs(), s.getHavingColumnRefs(), 9178 s.getOrderByColumnRefs(), s.getDistinctOnColumnRefs(), 9179 s.isDistinct(), s.getSetOperator(), s.getRowLimit(), 9180 s.getTarget()) 9181 // Slice 180 (R5): preserve the block span the legacy ctor 9182 // would otherwise drop, so this rename keeps the scope span. 9183 .withSourceSpan(s.getSourceSpan()); 9184 } 9185 9186 /** 9187 * Slice 102 — return a new STATEMENT_OUTPUT {@link LineageRef} with the 9188 * output name substituted when {@code ref} targets {@code bodyIdx} and 9189 * its current output name is a key in {@code oldToNewLower}. Otherwise 9190 * return {@code ref} unchanged (identity-comparable so the caller can 9191 * skip the {@code lineage.set} for no-op rewrites). 9192 */ 9193 private static LineageRef maybeRewriteStatementOutputRef( 9194 LineageRef ref, int bodyIdx, 9195 Map<String, String> oldToNewLower) { 9196 if (ref == null) return null; 9197 if (ref.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) return ref; 9198 if (ref.getStatementIndex() != bodyIdx) return ref; 9199 String oldName = ref.getOutputName(); 9200 if (oldName == null || oldName.isEmpty()) return ref; 9201 String newName = oldToNewLower.get(oldName.toLowerCase(Locale.ROOT)); 9202 if (newName == null) return ref; 9203 return LineageRef.statementOutput(bodyIdx, newName); 9204 } 9205 9206 /** 9207 * Emit one lineage edge per (output, source) pair. Edges target a 9208 * {@link LineageRef.Kind#STATEMENT_OUTPUT} when the source's relation 9209 * is a CTE or a FROM-clause subquery, or a 9210 * {@link LineageRef.Kind#TABLE_COLUMN} when it's a base table. 9211 * Multi-source derived columns produce one edge per source. 9212 * 9213 * <p>{@code subqueryAliasToStatementIndex} is statement-local; the 9214 * caller supplies the alias map for this statement's own FROM list. 9215 * That avoids cross-scope alias collisions. 9216 */ 9217 private static void emitLineageForStatement(StatementGraph stmt, 9218 int statementIndex, 9219 List<LineageEdge> lineage, 9220 Map<String, Integer> cteNameToStatementIndex, 9221 Map<String, Integer> subqueryAliasToStatementIndex, 9222 Map<Integer, ScalarInfo> ordinalToScalarInfo) { 9223 // Slice 87: lowercase alias keys so SQL identifiers written with 9224 // different casing in the FROM clause vs. SELECT qualifier resolve 9225 // correctly (e.g. `SELECT t.name FROM employees T`). Mirrors the 9226 // same fix in emitUpdateSubquerySourceEdges (slice 83). When two 9227 // relations collide after lowercasing (unusual, but not guaranteed 9228 // caught by the duplicate-alias preflight in all call paths per 9229 // codex Q1 advisory), last-write-wins — the same policy as slice 83. 9230 Map<String, RelationSource> aliasToRelation = new HashMap<>(); 9231 for (RelationSource r : stmt.getRelations()) { 9232 String key = r.getAlias(); 9233 if (key == null || key.isEmpty()) continue; 9234 aliasToRelation.put(key.toLowerCase(Locale.ROOT), r); 9235 } 9236 List<OutputColumn> outputs = stmt.getOutputColumns(); 9237 for (int outOrdinal = 0; outOrdinal < outputs.size(); outOrdinal++) { 9238 OutputColumn out = outputs.get(outOrdinal); 9239 // Slice 11: scalar-subquery projections have empty sources 9240 // by construction; their lineage edge is a single 9241 // STATEMENT_OUTPUT → STATEMENT_OUTPUT pointing at the 9242 // extracted scalar body's only output. Emit it once and 9243 // skip the per-source loop (which would be a no-op anyway). 9244 ScalarInfo scalar = ordinalToScalarInfo.get(outOrdinal); 9245 if (scalar != null) { 9246 lineage.add(new LineageEdge( 9247 LineageRef.statementOutput(statementIndex, out.getName()), 9248 LineageRef.statementOutput(scalar.statementIndex, 9249 scalar.innerOutputName))); 9250 continue; 9251 } 9252 for (ColumnRef src : out.getSources()) { 9253 String srcAlias = src.getRelationAlias(); 9254 RelationSource rel = aliasToRelation.get( 9255 srcAlias == null ? null : srcAlias.toLowerCase(Locale.ROOT)); 9256 if (rel == null) { 9257 throw new SemanticIRBuildException( 9258 Diagnostic.error(DiagnosticCode.OUTPUT_REFERENCES_UNKNOWN_RELATION, 9259 "output '" + out.getName() + "' references unknown relation '" 9260 + src.getRelationAlias() + "'", null)); 9261 } 9262 LineageRef from = LineageRef.statementOutput(statementIndex, out.getName()); 9263 LineageRef to; 9264 // Slice 15: resolved-kind dispatch. For OUTER_REFERENCE 9265 // bindings the underlying outerKind decides which 9266 // table-column or statement-output edge we emit. 9267 // Codex round-1 MUST 2 / round-2 MUST 1: exhaustive 9268 // dispatch instead of catch-all. 9269 RelationKind kind = rel.getBinding().getKind(); 9270 RelationKind resolvedKind = (kind == RelationKind.OUTER_REFERENCE) 9271 ? rel.getBinding().getOuterKind() 9272 : kind; 9273 if (resolvedKind == RelationKind.CTE) { 9274 Integer cteIndex = cteNameToStatementIndex.get( 9275 rel.getBinding().getQualifiedName().toLowerCase(Locale.ROOT)); 9276 if (cteIndex == null) { 9277 throw new SemanticIRBuildException( 9278 Diagnostic.error(DiagnosticCode.CTE_BODY_MISSING, 9279 "CTE '" + rel.getBinding().getQualifiedName() + "' has no body statement", null)); 9280 } 9281 to = LineageRef.statementOutput(cteIndex, src.getColumnName()); 9282 } else if (resolvedKind == RelationKind.SUBQUERY) { 9283 Integer subIndex = subqueryAliasToStatementIndex.get( 9284 rel.getAlias().toLowerCase(Locale.ROOT)); 9285 if (subIndex == null) { 9286 throw new SemanticIRBuildException( 9287 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_BINDING_UNRESOLVED, 9288 "FROM-clause subquery '" + rel.getAlias() 9289 + "' has no body statement registered", null)); 9290 } 9291 to = LineageRef.statementOutput(subIndex, src.getColumnName()); 9292 } else if (resolvedKind == RelationKind.TABLE 9293 || resolvedKind == RelationKind.FUNCTION) { 9294 // FUNCTION (table-valued function) is an opaque source: 9295 // lineage terminates at the function's qualified name, 9296 // same shape as a base table column. 9297 to = LineageRef.tableColumn( 9298 rel.getBinding().getQualifiedName(), 9299 src.getColumnName()); 9300 } else { 9301 throw new SemanticIRBuildException( 9302 Diagnostic.error(DiagnosticCode.OUTPUT_REFERENCES_UNSUPPORTED_BINDING_KIND, 9303 "output '" + out.getName() 9304 + "' references relation '" + rel.getAlias() 9305 + "' with unsupported binding kind " + kind 9306 + (kind == RelationKind.OUTER_REFERENCE 9307 ? " (outerKind=" + rel.getBinding().getOuterKind() + ")" 9308 : ""), null)); 9309 } 9310 lineage.add(new LineageEdge(from, to)); 9311 } 9312 } 9313 } 9314 9315 /** 9316 * Build one SELECT statement (CTE body or outer). The {@code name} 9317 * argument is non-null for a CTE body, null otherwise. When 9318 * {@code hasOuterCteListAlreadyProcessed} is true, the SELECT's own 9319 * {@code getCteList()} is not rejected because the caller has already 9320 * extracted those CTEs into separate statements; in all other cases a 9321 * non-empty CTE list on this node is rejected (so nested WITH inside 9322 * a CTE body does not silently slip through). 9323 */ 9324 private static StatementGraph buildSelectStatement(TSelectSqlStatement select, 9325 NameBindingProvider provider, 9326 String name, 9327 boolean hasOuterCteListAlreadyProcessed, 9328 boolean allowFromSubqueries, 9329 boolean allowScalarProjectionSubqueries, 9330 boolean allowWindowProjection) { 9331 // Slice 23: legacy 7-arg call site. Predicate-subquery extraction is 9332 // disabled (allowJoinOnPredicateSubqueries=false) and this is not a 9333 // predicate body itself (isPredicateBody=false). All non-outer call 9334 // sites use this overload — the slice-17 `rejectSubqueriesInJoinOn` 9335 // continues to fire at every non-outer JOIN-ON site. 9336 return buildSelectStatementImpl(select, provider, name, 9337 hasOuterCteListAlreadyProcessed, 9338 allowFromSubqueries, 9339 allowScalarProjectionSubqueries, 9340 allowWindowProjection, 9341 /*allowJoinOnPredicateSubqueries=*/ false, 9342 /*stmtsForExtraction=*/ null, 9343 /*lineageForExtraction=*/ null, 9344 /*cteMapForExtraction=*/ null, 9345 /*isPredicateBody=*/ false, 9346 /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE, 9347 /*allowWherePredicateSubqueries=*/ false); 9348 } 9349 9350 /** 9351 * Internal body shared between the legacy 7-arg overload and the 9352 * outer-SELECT entry point used by {@link #build}. Slice 23 added two new 9353 * concepts; slice 24 added one more. 9354 * <ul> 9355 * <li>{@code allowJoinOnPredicateSubqueries} + {@code stmts}/{@code lineage} 9356 * — when {@code allow...} is {@code true}, JOIN-ON uncorrelated 9357 * EXISTS subqueries are extracted as their own 9358 * {@code <predicate_subquery_<i>>} statements appended to 9359 * {@code stmts} (slice-11/12 synthetic-name pattern). Outer-SELECT 9360 * entry only.</li> 9361 * <li>{@code isPredicateBody} — when {@code true}, this statement IS 9362 * the inner SELECT of an extracted EXISTS body. The constant-only 9363 * projection rejection in {@link #buildOutputColumns} is bypassed 9364 * and a single synthetic OutputColumn is emitted in its place.</li> 9365 * <li>{@code cteMapForExtraction} (slice 24) — outer's CTE 9366 * name-to-statement-index map, plumbed in only when 9367 * {@code allowJoinOnPredicateSubqueries=true}. Required so the 9368 * slice-24 column-bearing inner projection can emit 9369 * STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edges into outer- 9370 * visible CTE bodies. Non-outer call sites pass {@code null}.</li> 9371 * </ul> 9372 */ 9373 private static StatementGraph buildSelectStatementImpl( 9374 TSelectSqlStatement select, 9375 NameBindingProvider provider, 9376 String name, 9377 boolean hasOuterCteListAlreadyProcessed, 9378 boolean allowFromSubqueries, 9379 boolean allowScalarProjectionSubqueries, 9380 boolean allowWindowProjection, 9381 boolean allowJoinOnPredicateSubqueries, 9382 List<StatementGraph> stmtsForExtraction, 9383 List<LineageEdge> lineageForExtraction, 9384 Map<String, Integer> cteMapForExtraction, 9385 boolean isPredicateBody, 9386 PredicateClauseContext whereClauseContext, 9387 boolean allowWherePredicateSubqueries) { 9388 rejectUnsupportedShape(select, hasOuterCteListAlreadyProcessed); 9389 // Slice 129: PIVOT sub-slice (a). A SELECT whose single FROM source 9390 // is a PIVOT routes to the dedicated pivot builder, which binds the 9391 // underlying source relation and captures the consumed FOR / 9392 // aggregation-arg columns into StatementGraph.pivotColumnRefs. Scoped 9393 // to the outer SELECT context (name == null, not a predicate body); 9394 // nested / non-outer PIVOTs are deferred to a later sub-slice and 9395 // fall through to the normal path (which rejects with the existing 9396 // TABLE_BINDING_UNRESOLVED as before). 9397 if (name == null && !isPredicateBody && isPivotSelect(select)) { 9398 return buildPivotSelect(select, provider, name); 9399 } 9400 // Slice 143: a PIVOT/UNPIVOT driver that ALSO has a JOIN partner 9401 // (e.g. FROM sales PIVOT(...) AS p JOIN dim d ON ...) trips 9402 // isPivotSelect's "no trailing JOIN" guard and would otherwise fall to 9403 // the normal path and reject with the GENERIC TABLE_BINDING_UNRESOLVED. 9404 // Emit a PRECISE deferral reusing the existing 9405 // PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED code (no new lineage admitted), 9406 // mirroring the precise chained-PIVOT reject. 9407 // 9408 // Slice 145: this precise routing no longer carries the slice-143/144 9409 // `name == null && !isPredicateBody` guard, so it also fires in NESTED 9410 // contexts (CTE body / FROM-subquery body / set-op branch / 9411 // predicate-subquery body) where a pivot-with-join reaches 9412 // buildSelectStatementImpl under a non-null name (or isPredicateBody). 9413 // The slice-129 ADMISSION router above keeps its outer-only guard — 9414 // nested ADMITTED pivots (isPivotSelect) are routed to buildPivotSelect 9415 // at their own call sites and never reach here; only NON-admitted 9416 // pivot-with-join shapes do, which were previously rejected with the 9417 // generic TABLE_BINDING_UNRESOLVED. The helpers only match when a 9418 // pivoted_table is genuinely present, so a plain JOIN is untouched. 9419 if (isPivotWithJoinPartner(select)) { 9420 TTable pivotTable = select.joins.getJoin(0).getTable(); 9421 // Slice 146: name the ACTUAL operator (PIVOT vs UNPIVOT) so an 9422 // UNPIVOT-then-JOIN does not mis-report "PIVOT". 9423 throw new SemanticIRBuildException( 9424 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 9425 pivotOperatorKeyword(pivotTable) + " combined with a JOIN is not supported yet", 9426 pivotTable)); 9427 } 9428 // Slice 144: the symmetric positions — a PIVOT/UNPIVOT appearing as a 9429 // JOIN PARTNER rather than the FROM driver: the right-hand side of an 9430 // explicit JOIN (FROM dim JOIN sales PIVOT(...) ON ...) or a comma-join 9431 // sibling (FROM sales PIVOT(...), dim). Slice 143 closed the 9432 // pivot-as-driver case; these shapes previously fell to the normal path 9433 // and rejected with the GENERIC TABLE_BINDING_UNRESOLVED. Emit the same 9434 // PRECISE deferral reusing PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED (no new 9435 // lineage admitted). Slice 145 (see above) lifts the outer-only guard so 9436 // this also fires for a pivot join partner in any nesting context. 9437 TTable pivotPartner = findPivotJoinPartner(select); 9438 if (pivotPartner != null) { 9439 // Slice 146: name the ACTUAL operator (PIVOT vs UNPIVOT). 9440 throw new SemanticIRBuildException( 9441 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 9442 pivotOperatorKeyword(pivotPartner) + " combined with a JOIN is not supported yet", 9443 pivotPartner)); 9444 } 9445 boolean distinct = resolveDistinctFlag(select); 9446 // Slice 65 — reset using scope at entry so a parent SELECT's 9447 // scope cannot leak into recursive nested builds. The using 9448 // scope for THIS SELECT is installed only AFTER buildRelations 9449 // completes (see below) so the predicate-subquery extraction 9450 // walk inside buildRelations does not inherit the outer scope 9451 // (codex slice-65 diff-review round-1 P2 #1: an inner 9452 // {@code EXISTS (SELECT SUM(x.v) FILTER (WHERE k > 0) FROM x)} 9453 // would have its bare `k` expand to outer's merged sources, 9454 // causing a valid uncorrelated body to be rejected as 9455 // correlated). The slice-64 → 65 JOIN-ON merged-key reject 9456 // also runs BEFORE buildRelations so ON-clause refs aren't 9457 // collected with a stale or future scope. 9458 provider = provider.withUsingScope(UsingScope.EMPTY); 9459 // Reset the join-graph structural anchor at entry (mirrors the 9460 // UsingScope reset above) so a parent SELECT's anchor cannot leak into 9461 // this recursive nested build. It is re-installed below for THIS 9462 // SELECT's own FROM clause once relations are bound. 9463 provider = provider.withJoinStructureAnchor(false); 9464 rejectUnqualifiedMergedKeyInJoinOn(select, provider); 9465 List<ColumnRef> joinRefs = new ArrayList<>(); 9466 List<RelationSource> relations; 9467 if (isSetOpBranchSyntheticName(name) 9468 && hasNoFromSource(select) 9469 && allResultColumnsAreConstantExpressions(select)) { 9470 // Slice 61: allow FROM-less constant-only set-op branches 9471 // such as SELECT 1 UNION ALL SELECT 2. The general SELECT 9472 // boundary remains unchanged: non-branch SELECT 1 still 9473 // fails in buildRelations with "must have at least one 9474 // FROM source". 9475 relations = Collections.emptyList(); 9476 } else { 9477 relations = buildRelations(select, provider, joinRefs, 9478 allowFromSubqueries, 9479 allowJoinOnPredicateSubqueries, 9480 stmtsForExtraction, lineageForExtraction, cteMapForExtraction); 9481 } 9482 // Slice 65 — install this SELECT's own using scope AFTER 9483 // buildRelations / predicate-subquery extraction. From here 9484 // forward the clause collectors (output / filter / groupBy / 9485 // having / orderBy) see the merged-key scope for THIS SELECT. 9486 UsingScope ownScope = buildUsingScope(select, provider); 9487 if (!ownScope.isEmpty()) { 9488 provider = provider.withUsingScope(ownScope); 9489 } 9490 // Install the join-graph structural anchor for THIS SELECT: two or more 9491 // bound FROM endpoints mean the join graph is fully determined (an 9492 // unqualified column can only be NON_EXACT when ≥2 candidate relations 9493 // exist, and reaching here means every relation bound — buildRelations 9494 // throws TABLE_BINDING_UNRESOLVED otherwise). The anchor lets 9495 // rejectNonExactBindings degrade an unqualified, catalog-less column 9496 // miss over an explicit ON / CROSS / comma join to a warning instead of 9497 // discarding the whole statement graph — the same treatment the USING 9498 // merged-key anchor already gives. 9499 // 9500 // CATALOG-LESS GATE: the degrade exists only because, WITHOUT a catalog, 9501 // we cannot know which side an unqualified column belongs to. When every 9502 // FROM relation has a known column list (catalog or in-scope derived 9503 // columns), the catalog CAN adjudicate — an unqualified column matching 9504 // no side is a genuine error and must stay fatal (a multi-relation 9505 // resolver returns a null binding either way, so the reject result alone 9506 // cannot distinguish the two). So anchor only when at least one endpoint's 9507 // columns are unknown. 9508 if (relations.size() >= 2 && !fromRelationColumnsFullyKnown(select, provider)) { 9509 provider = provider.withJoinStructureAnchor(true); 9510 } 9511 List<OutputColumn> outputColumns = buildOutputColumns(select, provider, 9512 allowScalarProjectionSubqueries, allowWindowProjection, 9513 isPredicateBody, name); 9514 // Slice 112 — thread the SELECT path's outer extraction context 9515 // through buildFilterColumnRefs so top-level SELECT WHERE can 9516 // lift uncorrelated predicate-subquery wrappers via the 9517 // slice-23+ extraction pipeline (PredicateClauseContext.SELECT_WHERE). 9518 // Slice 113 — the same threading extends to set-op branch WHERE 9519 // via PredicateClauseContext.SET_OP_BRANCH_WHERE, distinguished 9520 // only by clauseLabel for diagnostic messages (codes are shared). 9521 // 9522 // {@code allowWherePredicateSubqueries} is INDEPENDENT of 9523 // {@code allowJoinOnPredicateSubqueries} (slice 113 split): 9524 // set-op branches admit WHERE-side predicate subqueries while 9525 // KEEPING JOIN-ON predicate subqueries rejected (slice 23 / 26 9526 // contract — pinned by Slice23Test#existsInSetOpBranchJoinOnStillRejected 9527 // and Slice26Test#lhsSubqueryInSetOpBranchRejected). Nested 9528 // SELECTs without extraction context 9529 // (allowWherePredicateSubqueries=false) keep the slice-80 9530 // blanket reject inside buildFilterColumnRefs. 9531 // R8 — collector for predicate-derived semi-joins (EXISTS / IN / 9532 // NOT EXISTS / NOT IN in WHERE). Populated by buildFilterColumnRefs' 9533 // extraction; converted to SEMI / ANTI_SEMI JoinEntities at 9534 // JoinGraph assembly below (which has `relations` to resolve the 9535 // outer left endpoint). 9536 List<SemiJoinFact> semiFacts = new ArrayList<>(); 9537 // R8 — the outer FROM relation aliases (lower-cased) gate the 9538 // correlated-EXISTS degrade: only a correlation to a genuine outer 9539 // relation degrades; an unknown alias still rejects. 9540 Set<String> outerRelationAliases = new HashSet<>(); 9541 for (RelationSource rs : relations) { 9542 if (rs.getAlias() != null && !rs.getAlias().isEmpty()) { 9543 outerRelationAliases.add(rs.getAlias().toLowerCase(Locale.ROOT)); 9544 } 9545 } 9546 List<ColumnRef> filterRefs = buildFilterColumnRefs(select, provider, 9547 allowWherePredicateSubqueries, 9548 stmtsForExtraction, lineageForExtraction, cteMapForExtraction, 9549 whereClauseContext, semiFacts, outerRelationAliases); 9550 List<ColumnRef> groupByRefs = buildGroupByColumnRefs(select, provider); 9551 List<ColumnRef> havingRefs = buildHavingColumnRefs(select, provider); 9552 List<ColumnRef> orderByRefs = buildOrderByColumnRefs(select, provider, outputColumns); 9553 // Slice 73: DISTINCT ON refs collected here so they observe the 9554 // same {@code provider} (with UsingScope already installed) used 9555 // by buildGroupByColumnRefs / buildHavingColumnRefs / 9556 // buildOrderByColumnRefs. This keeps `DISTINCT ON (k)` over 9557 // `JOIN ... USING (k)` consistent with slice-65 merged-key 9558 // semantics and prevents parent-scope leakage into nested 9559 // builds. 9560 List<ColumnRef> distinctOnRefs = buildDistinctOnColumnRefs(select, provider); 9561 // Slice 125: QUALIFY filter refs. Needs outputColumns for the 9562 // projection-alias form (QUALIFY rn = 1); observes the same 9563 // provider (with UsingScope already installed) as the other 9564 // clause-ref builders so QUALIFY over JOIN ... USING (k) stays 9565 // consistent with slice-65 merged-key semantics. 9566 List<ColumnRef> qualifyRefs = buildQualifyColumnRefs(select, provider, outputColumns); 9567 // Slice 128: structured GROUP BY view. Built after 9568 // buildGroupByColumnRefs so its slice-61/slice-13 guards have 9569 // already fired; observes the same provider as the other clause-ref 9570 // builders. Empty unless the statement has a GROUP BY. 9571 List<GroupingElement> groupingElements = buildGroupingElements(select, provider); 9572 RowLimit rowLimit = buildRowLimit(select); 9573 StatementGraph sg = new StatementGraph(name, "SELECT", relations, outputColumns, 9574 filterRefs, joinRefs, groupByRefs, havingRefs, orderByRefs, 9575 distinctOnRefs, 9576 qualifyRefs, 9577 groupingElements, 9578 distinct, 9579 /*setOperator=*/ null, 9580 rowLimit); 9581 // Slice 179 (R5): carry this block's own source span (covers the 9582 // full select [... where ...]). All SELECT blocks — main, CTE bodies, 9583 // FROM-subquery bodies — funnel through this site, so each gets its 9584 // span from its own parse node. Read by attachQueryBlockScopes. 9585 sg = sg.withSourceSpan(SourceSpan.of(select)); 9586 // Slice 167 (S6): attach the ordered structured JoinGraph (GAP 1). 9587 // Slice 169 (S8): attach the WHERE filter predicate tree (GAP 2/3). 9588 // Both additive — the flat joinColumnRefs / filterColumnRefs above 9589 // are untouched. 9590 JoinGraph joinGraph = buildJoinGraph(select, provider); 9591 // Source-dialect Sema runs after relation binding and before join 9592 // promotion. It is unconditional for every SELECT/query block, so an 9593 // Oracle (+) marker cannot disappear merely because no comma-join 9594 // boundary was promotable. Compatibility lowering continues below, 9595 // while the atomic build result retains every ERROR and recovery link. 9596 OracleLegacyOuterJoinValidationResult oraclePlusValidation = 9597 OracleLegacyOuterJoinValidator.validate( 9598 select, relations, provider); 9599 for (Diagnostic diagnostic : oraclePlusValidation.getDiagnostics()) { 9600 recordBuildDiagnostic(diagnostic); 9601 } 9602 for (RecoveryMetadata.Entry recovery 9603 : oraclePlusValidation.getRecoveryEntries()) { 9604 recordBuildRecovery(recovery); 9605 } 9606 // R8 — append predicate-derived semi-join entities (EXISTS / IN / 9607 // NOT EXISTS / NOT IN) after the FROM-clause joins, continuing the 9608 // order numbering. The left endpoint is the outer relation owning 9609 // the correlated outer column (falling back to the first FROM 9610 // relation); the right endpoint is the lifted subquery block. 9611 joinGraph = appendSemiJoins(joinGraph, semiFacts, relations); 9612 // Oracle-style implicit-join predicate promotion: when a WHERE conjunct 9613 // links both sides of a comma-join (IMPLICIT_CROSS) entity, move it from 9614 // the filter list into that join's conditions and upgrade the join to 9615 // INNER. A two-sided predicate — equi, non-equi, or a BETWEEN/band range 9616 // across both relations — is a join condition, not a cartesian filter. 9617 // The entity keeps sourceSyntax=COMMA so its implicit origin stays 9618 // distinguishable from an explicit ON join. Single-sided predicates 9619 // remain filters; an IMPLICIT_CROSS with no linking predicate stays a 9620 // true cross product. 9621 TExpression implicitWhere = (select.getWhereClause() != null) 9622 ? select.getWhereClause().getCondition() : null; 9623 List<Predicate> filterPredicates; 9624 if (implicitWhere != null && hasImplicitCrossJoin(joinGraph)) { 9625 ImplicitJoinPromotion promo = promoteImplicitJoinConditions( 9626 joinGraph, implicitWhere, provider, 9627 oraclePlusValidation); 9628 joinGraph = promo.graph; 9629 filterPredicates = promo.filterPredicates; 9630 } else { 9631 filterPredicates = buildFilterPredicates(select, provider); 9632 } 9633 JoinAnalysisFacts facts = sg.getJoinAnalysisFacts(); 9634 if (!joinGraph.isEmpty()) { 9635 facts = facts.withJoinGraph(joinGraph); 9636 } 9637 if (!filterPredicates.isEmpty()) { 9638 facts = facts.withFilterPredicates(filterPredicates); 9639 } 9640 if (!facts.isEmpty()) { 9641 sg = sg.withJoinAnalysisFacts(facts); 9642 } 9643 return sg; 9644 } 9645 9646 /** 9647 * Slice 169 (S8) — decompose the WHERE clause into a resolved filter 9648 * predicate list (GAP 2/3), reusing the slice-166 PredicateTreeBuilder. 9649 * Tolerant by construction: unknown shapes (including predicate 9650 * subqueries) fold into COMPLEX without leaking inner columns or 9651 * throwing. The flat {@code filterColumnRefs} are unaffected. 9652 */ 9653 private static List<Predicate> buildFilterPredicates(TSelectSqlStatement select, 9654 NameBindingProvider provider) { 9655 if (select == null || select.getWhereClause() == null 9656 || select.getWhereClause().getCondition() == null) { 9657 return Collections.emptyList(); 9658 } 9659 return PredicateTreeBuilder.build(select.getWhereClause().getCondition(), provider); 9660 } 9661 9662 /** True iff {@code graph} contains a comma-join {@link SemanticJoinType#IMPLICIT_CROSS} entity. */ 9663 private static boolean hasImplicitCrossJoin(JoinGraph graph) { 9664 for (JoinEntity e : graph.getJoins()) { 9665 if (e.getJoinType() == SemanticJoinType.IMPLICIT_CROSS) { 9666 return true; 9667 } 9668 } 9669 return false; 9670 } 9671 9672 /** Result of {@link #promoteImplicitJoinConditions}: the rewritten graph and residual WHERE filters. */ 9673 private static final class ImplicitJoinPromotion { 9674 final JoinGraph graph; 9675 final List<Predicate> filterPredicates; 9676 9677 ImplicitJoinPromotion(JoinGraph graph, List<Predicate> filterPredicates) { 9678 this.graph = graph; 9679 this.filterPredicates = filterPredicates; 9680 } 9681 } 9682 9683 /** 9684 * Oracle implicit-join predicate promotion. For each top-level WHERE 9685 * conjunct that references aliases from BOTH sides of an 9686 * {@link SemanticJoinType#IMPLICIT_CROSS} comma-join entity, move its 9687 * {@link Predicate} into that entity's conditions and upgrade the entity's 9688 * join type. Aliases are read from the conjunct's AST (not the decomposed 9689 * predicate) so a {@code BETWEEN}'s range bounds — which collapse into a 9690 * single COMPLEX operand — are still seen as the right side. 9691 * 9692 * <p>The upgraded type depends on Oracle's {@code (+)} outer-join marker: 9693 * <ul> 9694 * <li>no {@code (+)} → {@link SemanticJoinType#INNER};</li> 9695 * <li>{@code (+)} on the column of the entity's RIGHT endpoint (the 9696 * right side is null-producing) → {@link SemanticJoinType#LEFT};</li> 9697 * <li>{@code (+)} on a column of the entity's LEFT endpoint (the left 9698 * side is null-producing) → {@link SemanticJoinType#RIGHT}.</li> 9699 * </ul> 9700 * Direction is relative to the entity's endpoints, not operand position; 9701 * for GSP's left-deep comma model the LEFT endpoint is the accumulated 9702 * comma-join result and the RIGHT endpoint is the newly-added relation. 9703 * Multiple {@code (+)} conjuncts naming the same outer side all attach to 9704 * the one entity. A conjunct with {@code (+)} on both operands (illegal 9705 * Oracle) or conjuncts implying conflicting directions degrade the entity 9706 * back to {@link SemanticJoinType#IMPLICIT_CROSS} (its predicates stay 9707 * filters) rather than failing. 9708 * 9709 * <p>Each conjunct is assigned to the FIRST IMPLICIT_CROSS entity (in 9710 * FROM/graph order) whose right relation it references together with at 9711 * least one left-side relation, and all of whose referenced relations are 9712 * available at that boundary. A conjunct touching only one side, or no 9713 * comma-join boundary, stays a filter; an IMPLICIT_CROSS with no linking 9714 * conjunct stays a true cross product. The entity's {@code sourceSyntax} 9715 * stays {@link JoinSourceSyntax#COMMA}. 9716 */ 9717 private static ImplicitJoinPromotion promoteImplicitJoinConditions( 9718 JoinGraph graph, TExpression whereCond, NameBindingProvider provider, 9719 OracleLegacyOuterJoinValidationResult oraclePlusValidation) { 9720 List<PredicateTreeBuilder.ConjunctPredicate> conjuncts = 9721 PredicateTreeBuilder.classifyConjuncts(whereCond, provider); 9722 List<JoinEntity> joins = graph.getJoins(); 9723 JoinEndpointInstanceIndex endpointIndex = 9724 new JoinEndpointInstanceIndex(joins); 9725 int n = conjuncts.size(); 9726 // Pass 1: assign each conjunct to the comma-join boundary it links, 9727 // and accumulate the Oracle (+) outer-join direction signal per entity. 9728 int[] assigned = new int[n]; 9729 java.util.Arrays.fill(assigned, -1); 9730 boolean[] plusPresent = new boolean[n]; 9731 java.util.Map<Integer, java.util.Set<SemanticJoinType>> dirByEntity = 9732 new java.util.HashMap<>(); 9733 // Marked constant/function filters carry only the null-generated 9734 // relation. Resolve them to the earliest entity established by another 9735 // validated pair predicate in this same query block; in Oracle 12c+ 9736 // one null-generated relation may have multiple preserved partners. 9737 java.util.Map<Integer, Integer> entityByNullGeneratedInstance = 9738 new java.util.HashMap<>(); 9739 for (PredicateTreeBuilder.ConjunctPredicate cp : conjuncts) { 9740 PredicateJoinIntent intent = 9741 oraclePlusValidation.intentForConjunct(cp.conjunct); 9742 if (intent == null 9743 || intent.getValidity() != PredicateJoinIntent.Validity.VALID 9744 || intent.isNullGeneratedFilter() 9745 || intent.getNullGeneratedRelationInstanceId() == null) { 9746 continue; 9747 } 9748 int entityIndex = findImplicitJoinEntity( 9749 joins, endpointIndex, 9750 intent.getReferencedRelationInstanceIds()); 9751 if (entityIndex < 0) continue; 9752 Integer nullId = intent.getNullGeneratedRelationInstanceId(); 9753 Integer prior = entityByNullGeneratedInstance.get(nullId); 9754 if (prior == null || entityIndex < prior.intValue()) { 9755 entityByNullGeneratedInstance.put( 9756 nullId, Integer.valueOf(entityIndex)); 9757 } 9758 } 9759 // Entities to degrade: a (+) that is on both sides (illegal Oracle), or 9760 // a (+) we can detect but cannot classify to exactly one endpoint. 9761 java.util.Set<Integer> degradePlusEntities = new java.util.HashSet<>(); 9762 for (int i = 0; i < n; i++) { 9763 PredicateTreeBuilder.ConjunctPredicate cp = conjuncts.get(i); 9764 PredicateJoinIntent validatedIntent = 9765 oraclePlusValidation.intentForConjunct(cp.conjunct); 9766 if (validatedIntent != null 9767 && validatedIntent.isNullGeneratedFilter()) { 9768 Integer target = entityByNullGeneratedInstance.get( 9769 validatedIntent.getNullGeneratedRelationInstanceId()); 9770 if (target != null && target.intValue() >= 0) { 9771 assigned[i] = target.intValue(); 9772 } 9773 } else { 9774 for (int ix = 0; ix < joins.size(); ix++) { 9775 JoinEntity e = joins.get(ix); 9776 if (e.getJoinType() != SemanticJoinType.IMPLICIT_CROSS) continue; 9777 boolean links = validatedIntent != null 9778 && validatedIntent.hasMarker() 9779 ? conjunctInstanceIdsLinkImplicitJoin( 9780 endpointIndex, e, 9781 validatedIntent.getReferencedRelationInstanceIds()) 9782 : conjunctLinksImplicitJoin(e, 9783 collectConjunctRelationAliases( 9784 cp.conjunct, provider)); 9785 if (links) { 9786 assigned[i] = ix; 9787 break; 9788 } 9789 } 9790 } 9791 int target = assigned[i]; 9792 if (target < 0) continue; 9793 // Oracle (+) outer-join direction for this conjunct on its entity. 9794 // Deep-scan so a (+) anywhere in the conjunct is noticed even when 9795 // we cannot classify it (then we degrade rather than silently 9796 // treating it as an inner join). 9797 plusPresent[i] = validatedIntent != null 9798 && validatedIntent.hasMarker(); 9799 LegacyPlusAliasSignal compatibilitySignal = null; 9800 if (!plusPresent[i]) { 9801 compatibilitySignal = legacyPlusAliasSignal( 9802 cp.conjunct, provider); 9803 plusPresent[i] = compatibilitySignal.markerPresent; 9804 } 9805 if (!plusPresent[i]) continue; 9806 if (validatedIntent != null) { 9807 if (validatedIntent.getValidity() 9808 != PredicateJoinIntent.Validity.VALID) { 9809 degradePlusEntities.add(target); 9810 continue; 9811 } 9812 SemanticJoinType dir = oraclePlusJoinDirection( 9813 endpointIndex, joins.get(target), validatedIntent); 9814 if (dir != null) { 9815 dirByEntity.computeIfAbsent(target, 9816 k -> new java.util.HashSet<SemanticJoinType>()).add(dir); 9817 } else { 9818 degradePlusEntities.add(target); 9819 } 9820 } else if (compatibilitySignal.invalid) { 9821 degradePlusEntities.add(target); 9822 } else { 9823 SemanticJoinType dir = compatibilitySignal.outerAlias == null 9824 ? null : oraclePlusJoinDirection( 9825 joins.get(target), compatibilitySignal.outerAlias); 9826 if (dir != null) { 9827 dirByEntity.computeIfAbsent(target, 9828 k -> new java.util.HashSet<SemanticJoinType>()).add(dir); 9829 } else { 9830 // (+) is present but unclassifiable (not a simple 9831 // comparison, ambiguous operand, or outer column not an 9832 // endpoint of this boundary) — degrade rather than guess. 9833 degradePlusEntities.add(target); 9834 } 9835 } 9836 } 9837 // Pass 2: decide each linked entity's final join type. INNER when no 9838 // (+) is present; LEFT/RIGHT when all (+) conjuncts agree on direction; 9839 // DEGRADE (entity stays IMPLICIT_CROSS, its conjuncts fall back to 9840 // filters) when a (+) is invalid/unclassifiable or different conjuncts 9841 // imply conflicting directions. 9842 java.util.Map<Integer, SemanticJoinType> finalType = new java.util.HashMap<>(); 9843 java.util.Set<Integer> degraded = new java.util.HashSet<>(); 9844 for (int ix = 0; ix < joins.size(); ix++) { 9845 if (joins.get(ix).getJoinType() != SemanticJoinType.IMPLICIT_CROSS) continue; 9846 if (degradePlusEntities.contains(ix)) { 9847 degraded.add(ix); 9848 continue; 9849 } 9850 java.util.Set<SemanticJoinType> dirs = dirByEntity.get(ix); 9851 if (dirs == null || dirs.isEmpty()) { 9852 finalType.put(ix, SemanticJoinType.INNER); 9853 } else if (dirs.size() == 1) { 9854 finalType.put(ix, dirs.iterator().next()); 9855 } else { 9856 degraded.add(ix); 9857 } 9858 } 9859 // Pass 3: in conjunct order, route each predicate to its entity's 9860 // condition list or to the residual filter list. For an OUTER (LEFT/ 9861 // RIGHT) join only the (+)-marked conjuncts are join conditions: an 9862 // unmarked two-sided predicate is a post-join WHERE filter in Oracle 9863 // (it eliminates null-extended rows), so it must stay residual. 9864 java.util.Map<Integer, List<Predicate>> condsByEntity = 9865 new java.util.LinkedHashMap<>(); 9866 List<Predicate> residualFilters = new ArrayList<>(); 9867 for (int i = 0; i < n; i++) { 9868 int t = assigned[i]; 9869 boolean asCondition = t >= 0 && !degraded.contains(t); 9870 if (asCondition) { 9871 SemanticJoinType ft = finalType.get(t); 9872 if (ft != SemanticJoinType.INNER && !plusPresent[i]) { 9873 asCondition = false; 9874 } 9875 } 9876 if (asCondition) { 9877 condsByEntity.computeIfAbsent(t, k -> new ArrayList<Predicate>()) 9878 .add(conjuncts.get(i).predicate); 9879 } else { 9880 residualFilters.add(conjuncts.get(i).predicate); 9881 } 9882 } 9883 if (condsByEntity.isEmpty()) { 9884 return new ImplicitJoinPromotion(graph, residualFilters); 9885 } 9886 List<JoinEntity> upgraded = new ArrayList<>(joins.size()); 9887 for (int ix = 0; ix < joins.size(); ix++) { 9888 JoinEntity e = joins.get(ix); 9889 List<Predicate> conds = condsByEntity.get(ix); 9890 if (conds == null || conds.isEmpty()) { 9891 upgraded.add(e); 9892 } else { 9893 upgraded.add(new JoinEntity(finalType.get(ix), 9894 e.getLeftEndpoint(), e.getRightEndpoint(), e.getOrder(), 9895 e.getSourceSyntax(), e.isNatural(), e.getUsingColumns(), 9896 conds, e.getSourceSpan(), e.getConditionText(), e.isLateral())); 9897 } 9898 } 9899 return new ImplicitJoinPromotion(new JoinGraph(upgraded), residualFilters); 9900 } 9901 9902 /** 9903 * Compatibility-only typed signal for non-Oracle dialects whose parser 9904 * reuses the Oracle marker flag. Oracle builds consume validated instance 9905 * identities and never reach this alias-based fallback. 9906 * 9907 * <p>Returns {@code null} when there is no {@code (+)} (or it is not on a 9908 * simple comparison — the only shape Oracle's {@code (+)} attaches to), or 9909 * when the marked column's relation cannot be resolved (caller degrades 9910 * when a marker was nonetheless detected). Returns 9911 * <p>Operand subtrees are deep-scanned for the marker so a parenthesised 9912 * column ({@code (b.y(+))}) is recognised as well as a bare one. 9913 */ 9914 private static LegacyPlusAliasSignal legacyPlusAliasSignal( 9915 TExpression conjunct, NameBindingProvider provider) { 9916 boolean markerPresent = expressionHasOraclePlus(conjunct); 9917 TExpression e = unwrapParenExpression(conjunct); 9918 if (e == null || e.getExpressionType() != EExpressionType.simple_comparison_t) { 9919 return new LegacyPlusAliasSignal(markerPresent, false, null); 9920 } 9921 TExpression lo = e.getLeftOperand(); 9922 TExpression ro = e.getRightOperand(); 9923 boolean leftPlus = expressionHasOraclePlus(lo); 9924 boolean rightPlus = expressionHasOraclePlus(ro); 9925 if (leftPlus && rightPlus) { 9926 return new LegacyPlusAliasSignal(true, true, null); 9927 } 9928 String alias = leftPlus 9929 ? soleOperandRelationAlias(lo, provider) 9930 : rightPlus ? soleOperandRelationAlias(ro, provider) : null; 9931 return new LegacyPlusAliasSignal(markerPresent, false, alias); 9932 } 9933 9934 private static final class LegacyPlusAliasSignal { 9935 private final boolean markerPresent; 9936 private final boolean invalid; 9937 private final String outerAlias; 9938 9939 private LegacyPlusAliasSignal(boolean markerPresent, boolean invalid, 9940 String outerAlias) { 9941 this.markerPresent = markerPresent; 9942 this.invalid = invalid; 9943 this.outerAlias = outerAlias; 9944 } 9945 } 9946 9947 /** 9948 * True iff {@code e} or any descendant (outside a nested subquery) carries 9949 * the Oracle {@code (+)} outer-join marker. Used both to notice a marker we 9950 * cannot otherwise classify and to find it under a {@code parenthesis_t} 9951 * operand wrapper. 9952 */ 9953 private static boolean expressionHasOraclePlus(TExpression e) { 9954 if (e == null) return false; 9955 java.util.Set<TExpression> seen = java.util.Collections.newSetFromMap( 9956 new java.util.IdentityHashMap<TExpression, Boolean>()); 9957 java.util.Deque<TExpression> work = new java.util.ArrayDeque<>(); 9958 work.push(e); 9959 while (!work.isEmpty()) { 9960 TExpression current = work.pop(); 9961 if (!seen.add(current)) continue; 9962 if (current.isOracleOuterJoin()) return true; 9963 if (current.getSubQuery() != null) continue; 9964 if (current.getExprList() != null) { 9965 for (int i = current.getExprList().size() - 1; i >= 0; i--) { 9966 TExpression child = current.getExprList().getExpression(i); 9967 if (child != null) work.push(child); 9968 } 9969 } 9970 if (current.getBetweenOperand() != null) { 9971 work.push(current.getBetweenOperand()); 9972 } 9973 if (current.getRightOperand() != null) { 9974 work.push(current.getRightOperand()); 9975 } 9976 if (current.getLeftOperand() != null) { 9977 work.push(current.getLeftOperand()); 9978 } 9979 } 9980 return false; 9981 } 9982 9983 /** 9984 * The single relation alias an operand expression references, lower-cased, 9985 * or {@code null} when it references zero or more than one relation (an 9986 * Oracle {@code (+)} marks exactly one column). 9987 */ 9988 private static String soleOperandRelationAlias(TExpression operand, 9989 NameBindingProvider provider) { 9990 java.util.Set<String> aliases = collectConjunctRelationAliases(operand, provider); 9991 return aliases.size() == 1 ? aliases.iterator().next() : null; 9992 } 9993 9994 /** 9995 * Map an Oracle {@code (+)} outer (null-producing) relation alias to the 9996 * {@link SemanticJoinType} for {@code entity}, relative to its endpoints: 9997 * a null-producing RIGHT endpoint yields {@link SemanticJoinType#LEFT} 9998 * (the left side is preserved); a null-producing LEFT endpoint yields 9999 * {@link SemanticJoinType#RIGHT} (the right side is preserved). Returns 10000 * {@code null} when the outer alias is not one of this entity's endpoints. 10001 */ 10002 private static SemanticJoinType oraclePlusJoinDirection(JoinEntity entity, 10003 String outerAliasLc) { 10004 String right = entity.getRightEndpoint().getAlias(); 10005 if (right != null && right.toLowerCase(Locale.ROOT).equals(outerAliasLc)) { 10006 return SemanticJoinType.LEFT; 10007 } 10008 for (String left : endpointAliasesLowerCase(entity.getLeftEndpoint())) { 10009 if (left.equals(outerAliasLc)) { 10010 return SemanticJoinType.RIGHT; 10011 } 10012 } 10013 return null; 10014 } 10015 10016 /** Map a validated relation-instance intent to this join boundary. */ 10017 private static SemanticJoinType oraclePlusJoinDirection( 10018 JoinEndpointInstanceIndex endpointIndex, JoinEntity entity, 10019 PredicateJoinIntent intent) { 10020 if (entity == null || entity.getRightEndpoint() == null 10021 || intent == null) { 10022 return null; 10023 } 10024 java.util.Set<Integer> rightIds = endpointIndex.resolve( 10025 entity.getRightEndpoint()); 10026 java.util.Set<Integer> leftIds = endpointIndex.resolve( 10027 entity.getLeftEndpoint()); 10028 Integer nullId = intent.getNullGeneratedRelationInstanceId(); 10029 if (nullId != null && rightIds.contains(nullId)) { 10030 return SemanticJoinType.LEFT; 10031 } 10032 Integer preservedId = intent.getPreservedRelationInstanceId(); 10033 if (nullId != null && leftIds.contains(nullId) 10034 && (preservedId == null || rightIds.contains(preservedId))) { 10035 return SemanticJoinType.RIGHT; 10036 } 10037 return null; 10038 } 10039 10040 /** Unwrap nested parenthesis expressions iteratively; returns the inner non-paren expression. */ 10041 private static TExpression unwrapParenExpression(TExpression e) { 10042 while (e != null && e.getExpressionType() == EExpressionType.parenthesis_t 10043 && e.getLeftOperand() != null) { 10044 e = e.getLeftOperand(); 10045 } 10046 return e; 10047 } 10048 10049 /** 10050 * True iff the comma-join {@code entity} is the boundary at which a WHERE 10051 * conjunct referencing {@code aliases} should be evaluated. Three 10052 * conditions must hold: 10053 * 10054 * <ul> 10055 * <li>the conjunct references the entity's right relation (so the 10056 * newly-added relation actually participates — otherwise the 10057 * predicate belongs to an earlier boundary);</li> 10058 * <li>it references at least one left-side relation (so it is genuinely 10059 * two-sided, not a single-table filter);</li> 10060 * <li>EVERY relation it references is available at this boundary — i.e. 10061 * contained in the left aliases plus the right alias. This prevents 10062 * a predicate that also touches a not-yet-joined relation (e.g. 10063 * {@code a.x + c.z = b.y} at the {@code a×b} boundary) from being 10064 * promoted too early; it is instead picked up at the later boundary 10065 * where its last relation is added.</li> 10066 * </ul> 10067 */ 10068 private static boolean conjunctLinksImplicitJoin(JoinEntity entity, 10069 java.util.Set<String> aliases) { 10070 String right = entity.getRightEndpoint().getAlias(); 10071 if (right == null || right.isEmpty()) return false; 10072 String rightLc = right.toLowerCase(Locale.ROOT); 10073 if (!aliases.contains(rightLc)) return false; 10074 java.util.Set<String> available = new java.util.HashSet<>(); 10075 available.add(rightLc); 10076 boolean linksLeft = false; 10077 for (String left : endpointAliasesLowerCase(entity.getLeftEndpoint())) { 10078 available.add(left); 10079 if (!left.equals(rightLc) && aliases.contains(left)) { 10080 linksLeft = true; 10081 } 10082 } 10083 if (!linksLeft) return false; 10084 // All referenced relations must be available at this boundary. 10085 return available.containsAll(aliases); 10086 } 10087 10088 /** Instance-identity counterpart used for validated Oracle (+) facts. */ 10089 private static boolean conjunctInstanceIdsLinkImplicitJoin( 10090 JoinEndpointInstanceIndex endpointIndex, JoinEntity entity, 10091 java.util.Set<Integer> relationInstanceIds) { 10092 if (entity == null || entity.getRightEndpoint() == null 10093 || relationInstanceIds == null 10094 || relationInstanceIds.size() < 2) { 10095 return false; 10096 } 10097 java.util.Set<Integer> left = endpointIndex.resolve( 10098 entity.getLeftEndpoint()); 10099 java.util.Set<Integer> right = endpointIndex.resolve( 10100 entity.getRightEndpoint()); 10101 java.util.Set<Integer> covered = new java.util.HashSet<>(left); 10102 covered.addAll(right); 10103 return !java.util.Collections.disjoint(relationInstanceIds, left) 10104 && !java.util.Collections.disjoint(relationInstanceIds, right) 10105 && covered.containsAll(relationInstanceIds); 10106 } 10107 10108 private static int findImplicitJoinEntity( 10109 List<JoinEntity> joins, 10110 JoinEndpointInstanceIndex endpointIndex, 10111 java.util.Set<Integer> relationInstanceIds) { 10112 for (int i = 0; i < joins.size(); i++) { 10113 JoinEntity entity = joins.get(i); 10114 if (entity.getJoinType() == SemanticJoinType.IMPLICIT_CROSS 10115 && conjunctInstanceIdsLinkImplicitJoin( 10116 endpointIndex, entity, relationInstanceIds)) { 10117 return i; 10118 } 10119 } 10120 return -1; 10121 } 10122 10123 /** 10124 * Query-block index resolving RELATION/JOIN_RESULT endpoints to exact 10125 * relation instance ids. The producing-join map and endpoint results are 10126 * built once per promotion pass; traversal stays iterative for deep join 10127 * chains. 10128 */ 10129 private static final class JoinEndpointInstanceIndex { 10130 private final java.util.Map<Integer, JoinEntity> byOrder = 10131 new java.util.HashMap<>(); 10132 private final java.util.Map<JoinEndpoint, java.util.Set<Integer>> cache = 10133 new java.util.IdentityHashMap<>(); 10134 10135 private JoinEndpointInstanceIndex(List<JoinEntity> joins) { 10136 for (JoinEntity entity : joins) { 10137 byOrder.put(Integer.valueOf(entity.getOrder()), entity); 10138 } 10139 } 10140 10141 private java.util.Set<Integer> resolve(JoinEndpoint endpoint) { 10142 if (endpoint == null) return java.util.Collections.emptySet(); 10143 java.util.Set<Integer> cached = cache.get(endpoint); 10144 if (cached != null) return cached; 10145 10146 java.util.Set<Integer> ids = new java.util.LinkedHashSet<>(); 10147 java.util.Deque<JoinEndpoint> work = new java.util.ArrayDeque<>(); 10148 work.push(endpoint); 10149 java.util.Set<JoinEndpoint> seen = 10150 java.util.Collections.newSetFromMap( 10151 new java.util.IdentityHashMap<JoinEndpoint, Boolean>()); 10152 while (!work.isEmpty()) { 10153 JoinEndpoint current = work.pop(); 10154 if (!seen.add(current)) continue; 10155 if (current.getKind() == JoinEndpointKind.RELATION) { 10156 if (current.getInstanceId() != JoinEndpoint.NO_INSTANCE_ID) { 10157 ids.add(Integer.valueOf(current.getInstanceId())); 10158 } 10159 continue; 10160 } 10161 if (current.getKind() == JoinEndpointKind.JOIN_RESULT) { 10162 JoinEntity producer = byOrder.get( 10163 Integer.valueOf(current.getProducingJoinOrder())); 10164 if (producer != null) { 10165 work.push(producer.getRightEndpoint()); 10166 work.push(producer.getLeftEndpoint()); 10167 } 10168 } 10169 } 10170 java.util.Set<Integer> result = 10171 java.util.Collections.unmodifiableSet(ids); 10172 cache.put(endpoint, result); 10173 return result; 10174 } 10175 } 10176 10177 /** Lower-cased relation aliases an endpoint represents (RELATION → one; JOIN_RESULT → all contributors). */ 10178 private static java.util.List<String> endpointAliasesLowerCase(JoinEndpoint ep) { 10179 java.util.List<String> out = new ArrayList<>(); 10180 if (ep == null) return out; 10181 if (ep.getKind() == JoinEndpointKind.RELATION) { 10182 if (ep.getAlias() != null && !ep.getAlias().isEmpty()) { 10183 out.add(ep.getAlias().toLowerCase(Locale.ROOT)); 10184 } 10185 } else if (ep.getKind() == JoinEndpointKind.JOIN_RESULT) { 10186 for (String a : ep.getContributingAliases()) { 10187 if (a != null && !a.isEmpty()) { 10188 out.add(a.toLowerCase(Locale.ROOT)); 10189 } 10190 } 10191 } 10192 return out; 10193 } 10194 10195 /** 10196 * Collect the lower-cased relation aliases a WHERE conjunct references, by 10197 * walking its column {@link TObjectName}s. Each column contributes exactly 10198 * ONE canonical alias: the resolver-bound relation alias (which matches the 10199 * join endpoints' {@code effectiveAliasOf}, even when the column is written 10200 * with the table name rather than the alias); when the column does not 10201 * bind, the SQL-written qualifier is the fallback. A column that neither 10202 * binds nor carries a qualifier (a bare unqualified name that the resolver 10203 * cannot place) contributes nothing — it cannot pick a side deterministically. 10204 * 10205 * <p>One canonical alias per column keeps the set comparable to the 10206 * endpoint alias sets, which the containment check in 10207 * {@link #conjunctLinksImplicitJoin} relies on. Nested subqueries are not 10208 * descended into. 10209 */ 10210 private static java.util.Set<String> collectConjunctRelationAliases( 10211 TExpression conjunct, final NameBindingProvider provider) { 10212 final java.util.Set<String> aliases = new java.util.HashSet<>(); 10213 conjunct.acceptChildren(new TParseTreeVisitor() { 10214 int nestedSelectDepth = 0; 10215 10216 @Override 10217 public void preVisit(TSelectSqlStatement nested) { 10218 nestedSelectDepth++; 10219 } 10220 10221 @Override 10222 public void postVisit(TSelectSqlStatement nested) { 10223 nestedSelectDepth--; 10224 } 10225 10226 @Override 10227 public void preVisit(TObjectName node) { 10228 if (nestedSelectDepth > 0) return; 10229 if (node.getDbObjectType() != EDbObjectType.column) return; 10230 ColumnBinding binding = provider.bindColumn(node); 10231 if (binding != null && binding.getRelationAlias() != null 10232 && !binding.getRelationAlias().isEmpty()) { 10233 aliases.add(binding.getRelationAlias().toLowerCase(Locale.ROOT)); 10234 return; 10235 } 10236 String qualifier = node.getTableString(); 10237 if (qualifier != null && !qualifier.isEmpty()) { 10238 aliases.add(qualifier.toLowerCase(Locale.ROOT)); 10239 } 10240 } 10241 }); 10242 return aliases; 10243 } 10244 10245 // ----------------------------------------------------------------- 10246 // Slice 129 — PIVOT sub-slice (a): source binding + consumed-refs slot. 10247 // 10248 // A PIVOT FROM source is a TTable of type pivoted_table carrying a 10249 // TPivotedTable: getRelations().get(0) is the underlying source relation 10250 // and getPivotClause() carries the aggregation function(s), the FOR / 10251 // pivot column(s), and the IN-list. Before slice 129 this rejected with 10252 // a misleading generic TABLE_BINDING_UNRESOLVED ("could not bind table 10253 // null(...)") because bindRelation returns null for a pivoted_table. 10254 // 10255 // Sub-slice (a) admits only the narrow base-table-source / explicit- 10256 // projection / no-other-clauses shape; it binds the source relation and 10257 // captures the consumed FOR + aggregation-arg columns into the new 10258 // StatementGraph.pivotColumnRefs slot. Output-column lineage and 10259 // SELECT * expansion are sub-slice (b); UNPIVOT is (c); subquery source, 10260 // chained PIVOT, extra clauses and nested PIVOT are later sub-slices and 10261 // reject here with structured PIVOT_* diagnostics. 10262 // ----------------------------------------------------------------- 10263 10264 /** 10265 * True when {@code select}'s sole FROM source is a PIVOT/UNPIVOT operator 10266 * with no trailing JOINs. A trailing JOIN on the pivot result 10267 * (e.g. {@code FROM t PIVOT(...) p JOIN x ON ...}) is out of scope for 10268 * slice 129 and stays on the normal path. 10269 */ 10270 private static boolean isPivotSelect(TSelectSqlStatement select) { 10271 if (select.joins == null || select.joins.size() != 1) { 10272 return false; 10273 } 10274 TJoin join = select.joins.getJoin(0); 10275 if (join == null) { 10276 return false; 10277 } 10278 TJoinItemList items = join.getJoinItems(); 10279 if (items != null && items.size() > 0) { 10280 return false; 10281 } 10282 TTable t = join.getTable(); 10283 return t != null 10284 && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 10285 && t.getPivotedTable() != null; 10286 } 10287 10288 /** 10289 * Slice 143 — mirror of {@link #isPivotSelect} but for the PIVOT-then-JOIN 10290 * shape: a single explicit JOIN ({@code joinItems} PRESENT) whose driver 10291 * table is a {@code pivoted_table}. Routes to a precise deferral reject 10292 * (reusing {@code PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED}) instead of the 10293 * generic {@code TABLE_BINDING_UNRESOLVED} the normal path would emit. 10294 */ 10295 private static boolean isPivotWithJoinPartner(TSelectSqlStatement select) { 10296 if (select.joins == null || select.joins.size() != 1) { 10297 return false; 10298 } 10299 TJoin join = select.joins.getJoin(0); 10300 if (join == null) { 10301 return false; 10302 } 10303 TJoinItemList items = join.getJoinItems(); 10304 if (items == null || items.size() == 0) { 10305 return false; 10306 } 10307 TTable t = join.getTable(); 10308 return t != null 10309 && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 10310 && t.getPivotedTable() != null; 10311 } 10312 10313 /** 10314 * Slice 144 — find a PIVOT/UNPIVOT operator that appears as a JOIN 10315 * PARTNER rather than the sole FROM driver. Two positions are covered: 10316 * <ul> 10317 * <li>the right-hand side of an explicit JOIN 10318 * ({@code FROM dim d JOIN sales PIVOT(...) AS p ON ...}), and</li> 10319 * <li>a comma-join sibling ({@code FROM sales PIVOT(...) AS p, dim d}), 10320 * which the parser represents as {@code select.joins.size() > 1}.</li> 10321 * </ul> 10322 * 10323 * <p>The admitted single-PIVOT shape ({@link #isPivotSelect}) and the 10324 * slice-143 pivot-as-driver-with-a-join shape 10325 * ({@link #isPivotWithJoinPartner}) are both routed earlier and never reach 10326 * here, so this helper only upgrades shapes that would otherwise fall to the 10327 * generic {@code TABLE_BINDING_UNRESOLVED}. The driver of the SOLE 10328 * {@code TJoin} is the slice-129/-143 driver and is skipped; for comma-join 10329 * siblings ({@code size > 1}) every {@code TJoin} is an independent FROM 10330 * item whose driver may itself be the pivot. 10331 * 10332 * @return the pivoted {@link TTable} to anchor the diagnostic on, or null. 10333 */ 10334 private static TTable findPivotJoinPartner(TSelectSqlStatement select) { 10335 if (select.joins == null) { 10336 return null; 10337 } 10338 boolean singleJoin = select.joins.size() == 1; 10339 for (int j = 0; j < select.joins.size(); j++) { 10340 TJoin join = select.joins.getJoin(j); 10341 if (join == null) { 10342 continue; 10343 } 10344 if (!singleJoin && isPivotedTable(join.getTable())) { 10345 return join.getTable(); 10346 } 10347 TJoinItemList items = join.getJoinItems(); 10348 if (items != null) { 10349 for (int i = 0; i < items.size(); i++) { 10350 if (items.getJoinItem(i) != null 10351 && isPivotedTable(items.getJoinItem(i).getTable())) { 10352 return items.getJoinItem(i).getTable(); 10353 } 10354 } 10355 } 10356 } 10357 return null; 10358 } 10359 10360 /** 10361 * Slice 146 — the SQL keyword ("PIVOT" or "UNPIVOT") naming the row-shaping 10362 * operator carried by {@code pivotedTable}, used to make the JOIN-deferral 10363 * message name the actual operator instead of always saying "PIVOT". 10364 * Defaults to "PIVOT" when the clause is absent (e.g. a chained-clause-list 10365 * shape where {@link TPivotClause#getType()} is not reachable via 10366 * {@code getPivotClause()}), which is harmless for the deferral message. 10367 */ 10368 private static String pivotOperatorKeyword(TTable pivotedTable) { 10369 if (pivotedTable != null && pivotedTable.getPivotedTable() != null) { 10370 TPivotedTable pivoted = pivotedTable.getPivotedTable(); 10371 // Slice 149 — a CHAINED pivoted_table (clause list > 1) has no single 10372 // getPivotClause(): that accessor surfaces only ONE of the chained 10373 // clauses, so a MIXED chain (PIVOT(...) UNPIVOT(...)) as the JOIN 10374 // driver/partner would name just that one operator. Delegate to the 10375 // slice-148 list-walking helper so a mixed chain names the generic 10376 // "PIVOT / UNPIVOT" (an all-PIVOT chain still names "PIVOT", an 10377 // all-UNPIVOT chain still names "UNPIVOT" — byte-identical to before). 10378 if (pivoted.getPivotClauseList() != null 10379 && pivoted.getPivotClauseList().size() > 1) { 10380 return chainedPivotOperatorKeyword(pivoted); 10381 } 10382 TPivotClause pc = pivoted.getPivotClause(); 10383 if (pc != null && pc.getType() != TPivotClause.pivot) { 10384 return "UNPIVOT"; 10385 } 10386 } 10387 return "PIVOT"; 10388 } 10389 10390 /** 10391 * Slice 148 — the SQL keyword(s) naming the operator(s) carried by a 10392 * CHAINED {@code pivoted_table} ({@code pivotClauseList.size() > 1}), used to 10393 * make the {@code PIVOT_MULTIPLE_CLAUSES_NOT_SUPPORTED} deferral message name 10394 * the actual operator(s) instead of always saying "PIVOT / UNPIVOT". 10395 * Inspects every clause's {@link TPivotClause#getType()}: all PIVOT → 10396 * "PIVOT"; all UNPIVOT → "UNPIVOT"; a genuine mix (or an empty/unknown 10397 * list) → the generic "PIVOT / UNPIVOT". Unlike 10398 * {@link #pivotOperatorKeyword}, this reads the whole clause LIST because the 10399 * single {@code getPivotClause()} accessor is null for a chained shape. 10400 */ 10401 private static String chainedPivotOperatorKeyword(TPivotedTable pivoted) { 10402 boolean sawPivot = false; 10403 boolean sawUnpivot = false; 10404 if (pivoted != null && pivoted.getPivotClauseList() != null) { 10405 for (int i = 0; i < pivoted.getPivotClauseList().size(); i++) { 10406 TPivotClause pc = pivoted.getPivotClauseList().getElement(i); 10407 if (pc == null) { 10408 continue; 10409 } 10410 if (pc.getType() == TPivotClause.pivot) { 10411 sawPivot = true; 10412 } else { 10413 sawUnpivot = true; 10414 } 10415 } 10416 } 10417 if (sawPivot && !sawUnpivot) { 10418 return "PIVOT"; 10419 } 10420 if (sawUnpivot && !sawPivot) { 10421 return "UNPIVOT"; 10422 } 10423 return "PIVOT / UNPIVOT"; 10424 } 10425 10426 /** 10427 * Slice 150 — true when {@code pc} is Oracle's PIVOT XML variant 10428 * ({@code PIVOT XML (...)}). PIVOT XML aggregates all pivoted values into a 10429 * SINGLE XMLType output column rather than producing one column per IN-list 10430 * value, so its output-column lineage differs from a regular PIVOT and 10431 * {@link #buildPivotSelect} defers it. 10432 * 10433 * <p>The parser exposes no XML flag on {@link TPivotClause}; the only signal 10434 * is the clause's start token, which is the {@code XML} keyword when the XML 10435 * modifier is present (it is the opening {@code (} otherwise). UNPIVOT has no 10436 * XML variant, so this only matches a {@code pivot}-type clause. 10437 */ 10438 private static boolean isPivotXml(TPivotClause pc) { 10439 if (pc == null || pc.getType() != TPivotClause.pivot) { 10440 return false; 10441 } 10442 TSourceToken start = pc.getStartToken(); 10443 return start != null && "XML".equalsIgnoreCase(start.astext); 10444 } 10445 10446 /** 10447 * Slice 144 — true when {@code t} is a PIVOT/UNPIVOT operator 10448 * ({@code pivoted_table} carrying a {@code TPivotedTable}). 10449 */ 10450 private static boolean isPivotedTable(TTable t) { 10451 return t != null 10452 && t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 10453 && t.getPivotedTable() != null; 10454 } 10455 10456 /** 10457 * Slice 129 — build the StatementGraph for a PIVOT-source SELECT 10458 * (see {@link #isPivotSelect}). Applies the admit-set rejects in a fixed 10459 * order, binds the underlying source relation, captures the consumed 10460 * FOR + aggregation-arg columns, and emits output-column NAMES with empty 10461 * sources (lineage deferred to a later sub-slice). 10462 */ 10463 private static StatementGraph buildPivotSelect(TSelectSqlStatement select, 10464 NameBindingProvider provider, 10465 String name) { 10466 TTable pivotTable = select.joins.getJoin(0).getTable(); 10467 TPivotedTable pivoted = pivotTable.getPivotedTable(); 10468 10469 // 1. single pivot clause only (chained PIVOTs deferred). 10470 if (pivoted.getPivotClauseList() != null 10471 && pivoted.getPivotClauseList().size() > 1) { 10472 // Slice 148 — name the ACTUAL chained operator(s) (all PIVOT, 10473 // all UNPIVOT, or a genuine mix), completing the message-operator 10474 // consistency begun on the JOIN-deferral (slice 146) and 10475 // extra-clause-deferral (slice 147) paths. 10476 throw new SemanticIRBuildException( 10477 Diagnostic.error(DiagnosticCode.PIVOT_MULTIPLE_CLAUSES_NOT_SUPPORTED, 10478 "chained " + chainedPivotOperatorKeyword(pivoted) 10479 + " clauses are not supported yet", pivotTable)); 10480 } 10481 TPivotClause pc = pivoted.getPivotClause(); 10482 if (pc == null) { 10483 throw new SemanticIRBuildException( 10484 Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED, 10485 "could not bind PIVOT source (no pivot clause)", pivotTable)); 10486 } 10487 // 1b. Slice 150 — Oracle PIVOT XML is a fundamentally different shape: 10488 // it produces a SINGLE XMLType output column (an XML aggregate of 10489 // all pivoted values), NOT one column per IN-list value. Treating 10490 // it as a regular PIVOT (the only path below) would emit incorrect 10491 // output-column lineage, so defer it with the generic 10492 // PIVOT/UNPIVOT-unsupported code. Checked here — before the 10493 // UNPIVOT-shape, extra-clause, and bare-* handling — so the XML 10494 // limitation is the reason reported regardless of the other clauses. 10495 if (isPivotXml(pc)) { 10496 throw new SemanticIRBuildException( 10497 Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED, 10498 "PIVOT XML is not supported yet (the single XMLType output " 10499 + "column has different lineage than a regular PIVOT)", 10500 pivotTable)); 10501 } 10502 // 2. UNPIVOT sub-slice (c): the deterministic single-value-column / 10503 // simple-IN-list shape is now supported (see buildUnpivotOutputColumns). 10504 // A multi-value-column or column-group ((a,b) ...) UNPIVOT stays 10505 // deferred — checked here so PIVOT_UNPIVOT_NOT_SUPPORTED stays reached 10506 // and the harder positional mapping is not mis-attributed. 10507 boolean isUnpivot = pc.getType() != TPivotClause.pivot; 10508 if (isUnpivot) { 10509 rejectUnsupportedUnpivotShape(pc, pivotTable); 10510 } 10511 // 3. extra query clauses deferred (WHERE / GROUP BY / HAVING / 10512 // ORDER BY / QUALIFY / DISTINCT / row-limit / set-op). Processing 10513 // them would hit the same resolver NOT_FOUND quirk that forces the 10514 // direct consumed-ref construction below. 10515 rejectPivotExtraClauses(select, pivotTable); 10516 // 4. source must be a base table (subquery source deferred). 10517 if (pivoted.getRelations().isEmpty() || pivoted.getRelations().get(0) == null) { 10518 throw new SemanticIRBuildException( 10519 Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED, 10520 "could not bind PIVOT source (no source relation)", pivotTable)); 10521 } 10522 TTable sourceTable = pivoted.getRelations().get(0); 10523 // Slice 136: a FROM-subquery source is admitted for EXPLICIT projections. 10524 // The inner SELECT was already extracted as its own statement by the 10525 // processDirectSubqueryTable pre-pass (which unwraps the pivoted_table); 10526 // binding here with allowFromSubqueries=true yields a SUBQUERY-kind 10527 // relation whose alias matches the registered subquery-statement alias, so 10528 // emitLineageForStatement wires the pivot output → subquery output edges. 10529 // Slice 137: a bare SELECT * over a subquery source is now ALSO admitted — 10530 // the slice-137 addRelationToInScopeMap unwrap registers the subquery's 10531 // published columns under the source alias, so expandPivot/UnpivotBareStar 10532 // can synthesise the passthrough schema without an external catalog. 10533 RelationSource sourceRel = buildRelation(sourceTable, provider, /*allowFromSubqueries=*/ true); 10534 10535 // 4b. Slice 156 (PIVOT) / slice 157 (UNPIVOT) — a WHERE clause over a 10536 // PIVOT or UNPIVOT is now admitted when every ref is a PROVABLE 10537 // passthrough source column (resolved directly to the underlying 10538 // source column); anything else (a pivot-output / UNPIVOT value or 10539 // FOR-name / consumed / non-provable ref, or a subquery predicate) 10540 // keeps the slice-129 deferral. Resolved BEFORE output building so the 10541 // WHERE deferral wins over a (deferred) output shape, mirroring the 10542 // previous rejectPivotExtraClauses precedence. 10543 List<ColumnRef> filterRefs = buildPivotWhereColumnRefs(select, pc, isUnpivot, 10544 sourceRel.getAlias(), sourceTable, provider, pivotTable); 10545 10546 // 4c. Slice 158 — a GROUP BY clause over a PIVOT or UNPIVOT is now 10547 // admitted (into groupByColumnRefs) when every grouped column is a 10548 // PROVABLE passthrough source column, the sibling of the slice-156/157 10549 // WHERE admission. Resolved AFTER the WHERE so a WHERE deferral wins 10550 // over a GROUP BY deferral (preserving the previous 10551 // rejectPivotExtraClauses precedence, which checked WHERE before 10552 // GROUP BY). A GROUP BY paired with a HAVING already deferred earlier 10553 // in rejectPivotExtraClauses (HAVING branch), so it never reaches here. 10554 List<ColumnRef> groupByRefs = buildPivotGroupByColumnRefs(select, pc, isUnpivot, 10555 sourceRel.getAlias(), sourceTable, provider, pivotTable); 10556 10557 // 4d. Slice 160 — a HAVING clause over a PIVOT or UNPIVOT is now admitted 10558 // (into havingColumnRefs) when every column the HAVING touches is a 10559 // PROVABLE passthrough source column, the sibling of the slice-156/157 10560 // WHERE, slice-158 GROUP BY, and slice-159 QUALIFY admissions. Resolved 10561 // AFTER the WHERE and GROUP BY and BEFORE the QUALIFY — the natural SQL 10562 // clause order (WHERE, GROUP BY, HAVING, QUALIFY) — so a deferred WHERE 10563 // / GROUP BY wins over a deferred HAVING, and a deferred HAVING wins 10564 // over a deferred QUALIFY. 10565 List<ColumnRef> havingRefs = buildPivotHavingColumnRefs(select, pc, isUnpivot, 10566 sourceRel.getAlias(), sourceTable, provider, pivotTable); 10567 10568 // 4e. Slice 159 — a QUALIFY clause over a PIVOT or UNPIVOT is now admitted 10569 // (into qualifyColumnRefs) when every column the QUALIFY touches is a 10570 // PROVABLE passthrough source column, the sibling of the slice-156/157 10571 // WHERE, slice-158 GROUP BY, and slice-160 HAVING admissions. Resolved 10572 // AFTER the WHERE, GROUP BY, and HAVING so any earlier-clause deferral 10573 // wins — the natural SQL clause order (WHERE, GROUP BY, HAVING, 10574 // QUALIFY). 10575 List<ColumnRef> qualifyRefs = buildPivotQualifyColumnRefs(select, pc, isUnpivot, 10576 sourceRel.getAlias(), sourceTable, provider, pivotTable); 10577 10578 // 4f. Slice 161 - an ORDER BY clause over a PIVOT or UNPIVOT is already 10579 // admitted (slice 142, lineage-neutral); this records its 10580 // orderByColumnRefs when every sort-key column is a PROVABLE passthrough 10581 // source column, the sibling of the slice-156/157 WHERE, slice-158 GROUP 10582 // BY, slice-159 QUALIFY, and slice-160 HAVING admissions. UNLIKE those, 10583 // it NEVER defers: a non-passthrough / unprovable / subquery sort key 10584 // simply leaves the slot empty (the lineage-neutral slice-142 behaviour). 10585 List<ColumnRef> orderByRefs = buildPivotOrderByColumnRefs(select, pc, isUnpivot, 10586 sourceRel.getAlias(), sourceTable, provider); 10587 10588 // 5. + 6. consumed refs and output-column lineage, branched on PIVOT 10589 // vs UNPIVOT. PIVOT consumes the FOR + aggregation-arg columns and 10590 // its outputs are the passthrough + pivot-output columns; UNPIVOT 10591 // consumes the IN-list source columns and its outputs are the 10592 // passthrough + value + FOR columns (slice 131 sub-slice (c)). 10593 List<ColumnRef> consumedRefs; 10594 List<OutputColumn> outputs; 10595 if (isUnpivot) { 10596 consumedRefs = collectUnpivotInListRefs(pc, sourceRel.getAlias()); 10597 outputs = buildUnpivotOutputColumns(select, pivotTable, pc, 10598 sourceRel.getAlias(), sourceTable, provider); 10599 } else { 10600 consumedRefs = collectPivotConsumedRefs(pc, sourceRel.getAlias()); 10601 outputs = buildPivotOutputColumns(select, pivotTable, pc, 10602 sourceRel.getAlias(), sourceTable, provider); 10603 } 10604 10605 // Slice 180 (R5): PIVOT/UNPIVOT SELECT blocks are built here, not at 10606 // the main SELECT site, so set their block span too. 10607 return new StatementGraph(name, "SELECT", 10608 Collections.singletonList(sourceRel), outputs, filterRefs, groupByRefs, 10609 havingRefs, qualifyRefs, orderByRefs, consumedRefs) 10610 .withSourceSpan(SourceSpan.of(select)); 10611 } 10612 10613 /** 10614 * Slice 129 — reject a PIVOT SELECT that carries a COLUMN-TOUCHING query 10615 * clause beyond the projection and the FROM-PIVOT source. One 10616 * {@code DiagnosticCode} with clause-discriminated message text (the 10617 * deferral is the same semantic boundary: "PIVOT combined with an 10618 * additional query clause is not supported yet"). Deferred because 10619 * resolving a {@code WHERE} / {@code GROUP BY} / {@code HAVING} / 10620 * {@code QUALIFY} column reference needs to disambiguate a passthrough 10621 * SOURCE column from a PIVOT-OUTPUT column. 10622 * 10623 * <p>Slice 142 — the ROW-SHAPING clauses ({@code ORDER BY}, {@code DISTINCT}, 10624 * and the row-limit clauses {@code TOP} / {@code LIMIT} / {@code OFFSET} / 10625 * {@code FETCH FIRST}) are NO LONGER rejected: they only re-shape the row 10626 * SET (order / dedup / truncate) and do not change the output COLUMN set or 10627 * the column lineage, which {@link #buildPivotSelect} reads only from the 10628 * pivot clause, the projection, and the source. Admitting them is therefore 10629 * lineage-neutral — the resulting {@code StatementGraph} is byte-identical 10630 * to the same query without the clause. The (defensive) set-operator branch 10631 * stays: a pivot SELECT node never carries a set operator in practice 10632 * ({@link #isPivotSelect} requires a FROM-PIVOT, which a union node has not), 10633 * but the guard is kept for robustness. 10634 */ 10635 private static void rejectPivotExtraClauses(TSelectSqlStatement select, TTable pivotTable) { 10636 String clause = null; 10637 // Slice 156 — the WHERE clause is no longer rejected here; it is handled 10638 // by buildPivotWhereColumnRefs (called after the source is bound), which 10639 // ADMITS a WHERE whose every ref is a provable passthrough source column 10640 // and otherwise throws the same PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED 10641 // deferral. Slice 158 — likewise the GROUP BY clause is now handled by 10642 // buildPivotGroupByColumnRefs (passthrough-only admission). Slice 159 — 10643 // likewise the QUALIFY clause is now handled by buildPivotQualifyColumnRefs 10644 // (passthrough-only admission). Slice 160 — likewise the HAVING clause is 10645 // now handled by buildPivotHavingColumnRefs (passthrough-only admission). 10646 // The only remaining column-touching clause that still defers 10647 // unconditionally here is a set operator (a pivot SELECT node never carries 10648 // one in practice, but the guard is kept defensively). The WHERE / GROUP BY 10649 // / HAVING / QUALIFY admission helpers run after this guard, in natural SQL 10650 // clause order, so a deferred earlier clause wins over a deferred later one. 10651 if (select.getSetOperatorType() != ESetOperatorType.none) { 10652 clause = "a set operator"; 10653 } 10654 if (clause != null) { 10655 // Slice 147 — name the ACTUAL operator (PIVOT vs UNPIVOT) via the 10656 // shared slice-146 helper, completing the message-operator 10657 // consistency begun on the JOIN-deferral path. 10658 throw new SemanticIRBuildException( 10659 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10660 pivotOperatorKeyword(pivotTable) + " combined with " + clause 10661 + " is not supported yet", pivotTable)); 10662 } 10663 } 10664 10665 /** 10666 * Slice 156 (PIVOT) / slice 157 (UNPIVOT) — resolve the column refs of a 10667 * {@code WHERE} clause over a PIVOT or UNPIVOT, admitting the clause only when 10668 * EVERY referenced column is a PROVABLE passthrough source column. Returns the 10669 * resolved refs (document order, deduped) for the {@code StatementGraph} 10670 * filterColumnRefs slot, or an empty list when there is no WHERE. 10671 * 10672 * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the 10673 * slice-129 deferral, code kept reached) when the WHERE cannot be admitted: 10674 * the WHERE contains a subquery predicate; or any ref is NOT a provable 10675 * passthrough column. For a PIVOT, a non-passthrough ref is a pivot-output 10676 * column, a consumed FOR / aggregation-arg column, or a column whose source 10677 * membership cannot be proven without a catalog. For an UNPIVOT (slice 157), 10678 * it is the synthesised VALUE or FOR/name output column, a consumed IN-list 10679 * source column (narrowed into rows), or — again — a column with no provable 10680 * source membership. A passthrough column passes through the operator 10681 * unchanged, so its WHERE ref resolves directly to the underlying source 10682 * column ({@code sourceAlias.col}) regardless of the aggregation count or 10683 * IN-list shape — the same provable-membership doctrine as slice 153's 10684 * passthrough output columns. The {@code dbObjectType == column} filter 10685 * excludes function names and other non-column tokens. 10686 */ 10687 private static List<ColumnRef> buildPivotWhereColumnRefs(TSelectSqlStatement select, 10688 TPivotClause pc, 10689 boolean isUnpivot, 10690 String sourceAlias, 10691 TTable sourceTable, 10692 NameBindingProvider provider, 10693 TTable pivotTable) { 10694 TWhereClause where = select.getWhereClause(); 10695 if (where == null || where.getCondition() == null) { 10696 return Collections.emptyList(); 10697 } 10698 // Slice 156 (PIVOT) / slice 157 (UNPIVOT) — the PROVABLE passthrough source 10699 // columns of the operator. A passthrough column survives the PIVOT/UNPIVOT 10700 // unchanged, so a WHERE touching ONLY those columns resolves directly to 10701 // the underlying source column. The set is empty when membership cannot be 10702 // proven (a base table with no catalog) — keeping the WHERE deferred. 10703 Set<String> passthroughLC = isUnpivot 10704 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10705 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10706 if (!passthroughLC.isEmpty()) { 10707 final List<TObjectName> whereCols = new ArrayList<>(); 10708 final boolean[] hasSubquery = {false}; 10709 TParseTreeVisitor v = new TParseTreeVisitor() { 10710 @Override 10711 public void preVisit(TObjectName n) { 10712 whereCols.add(n); 10713 } 10714 10715 @Override 10716 public void preVisit(TSelectSqlStatement s) { 10717 hasSubquery[0] = true; 10718 } 10719 }; 10720 where.getCondition().acceptChildren(v); 10721 if (!hasSubquery[0]) { 10722 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10723 boolean allPassthrough = true; 10724 for (TObjectName n : whereCols) { 10725 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10726 continue; 10727 } 10728 String col = n.getColumnNameOnly(); 10729 if (col == null || col.isEmpty() || "*".equals(col)) { 10730 continue; 10731 } 10732 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10733 allPassthrough = false; 10734 break; 10735 } 10736 refs.add(new ColumnRef(sourceAlias, col)); 10737 } 10738 if (allPassthrough) { 10739 return new ArrayList<>(refs); 10740 } 10741 } 10742 } 10743 throw new SemanticIRBuildException( 10744 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10745 pivotOperatorKeyword(pivotTable) + " combined with a WHERE clause " 10746 + "is not supported yet", pivotTable)); 10747 } 10748 10749 /** 10750 * Slice 158 — resolve the column refs of a {@code GROUP BY} clause over a 10751 * PIVOT or UNPIVOT, admitting the clause only when EVERY grouped column is a 10752 * PROVABLE passthrough source column. Returns the resolved refs (document 10753 * order, deduped) for the {@code StatementGraph} groupByColumnRefs slot, or an 10754 * empty list when there is no GROUP BY. This is the GROUP BY sibling of the 10755 * slice-156/157 WHERE admission ({@link #buildPivotWhereColumnRefs}) and uses 10756 * the same provable-passthrough doctrine: a passthrough column survives the 10757 * PIVOT/UNPIVOT unchanged, so grouping on it resolves directly to the 10758 * underlying source column ({@code sourceAlias.col}). 10759 * 10760 * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the 10761 * slice-129 deferral, code kept reached) when the GROUP BY cannot be admitted: 10762 * it contains a subquery; or any grouped ref is NOT a provable passthrough 10763 * column (a pivot-output column, a consumed FOR / aggregation-arg or IN-list 10764 * column, or a column whose source membership cannot be proven without a 10765 * catalog). A GROUP BY paired with a HAVING already deferred earlier in 10766 * {@link #rejectPivotExtraClauses}, so it never reaches here. The 10767 * {@code dbObjectType == column} filter excludes function names and other 10768 * non-column tokens. 10769 */ 10770 private static List<ColumnRef> buildPivotGroupByColumnRefs(TSelectSqlStatement select, 10771 TPivotClause pc, 10772 boolean isUnpivot, 10773 String sourceAlias, 10774 TTable sourceTable, 10775 NameBindingProvider provider, 10776 TTable pivotTable) { 10777 TGroupBy groupBy = select.getGroupByClause(); 10778 if (groupBy == null || groupBy.getItems() == null 10779 || groupBy.getItems().size() == 0) { 10780 return Collections.emptyList(); 10781 } 10782 // The PROVABLE passthrough source columns of the operator — the same set 10783 // the WHERE admission uses. Empty when membership cannot be proven (a base 10784 // table with no catalog), keeping the GROUP BY deferred. 10785 Set<String> passthroughLC = isUnpivot 10786 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10787 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10788 if (!passthroughLC.isEmpty()) { 10789 final List<TObjectName> groupCols = new ArrayList<>(); 10790 final boolean[] hasSubquery = {false}; 10791 TParseTreeVisitor v = new TParseTreeVisitor() { 10792 @Override 10793 public void preVisit(TObjectName n) { 10794 groupCols.add(n); 10795 } 10796 10797 @Override 10798 public void preVisit(TSelectSqlStatement s) { 10799 hasSubquery[0] = true; 10800 } 10801 }; 10802 TGroupByItemList items = groupBy.getItems(); 10803 for (int i = 0; i < items.size(); i++) { 10804 TGroupByItem item = items.getGroupByItem(i); 10805 if (item != null && item.getExpr() != null) { 10806 item.getExpr().acceptChildren(v); 10807 } 10808 } 10809 if (!hasSubquery[0]) { 10810 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10811 boolean allPassthrough = true; 10812 for (TObjectName n : groupCols) { 10813 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10814 continue; 10815 } 10816 String col = n.getColumnNameOnly(); 10817 if (col == null || col.isEmpty() || "*".equals(col)) { 10818 continue; 10819 } 10820 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10821 allPassthrough = false; 10822 break; 10823 } 10824 refs.add(new ColumnRef(sourceAlias, col)); 10825 } 10826 if (allPassthrough) { 10827 return new ArrayList<>(refs); 10828 } 10829 } 10830 } 10831 throw new SemanticIRBuildException( 10832 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10833 pivotOperatorKeyword(pivotTable) + " combined with a GROUP BY clause " 10834 + "is not supported yet", pivotTable)); 10835 } 10836 10837 /** 10838 * Slice 159 — resolve the column refs of a {@code QUALIFY} clause over a PIVOT 10839 * or UNPIVOT, admitting the clause only when EVERY column the QUALIFY touches 10840 * (its window-function partition / order columns and any other column refs) is 10841 * a PROVABLE passthrough source column. Returns the resolved refs (document 10842 * order, deduped) for the {@code StatementGraph} qualifyColumnRefs slot, or an 10843 * empty list when there is no QUALIFY. This is the QUALIFY sibling of the 10844 * slice-156/157 WHERE admission ({@link #buildPivotWhereColumnRefs}) and the 10845 * slice-158 GROUP BY admission ({@link #buildPivotGroupByColumnRefs}), using 10846 * the same provable-passthrough doctrine: a passthrough column survives the 10847 * PIVOT/UNPIVOT unchanged, so a QUALIFY touching only those columns resolves 10848 * directly to the underlying source column ({@code sourceAlias.col}). 10849 * 10850 * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the 10851 * slice-129 deferral, code kept reached) when the QUALIFY cannot be admitted: 10852 * it contains a subquery; or any ref is NOT a provable passthrough column (a 10853 * pivot-output column, an UNPIVOT VALUE / FOR column, a consumed FOR / agg-arg 10854 * or IN-list column, or a column whose source membership cannot be proven 10855 * without a catalog). A QUALIFY paired with a HAVING already deferred earlier 10856 * in {@link #rejectPivotExtraClauses}, so it never reaches here. The 10857 * {@code dbObjectType == column} filter excludes the window-function name and 10858 * other non-column tokens. 10859 */ 10860 private static List<ColumnRef> buildPivotQualifyColumnRefs(TSelectSqlStatement select, 10861 TPivotClause pc, 10862 boolean isUnpivot, 10863 String sourceAlias, 10864 TTable sourceTable, 10865 NameBindingProvider provider, 10866 TTable pivotTable) { 10867 if (select.getQualifyClause() == null 10868 || select.getQualifyClause().getSearchConditoin() == null) { 10869 return Collections.emptyList(); 10870 } 10871 TExpression qualify = select.getQualifyClause().getSearchConditoin(); 10872 // The PROVABLE passthrough source columns of the operator — the same set 10873 // the WHERE and GROUP BY admissions use. Empty when membership cannot be 10874 // proven (a base table with no catalog), keeping the QUALIFY deferred. 10875 Set<String> passthroughLC = isUnpivot 10876 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10877 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10878 if (!passthroughLC.isEmpty()) { 10879 final List<TObjectName> qualifyCols = new ArrayList<>(); 10880 final boolean[] hasSubquery = {false}; 10881 TParseTreeVisitor v = new TParseTreeVisitor() { 10882 @Override 10883 public void preVisit(TObjectName n) { 10884 qualifyCols.add(n); 10885 } 10886 10887 @Override 10888 public void preVisit(TSelectSqlStatement s) { 10889 hasSubquery[0] = true; 10890 } 10891 }; 10892 qualify.acceptChildren(v); 10893 if (!hasSubquery[0]) { 10894 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10895 boolean allPassthrough = true; 10896 for (TObjectName n : qualifyCols) { 10897 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10898 continue; 10899 } 10900 String col = n.getColumnNameOnly(); 10901 if (col == null || col.isEmpty() || "*".equals(col)) { 10902 continue; 10903 } 10904 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10905 allPassthrough = false; 10906 break; 10907 } 10908 refs.add(new ColumnRef(sourceAlias, col)); 10909 } 10910 if (allPassthrough) { 10911 return new ArrayList<>(refs); 10912 } 10913 } 10914 } 10915 throw new SemanticIRBuildException( 10916 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 10917 pivotOperatorKeyword(pivotTable) + " combined with a QUALIFY clause " 10918 + "is not supported yet", pivotTable)); 10919 } 10920 10921 /** 10922 * Slice 161 — resolve the column refs of an {@code ORDER BY} clause over a 10923 * PIVOT or UNPIVOT, recording them in the {@code orderByColumnRefs} slot only 10924 * when EVERY sort-key column is a PROVABLE passthrough source column. Returns 10925 * the resolved refs (document order, deduped) or an empty list. 10926 * 10927 * <p>UNLIKE the slice-156/157 WHERE, slice-158 GROUP BY, slice-159 QUALIFY, and 10928 * slice-160 HAVING admissions (which THROW 10929 * {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} when the clause 10930 * cannot be admitted), an ORDER BY over a pivot is ALREADY admitted (slice 142, 10931 * lineage-neutral — it only re-shapes the row order, not the output column set). 10932 * This helper therefore NEVER throws: it merely refines the lineage by recording 10933 * the passthrough refs when they can be proven, and otherwise returns an empty 10934 * list — exactly the lineage-neutral slice-142 behaviour. The slot stays empty 10935 * when: there is no ORDER BY; the source has no provable schema (a base table 10936 * with no catalog); any sort key contains a subquery; or any sort-key column is 10937 * NOT a provable passthrough column (a pivot-output / UNPIVOT-value / FOR / 10938 * consumed column, an ordinal, or a projection-alias reference). The 10939 * all-or-nothing gate mirrors the WHERE doctrine: a mixed ORDER BY (one 10940 * passthrough + one non-passthrough key) records nothing rather than partial 10941 * lineage. The {@code dbObjectType == column} filter excludes function names and 10942 * other non-column tokens; ordinals/aliases that are not source columns fail the 10943 * passthrough membership test and keep the slot empty. 10944 */ 10945 private static List<ColumnRef> buildPivotOrderByColumnRefs(TSelectSqlStatement select, 10946 TPivotClause pc, 10947 boolean isUnpivot, 10948 String sourceAlias, 10949 TTable sourceTable, 10950 NameBindingProvider provider) { 10951 TOrderBy orderBy = select.getOrderbyClause(); 10952 if (orderBy == null || orderBy.getItems() == null) { 10953 return Collections.emptyList(); 10954 } 10955 // The PROVABLE passthrough source columns of the operator — the same set the 10956 // WHERE, GROUP BY, HAVING, and QUALIFY admissions use. Empty when membership 10957 // cannot be proven (a base table with no catalog), keeping ORDER BY 10958 // lineage-neutral. 10959 Set<String> passthroughLC = isUnpivot 10960 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 10961 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 10962 if (passthroughLC.isEmpty()) { 10963 return Collections.emptyList(); 10964 } 10965 final List<TObjectName> orderByCols = new ArrayList<>(); 10966 final boolean[] hasSubquery = {false}; 10967 TParseTreeVisitor v = new TParseTreeVisitor() { 10968 @Override 10969 public void preVisit(TObjectName n) { 10970 orderByCols.add(n); 10971 } 10972 10973 @Override 10974 public void preVisit(TSelectSqlStatement s) { 10975 hasSubquery[0] = true; 10976 } 10977 }; 10978 TOrderByItemList items = orderBy.getItems(); 10979 for (int i = 0; i < items.size(); i++) { 10980 TOrderByItem item = items.getOrderByItem(i); 10981 if (item == null || item.getSortKey() == null) { 10982 continue; 10983 } 10984 item.getSortKey().acceptChildren(v); 10985 } 10986 if (hasSubquery[0]) { 10987 return Collections.emptyList(); 10988 } 10989 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 10990 for (TObjectName n : orderByCols) { 10991 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 10992 continue; 10993 } 10994 String col = n.getColumnNameOnly(); 10995 if (col == null || col.isEmpty() || "*".equals(col)) { 10996 continue; 10997 } 10998 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 10999 // Not all passthrough (a pivot-output / consumed column, or an 11000 // alias) — stay lineage-neutral (slice 142), do NOT defer. 11001 return Collections.emptyList(); 11002 } 11003 refs.add(new ColumnRef(sourceAlias, col)); 11004 } 11005 return new ArrayList<>(refs); 11006 } 11007 11008 /** 11009 * Slice 160 — resolve the column refs of a {@code HAVING} clause over a PIVOT 11010 * or UNPIVOT, admitting the clause only when EVERY column the HAVING touches 11011 * (its aggregate-function arguments and any other column refs) is a PROVABLE 11012 * passthrough source column. Returns the resolved refs (document order, 11013 * deduped) for the {@code StatementGraph} havingColumnRefs slot, or an empty 11014 * list when there is no HAVING. This is the HAVING sibling of the slice-156/157 11015 * WHERE admission ({@link #buildPivotWhereColumnRefs}), the slice-158 GROUP BY 11016 * admission ({@link #buildPivotGroupByColumnRefs}), and the slice-159 QUALIFY 11017 * admission ({@link #buildPivotQualifyColumnRefs}), using the same 11018 * provable-passthrough doctrine: a passthrough column survives the 11019 * PIVOT/UNPIVOT unchanged, so a HAVING touching only those columns resolves 11020 * directly to the underlying source column ({@code sourceAlias.col}). A 11021 * {@code HAVING COUNT(*)} touches no column at all, so it admits over a provable 11022 * source (empty refs) but still defers over a base table with no catalog (the 11023 * provable-passthrough set is empty, so membership cannot be proven). 11024 * 11025 * <p>Throws {@link DiagnosticCode#PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED} (the 11026 * slice-129 deferral, code kept reached) when the HAVING cannot be admitted: it 11027 * contains a subquery; or any ref is NOT a provable passthrough column (a 11028 * pivot-output column, an UNPIVOT VALUE / FOR column, a consumed FOR / agg-arg 11029 * or IN-list column, or a column whose source membership cannot be proven 11030 * without a catalog). The {@code dbObjectType == column} filter excludes the 11031 * aggregate-function names and other non-column tokens. 11032 */ 11033 private static List<ColumnRef> buildPivotHavingColumnRefs(TSelectSqlStatement select, 11034 TPivotClause pc, 11035 boolean isUnpivot, 11036 String sourceAlias, 11037 TTable sourceTable, 11038 NameBindingProvider provider, 11039 TTable pivotTable) { 11040 TGroupBy groupBy = select.getGroupByClause(); 11041 TExpression having = (groupBy == null) ? null : groupBy.getHavingClause(); 11042 if (having == null) { 11043 return Collections.emptyList(); 11044 } 11045 // The PROVABLE passthrough source columns of the operator — the same set 11046 // the WHERE, GROUP BY, and QUALIFY admissions use. Empty when membership 11047 // cannot be proven (a base table with no catalog), keeping the HAVING 11048 // deferred. 11049 Set<String> passthroughLC = isUnpivot 11050 ? provableUnpivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider) 11051 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 11052 if (!passthroughLC.isEmpty()) { 11053 final List<TObjectName> havingCols = new ArrayList<>(); 11054 final boolean[] hasSubquery = {false}; 11055 TParseTreeVisitor v = new TParseTreeVisitor() { 11056 @Override 11057 public void preVisit(TObjectName n) { 11058 havingCols.add(n); 11059 } 11060 11061 @Override 11062 public void preVisit(TSelectSqlStatement s) { 11063 hasSubquery[0] = true; 11064 } 11065 }; 11066 having.acceptChildren(v); 11067 if (!hasSubquery[0]) { 11068 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11069 boolean allPassthrough = true; 11070 for (TObjectName n : havingCols) { 11071 if (n == null || n.getDbObjectType() != EDbObjectType.column) { 11072 continue; 11073 } 11074 String col = n.getColumnNameOnly(); 11075 if (col == null || col.isEmpty() || "*".equals(col)) { 11076 continue; 11077 } 11078 if (!passthroughLC.contains(col.toLowerCase(Locale.ROOT))) { 11079 allPassthrough = false; 11080 break; 11081 } 11082 refs.add(new ColumnRef(sourceAlias, col)); 11083 } 11084 if (allPassthrough) { 11085 return new ArrayList<>(refs); 11086 } 11087 } 11088 } 11089 throw new SemanticIRBuildException( 11090 Diagnostic.error(DiagnosticCode.PIVOT_WITH_QUERY_CLAUSE_NOT_SUPPORTED, 11091 pivotOperatorKeyword(pivotTable) + " combined with a HAVING clause " 11092 + "is not supported yet", pivotTable)); 11093 } 11094 11095 /** 11096 * Slice 156 — the lower-cased PROVABLE passthrough (group) source column 11097 * names of a PIVOT: the source's published columns (the catalog or a 11098 * FROM-subquery's published schema) MINUS the columns consumed by the PIVOT 11099 * (the FOR / pivot column(s) and the aggregation argument(s)). Empty when the 11100 * source has no provable schema (a base table with no catalog). This is the 11101 * slice-153 passthrough rule, factored out so the WHERE-clause admission 11102 * (slice 156) and {@link #buildPivotOutputColumns}' passthrough column 11103 * resolution share one source of truth. 11104 */ 11105 private static Set<String> provablePivotPassthroughColsLC(TPivotClause pc, 11106 String sourceAlias, 11107 TTable sourceTable, 11108 NameBindingProvider provider) { 11109 List<String> srcCols = lookupRelationColumnNames(sourceTable, provider); 11110 if (srcCols == null || srcCols.isEmpty()) { 11111 return Collections.emptySet(); 11112 } 11113 Set<String> consumedLC = new HashSet<>(); 11114 List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias)); 11115 consumed.addAll(collectPivotAggArgRefs(pc, sourceAlias)); 11116 for (ColumnRef r : consumed) { 11117 String cn = (r == null) ? null : r.getColumnName(); 11118 if (cn != null && !cn.isEmpty()) { 11119 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 11120 } 11121 } 11122 Set<String> passthrough = new HashSet<>(); 11123 for (String c : srcCols) { 11124 if (c == null || c.isEmpty()) { 11125 continue; 11126 } 11127 String lc = c.toLowerCase(Locale.ROOT); 11128 if (!consumedLC.contains(lc)) { 11129 passthrough.add(lc); 11130 } 11131 } 11132 return passthrough; 11133 } 11134 11135 /** 11136 * Slice 157 — the lower-cased PROVABLE passthrough source column names of an 11137 * UNPIVOT: the source's published columns (the catalog or a FROM-subquery's 11138 * published schema) MINUS the IN-list SOURCE columns the UNPIVOT narrows into 11139 * rows ({@link #collectUnpivotInListRefs}). Empty when the source has no 11140 * provable schema (a base table with no catalog). These are exactly the 11141 * passthrough OUTPUT columns {@link #expandUnpivotBareStar} keeps unchanged; 11142 * the synthesised VALUE / FOR-name output columns are NOT source columns (by 11143 * UNPIVOT semantics they cannot name an existing input column), so they are 11144 * absent from the published set and a WHERE touching them stays deferred. This 11145 * is the UNPIVOT mirror of {@link #provablePivotPassthroughColsLC}, shared by 11146 * the WHERE-clause admission ({@link #buildPivotWhereColumnRefs}). 11147 */ 11148 private static Set<String> provableUnpivotPassthroughColsLC(TPivotClause pc, 11149 String sourceAlias, 11150 TTable sourceTable, 11151 NameBindingProvider provider) { 11152 List<String> srcCols = lookupRelationColumnNames(sourceTable, provider); 11153 if (srcCols == null || srcCols.isEmpty()) { 11154 return Collections.emptySet(); 11155 } 11156 Set<String> consumedLC = new HashSet<>(); 11157 for (ColumnRef r : collectUnpivotInListRefs(pc, sourceAlias)) { 11158 String cn = (r == null) ? null : r.getColumnName(); 11159 if (cn != null && !cn.isEmpty()) { 11160 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 11161 } 11162 } 11163 Set<String> passthrough = new HashSet<>(); 11164 for (String c : srcCols) { 11165 if (c == null || c.isEmpty()) { 11166 continue; 11167 } 11168 String lc = c.toLowerCase(Locale.ROOT); 11169 if (!consumedLC.contains(lc)) { 11170 passthrough.add(lc); 11171 } 11172 } 11173 return passthrough; 11174 } 11175 11176 /** 11177 * Slice 129 — capture the columns CONSUMED by a PIVOT: the FOR / pivot 11178 * column(s) first, then the aggregation-function argument column(s), in 11179 * document order, deduplicated by (alias, columnName). Each is built 11180 * DIRECTLY as a {@link ColumnRef} against the bound source relation — 11181 * resolver2 marks these refs NOT_FOUND in PIVOT context even though 11182 * Phase-1 {@code linkColumnToTable} sets their {@code sourceTable}, so the 11183 * strict {@code collectColumnRefs} path would reject them. 11184 */ 11185 private static List<ColumnRef> collectPivotConsumedRefs(TPivotClause pc, 11186 String sourceAlias) { 11187 // Consumed = FOR / pivot column(s) FIRST, then aggregation-arg 11188 // column(s), document order, deduped (LinkedHashSet). Slice 130 split 11189 // the two halves into reusable helpers: the agg-arg refs are also the 11190 // sources of a pivot-output column (see buildPivotOutputColumns). 11191 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11192 refs.addAll(collectPivotForRefs(pc, sourceAlias)); 11193 refs.addAll(collectPivotAggArgRefs(pc, sourceAlias)); 11194 return new ArrayList<>(refs); 11195 } 11196 11197 /** 11198 * Slice 130 — the FOR / pivot column ref(s) consumed by a PIVOT, built 11199 * directly against {@code sourceAlias} (deduped, document order). Split out 11200 * of {@link #collectPivotConsumedRefs} so the consumed-refs slot and the 11201 * output-column lineage share one source of truth. 11202 */ 11203 private static List<ColumnRef> collectPivotForRefs(TPivotClause pc, String sourceAlias) { 11204 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11205 TObjectNameList forCols = pc.getPivotColumnList(); 11206 if (forCols != null) { 11207 for (int i = 0; i < forCols.size(); i++) { 11208 addPivotConsumedRef(refs, forCols.getObjectName(i), sourceAlias); 11209 } 11210 } else if (pc.getPivotColumn() != null) { 11211 addPivotConsumedRef(refs, pc.getPivotColumn(), sourceAlias); 11212 } 11213 return new ArrayList<>(refs); 11214 } 11215 11216 /** 11217 * Slice 130 — the aggregation-function argument column ref(s) (the value 11218 * being aggregated), built directly against {@code sourceAlias} (deduped, 11219 * document order). SQL Server / Snowflake carry a single 11220 * {@code TFunctionCall}; Oracle a {@code TResultColumnList}. These are the 11221 * sources of a pivot-output column. Function names and column-alias nodes 11222 * are skipped by the {@code dbObjectType} filter in addPivotConsumedRef. 11223 */ 11224 private static List<ColumnRef> collectPivotAggArgRefs(TPivotClause pc, String sourceAlias) { 11225 final List<TObjectName> argCols = new ArrayList<>(); 11226 TParseTreeVisitor argVisitor = new TParseTreeVisitor() { 11227 @Override 11228 public void preVisit(TObjectName n) { 11229 argCols.add(n); 11230 } 11231 }; 11232 if (pc.getAggregation_function() != null 11233 && pc.getAggregation_function().getArgs() != null) { 11234 pc.getAggregation_function().getArgs().acceptChildren(argVisitor); 11235 } 11236 if (pc.getAggregation_function_list() != null) { 11237 pc.getAggregation_function_list().acceptChildren(argVisitor); 11238 } 11239 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11240 for (TObjectName n : argCols) { 11241 addPivotConsumedRef(refs, n, sourceAlias); 11242 } 11243 return new ArrayList<>(refs); 11244 } 11245 11246 /** 11247 * Slice 154 — the aggregation-argument column ref(s) for a SINGLE aggregation 11248 * in a multi-aggregation PIVOT's {@code aggregation_function_list} (the value 11249 * being aggregated by THIS one function), built directly against 11250 * {@code sourceAlias} (deduped, document order). This is the per-aggregation 11251 * counterpart of {@link #collectPivotAggArgRefs} (which unions ALL 11252 * aggregations' args): a multi-agg cross-product output column 11253 * {@code <in_value>_<agg_alias>} draws from ONLY the named aggregation's 11254 * argument, not every aggregation's. Function names / alias nodes are excluded 11255 * by the {@code dbObjectType == column} filter in {@link #addPivotConsumedRef}. 11256 */ 11257 private static List<ColumnRef> collectSingleAggArgRefs(TResultColumn aggRc, 11258 String sourceAlias) { 11259 final List<TObjectName> argCols = new ArrayList<>(); 11260 TParseTreeVisitor argVisitor = new TParseTreeVisitor() { 11261 @Override 11262 public void preVisit(TObjectName n) { 11263 argCols.add(n); 11264 } 11265 }; 11266 if (aggRc != null && aggRc.getExpr() != null 11267 && aggRc.getExpr().getFunctionCall() != null 11268 && aggRc.getExpr().getFunctionCall().getArgs() != null) { 11269 aggRc.getExpr().getFunctionCall().getArgs().acceptChildren(argVisitor); 11270 } 11271 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11272 for (TObjectName n : argCols) { 11273 addPivotConsumedRef(refs, n, sourceAlias); 11274 } 11275 return new ArrayList<>(refs); 11276 } 11277 11278 /** Slice 130 — number of aggregation functions in the PIVOT clause. */ 11279 private static int pivotAggregationCount(TPivotClause pc) { 11280 if (pc.getAggregation_function() != null) { 11281 return 1; 11282 } 11283 if (pc.getAggregation_function_list() != null) { 11284 return pc.getAggregation_function_list().size(); 11285 } 11286 return 0; 11287 } 11288 11289 /** 11290 * Slice 130 — true when the PIVOT IN-list is a STATIC value list, so the 11291 * pivot-output column names are known at build time. False for a subquery 11292 * or {@code ANY} dynamic IN-list (the parser leaves {@code getItems()} 11293 * null or empty), where output lineage stays deferred. 11294 */ 11295 private static boolean hasStaticInList(TPivotClause pc) { 11296 return pc.getPivotInClause() != null 11297 && pc.getPivotInClause().getItems() != null 11298 && pc.getPivotInClause().getItems().size() > 0; 11299 } 11300 11301 /** 11302 * Slice 130 — the set of pivot-output column names: the IN-list items' 11303 * display names ({@code [2006]} for SQL Server bracket values, the IN-list 11304 * alias such as {@code pa} / {@code jan} for Oracle / Snowflake). A 11305 * projected column whose underlying column name 11306 * ({@link TResultColumn#getColumnNameOnly()}) is in this set is a pivot 11307 * output (sourced from the aggregation argument); any other simple-column 11308 * projection is a passthrough (group) column. Caller must have confirmed a 11309 * static IN-list via {@link #hasStaticInList}. 11310 * 11311 * <p>Returns a {@link LinkedHashSet} so the names are in IN-list DOCUMENT 11312 * ORDER. Slice 130 only used this for {@code contains()} membership, but 11313 * slice 133's {@code SELECT *} expansion ({@link #expandPivotBareStar}) 11314 * relies on the ordered iteration to emit pivot-output columns in IN-list 11315 * order — so the ordered return type is part of the contract. 11316 */ 11317 private static LinkedHashSet<String> collectPivotOutputNames(TPivotClause pc) { 11318 LinkedHashSet<String> names = new LinkedHashSet<>(); 11319 TPivotInClause in = pc.getPivotInClause(); 11320 if (in != null && in.getItems() != null) { 11321 for (int i = 0; i < in.getItems().size(); i++) { 11322 TResultColumn item = in.getItems().getResultColumn(i); 11323 if (item == null) { 11324 continue; 11325 } 11326 String dn = item.getDisplayName(); 11327 if (dn != null && !dn.isEmpty()) { 11328 names.add(dn); 11329 } 11330 } 11331 } 11332 return names; 11333 } 11334 11335 /** 11336 * Slice 129 helper — add one consumed column ref to {@code refs} iff it is 11337 * an actual column (not a function name, column alias, or {@code *}). The 11338 * ref is attributed DIRECTLY to {@code sourceAlias}. 11339 * 11340 * <p>No source-table match guard: sub-slice (a) admits only a single 11341 * base-table PIVOT source, so every column consumed by the FOR clause or 11342 * an aggregation argument necessarily belongs to that one source relation 11343 * — there is no other relation to mis-attribute to, and resolver2 leaves 11344 * these refs NOT_FOUND in PIVOT context anyway. Guarding on 11345 * {@code sourceTable} identity / name would SILENTLY DROP a valid consumed 11346 * column when Phase-1 left {@code sourceTable} null or shaped its name 11347 * differently (alias-normalized / schema-qualified) — codex diff-review 11348 * round 1 BLOCKING. The {@code dbObjectType == column} filter already 11349 * excludes function names and column-alias nodes. 11350 */ 11351 private static void addPivotConsumedRef(LinkedHashSet<ColumnRef> refs, 11352 TObjectName ref, 11353 String sourceAlias) { 11354 if (ref == null || ref.getDbObjectType() != EDbObjectType.column) { 11355 return; 11356 } 11357 String col = ref.getColumnNameOnly(); 11358 if (col == null || col.isEmpty() || "*".equals(col)) { 11359 return; 11360 } 11361 refs.add(new ColumnRef(sourceAlias, col)); 11362 } 11363 11364 /** 11365 * Slice 129 / 130 — build output columns for a PIVOT SELECT. Each projected 11366 * result column yields one OutputColumn whose name is the projection's 11367 * effective name. A {@code SELECT *} / {@code t.*} over a PIVOT is deferred 11368 * (it needs the full synthesised output schema / a catalog). 11369 * 11370 * <p>Slice 130 sub-slice (b1) attaches output-column LINEAGE when the 11371 * output schema maps deterministically to source columns — i.e. a SINGLE 11372 * aggregation over a STATIC IN-list ({@code lineageEnabled}): 11373 * <ul> 11374 * <li><b>pivot-output column</b> — the projected column's underlying name 11375 * ({@link TResultColumn#getColumnNameOnly()}) is one of the IN-list 11376 * display names. It is the aggregate sliced by the FOR value, so 11377 * {@code derived=true}, {@code aggregate=true}, and sources are the 11378 * aggregation-argument column(s).</li> 11379 * <li><b>passthrough (group) column</b> — any other simple-column 11380 * projection. It is a direct reference to the source column, so 11381 * {@code derived=false}, {@code aggregate=false}, and the single 11382 * source is {@code sourceAlias.getColumnNameOnly()}. The output NAME 11383 * may be aliased ({@code vendorid AS v}) but the SOURCE column is the 11384 * underlying name.</li> 11385 * </ul> 11386 * Multi-aggregation (vendor-specific cross-product output names like 11387 * {@code pa_s}), a dynamic / subquery IN-list, and expression projections 11388 * keep the slice-129 NAME-only skeleton ({@code derived=true}, 11389 * {@code aggregate=false}, EMPTY sources) — no false lineage. 11390 * 11391 * <p>All refs are built DIRECTLY against {@code sourceAlias}: sub-slice (a) 11392 * admits only a single base-table source, so every passthrough / agg-arg 11393 * column belongs to that one relation, and resolver2 marks them NOT_FOUND 11394 * in PIVOT context anyway (slice 129). 11395 */ 11396 private static List<OutputColumn> buildPivotOutputColumns(TSelectSqlStatement select, 11397 TTable pivotTable, 11398 TPivotClause pc, 11399 String sourceAlias, 11400 TTable sourceTable, 11401 NameBindingProvider provider) { 11402 TResultColumnList rcl = select.getResultColumnList(); 11403 if (rcl == null || rcl.size() == 0) { 11404 throw new SemanticIRBuildException( 11405 Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS, 11406 "SELECT has no projected columns", select)); 11407 } 11408 boolean lineageEnabled = pivotAggregationCount(pc) == 1 && hasStaticInList(pc); 11409 Set<String> pivotOutputNames = lineageEnabled 11410 ? collectPivotOutputNames(pc) : Collections.<String>emptySet(); 11411 List<ColumnRef> aggArgRefs = lineageEnabled 11412 ? collectPivotAggArgRefs(pc, sourceAlias) : Collections.<ColumnRef>emptyList(); 11413 11414 // Slice 153 — a NON-lineage-enabled PIVOT (a multi-aggregation PIVOT or a 11415 // dynamic / subquery IN-list) still has determinable PASSTHROUGH (group) 11416 // column lineage: a passthrough column passes through UNCHANGED from the 11417 // source regardless of the aggregation count or IN-list shape. Slice 130 11418 // emitted every projected column of such a PIVOT as a NAME-only skeleton 11419 // (empty sources); this attaches the source lineage to the passthrough 11420 // columns only. The set below is the lower-cased SOURCE column names that 11421 // are NOT consumed by the PIVOT (the FOR / pivot column(s) and the 11422 // aggregation-argument column(s)); a projected simple-column whose name is 11423 // in it is a passthrough. This is keyed off PROVABLE source membership 11424 // (the source catalog or a FROM-subquery's published columns) so it never 11425 // invents a false source for a vendor-specific cross-product output column 11426 // (e.g. `y6_s`) — those names are not source columns. Without a source 11427 // catalog (a base table and no Catalog) the set is empty, so the behaviour 11428 // is unchanged. (The lineage-enabled single-agg path below keys off the 11429 // KNOWN IN-list output-name set instead — for multi-agg those names are 11430 // vendor-specific cross-products we do not enumerate, hence the 11431 // catalog-confirmation rule here.) 11432 // Slice 156 — factored into provablePivotPassthroughColsLC (shared with 11433 // the WHERE-clause admission). Computed only for a non-lineage-enabled 11434 // PIVOT: the lineage-enabled single-agg path below keys off the KNOWN 11435 // IN-list output-name set instead. 11436 Set<String> passthroughSourceColsLC = lineageEnabled 11437 ? Collections.<String>emptySet() 11438 : provablePivotPassthroughColsLC(pc, sourceAlias, sourceTable, provider); 11439 11440 // Slice 154 — a MULTI-aggregation PIVOT over a STATIC IN-list produces one 11441 // CROSS-PRODUCT output column per (IN-value × aggregation), named 11442 // `<in_value_display_name>_<agg_alias>` (`y6_s`, `y6_a`). Slice 130 / 153 11443 // left these as NAME-only skeletons because the names are vendor-specific 11444 // and were not enumerated. Build the map `<in_value>_<agg> -> that one 11445 // aggregation's argument column ref(s)` so a projected cross-product column 11446 // carries the SPECIFIC aggregation's lineage (NOT the union of all agg 11447 // args). Unlike the slice-153 passthrough rule, this needs NO source 11448 // catalog: the IN-value names and each aggregation's argument are fully 11449 // determined by the PIVOT clause itself (so a base table without a catalog 11450 // resolves the same as a subquery source — mirroring the lineage-enabled 11451 // single-agg path, which also reads the agg arg straight from the clause). 11452 // Keys are lower-cased (Locale.ROOT) to match the project's identifier 11453 // folding. A key produced by two (IN-value, agg) pairs with DIFFERENT 11454 // sources is AMBIGUOUS (e.g. degenerate names whose `_` concatenation 11455 // collides) and is dropped so it never attaches a wrong source. 11456 Map<String, List<ColumnRef>> crossProductSourcesLC = Collections.emptyMap(); 11457 Set<String> ambiguousCrossProductLC = Collections.emptySet(); 11458 if (!lineageEnabled && hasStaticInList(pc) 11459 && pc.getAggregation_function_list() != null 11460 && pc.getAggregation_function_list().size() > 1) { 11461 Map<String, List<ColumnRef>> xpMap = new HashMap<>(); 11462 Set<String> xpAmbiguous = new HashSet<>(); 11463 TResultColumnList aggList = pc.getAggregation_function_list(); 11464 for (String inVal : collectPivotOutputNames(pc)) { 11465 if (inVal == null || inVal.isEmpty()) { 11466 continue; 11467 } 11468 for (int ai = 0; ai < aggList.size(); ai++) { 11469 TResultColumn aggRc = aggList.getResultColumn(ai); 11470 if (aggRc == null) { 11471 continue; 11472 } 11473 String aggAlias = aggRc.getDisplayName(); 11474 if (aggAlias == null || aggAlias.isEmpty()) { 11475 continue; 11476 } 11477 String key = (inVal + "_" + aggAlias).toLowerCase(Locale.ROOT); 11478 List<ColumnRef> argRefs = collectSingleAggArgRefs(aggRc, sourceAlias); 11479 if (xpMap.containsKey(key)) { 11480 if (!xpMap.get(key).equals(argRefs)) { 11481 xpAmbiguous.add(key); 11482 } 11483 } else { 11484 xpMap.put(key, argRefs); 11485 } 11486 } 11487 } 11488 crossProductSourcesLC = xpMap; 11489 ambiguousCrossProductLC = xpAmbiguous; 11490 } 11491 11492 // Slice 133 (sub-slice (b2-pivot)) — a SOLE `SELECT *` over a 11493 // lineage-enabled PIVOT is expanded from the source table's catalog 11494 // column list into the synthesised output schema (group / passthrough 11495 // columns then pivot-output columns). A `*` mixed with explicit columns 11496 // and the non-lineage-enabled (multi-agg / dynamic-IN) `*` stay deferred 11497 // via the per-column reject below. 11498 // 11499 // Slice 151 — the SOLE star is now admitted whether it is BARE (`*`) or 11500 // QUALIFIED (`p.*` / `src.*`). An admitted PIVOT SELECT has exactly ONE 11501 // relation in scope — the pivot output (isPivotSelect requires a 11502 // FROM-PIVOT with no JOIN) — so a qualified star can only mean that one 11503 // relation and is therefore equivalent to a bare `*`; expandPivotBareStar 11504 // synthesises the identical schema (it never reads the star's qualifier, 11505 // only the source columns and the pivot clause). Slice 152 dropped the 11506 // mirror gate on the UNPIVOT bare-star path below. 11507 if (lineageEnabled && rcl.size() == 1) { 11508 TResultColumn only = rcl.getResultColumn(0); 11509 if ("*".equals(only.getColumnNameOnly())) { 11510 // Slice 137 lifted the slice-136 subquery-source deferral. A bare 11511 // SELECT * over a FROM-subquery PIVOT now expands the same way as a 11512 // base-table PIVOT: expandPivotBareStar reads the source's published 11513 // columns via lookupRelationColumnNames, which the slice-137 11514 // addRelationToInScopeMap unwrap registers under the subquery alias. 11515 // No catalog is required for a subquery source — the subquery's 11516 // explicit projection IS the published schema. 11517 return expandPivotBareStar(pc, sourceTable, sourceAlias, provider, 11518 aggArgRefs, only); 11519 } 11520 } 11521 11522 // Slice 155 — admit a SOLE bare/qualified `*` over a MULTI-aggregation 11523 // static-IN PIVOT. Slice 130 / 133 deferred this (PIVOT_STAR_NOT_SUPPORTED) 11524 // because the cross-product output names were not enumerated; slice 154 11525 // enumerated them (`<in_value>_<agg_alias>` from the IN-list display names 11526 // and each aggregation's alias), so the full output schema is now 11527 // synthesisable: the passthrough (group) columns followed by the 11528 // cross-product output columns. (A dynamic / subquery IN-list multi-agg 11529 // bare `*` keeps the deferral via the per-column reject below — its 11530 // cross-product names are unknown. A single-agg static-IN bare `*` is 11531 // handled by the lineage-enabled block above.) 11532 if (!lineageEnabled && hasStaticInList(pc) 11533 && pc.getAggregation_function_list() != null 11534 && pc.getAggregation_function_list().size() > 1 11535 && rcl.size() == 1) { 11536 TResultColumn only = rcl.getResultColumn(0); 11537 if ("*".equals(only.getColumnNameOnly())) { 11538 return expandPivotMultiAggBareStar(pc, sourceTable, sourceAlias, 11539 provider, only); 11540 } 11541 } 11542 11543 List<OutputColumn> outs = new ArrayList<>(); 11544 for (int i = 0; i < rcl.size(); i++) { 11545 TResultColumn rc = rcl.getResultColumn(i); 11546 if ("*".equals(rc.getColumnNameOnly())) { 11547 throw new SemanticIRBuildException( 11548 Diagnostic.error(DiagnosticCode.PIVOT_STAR_NOT_SUPPORTED, 11549 "SELECT * over a PIVOT is not supported yet " 11550 + "(the synthesised output schema is deferred); " 11551 + "project explicit columns", rc)); 11552 } 11553 // effectiveOutputName throws RESULT_COLUMN_NO_NAME for an 11554 // unaliased non-name expression — acceptable deferral. 11555 String outName = effectiveOutputName(rc); 11556 if (!lineageEnabled) { 11557 // A non-lineage-enabled PIVOT (multi-agg, or dynamic / subquery 11558 // IN-list). A simple-column projection can be one of two resolvable 11559 // shapes; everything else (expressions) keeps the skeleton. 11560 String colName = rc.getColumnNameOnly(); 11561 boolean simpleCol = colName != null && !colName.isEmpty() 11562 && rc.getExpr() != null 11563 && rc.getExpr().getExpressionType() 11564 == EExpressionType.simple_object_name_t; 11565 String colNameLC = simpleCol ? colName.toLowerCase(Locale.ROOT) : null; 11566 if (simpleCol && passthroughSourceColsLC.contains(colNameLC)) { 11567 // Slice 153 — passthrough (group) column: a simple source 11568 // column proven NOT consumed by the PIVOT, passes through 11569 // unchanged. (Disjoint from a cross-product name: PIVOT 11570 // semantics forbid an IN-value naming an existing source 11571 // column, so `<in>_<agg>` is never a source column.) 11572 outs.add(new OutputColumn(outName, /*derived=*/ false, 11573 /*aggregate=*/ false, 11574 Collections.singletonList(new ColumnRef(sourceAlias, colName)), 11575 /*windowSpec=*/ null)); 11576 } else if (simpleCol && crossProductSourcesLC.containsKey(colNameLC) 11577 && !ambiguousCrossProductLC.contains(colNameLC)) { 11578 // Slice 154 — multi-agg cross-product pivot-output column: 11579 // the aggregate of its SPECIFIC aggregation sliced by the FOR 11580 // value (derived + aggregate, sourced from that one 11581 // aggregation's argument column(s)). 11582 outs.add(new OutputColumn(outName, /*derived=*/ true, 11583 /*aggregate=*/ true, 11584 crossProductSourcesLC.get(colNameLC), 11585 /*windowSpec=*/ null)); 11586 } else { 11587 outs.add(pivotSkeletonOutput(outName)); 11588 } 11589 continue; 11590 } 11591 String colOnly = rc.getColumnNameOnly(); 11592 // Name-collision is a non-issue here: PIVOT semantics forbid an 11593 // IN-list value from naming a column that already exists in the 11594 // input source (see TPivotClause), so an IN-list display name can 11595 // never coincide with a real passthrough source column. 11596 if (colOnly != null && !colOnly.isEmpty() && pivotOutputNames.contains(colOnly)) { 11597 // Pivot-output column: aggregate over the value column, sliced 11598 // by the FOR value. Sources = aggregation-arg column(s). 11599 outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ true, 11600 aggArgRefs, /*windowSpec=*/ null)); 11601 } else if (colOnly != null && !colOnly.isEmpty() 11602 && rc.getExpr() != null 11603 && rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t) { 11604 // Passthrough (group) column: direct reference to the source 11605 // column (the underlying name, not the output alias). 11606 outs.add(new OutputColumn(outName, /*derived=*/ false, /*aggregate=*/ false, 11607 Collections.singletonList(new ColumnRef(sourceAlias, colOnly)), 11608 /*windowSpec=*/ null)); 11609 } else { 11610 // Expression projection (or no underlying column) — keep skeleton. 11611 outs.add(pivotSkeletonOutput(outName)); 11612 } 11613 } 11614 return outs; 11615 } 11616 11617 /** 11618 * Slice 133 (sub-slice (b2-pivot)) — synthesise the full output schema for a 11619 * bare {@code SELECT *} over a lineage-enabled (single-agg / static-IN) 11620 * PIVOT, using the source table's catalog column list: 11621 * <ol> 11622 * <li><b>group / passthrough columns</b> — every catalog column that is 11623 * NOT consumed by the PIVOT (i.e. not the FOR / pivot column(s) and not 11624 * an aggregation-argument column), in CATALOG declaration order. Each is 11625 * a direct source reference ({@code derived=false}, 11626 * {@code aggregate=false}, single source {@code sourceAlias.col}).</li> 11627 * <li><b>pivot-output columns</b> — one per IN-list value 11628 * ({@link #collectPivotOutputNames}, IN-list order). Each is the 11629 * aggregate sliced by the FOR value ({@code derived=true}, 11630 * {@code aggregate=true}, sources = the aggregation-argument column(s)).</li> 11631 * </ol> 11632 * This is the SQL-standard {@code SELECT *} PIVOT shape (SQL Server / Oracle / 11633 * Snowflake): the implicit GROUP BY columns first, then the generated pivot 11634 * columns. (There is no dlineage {@code SELECT *} golden to match: dlineage 11635 * cannot expand {@code *} without a catalog either.) 11636 * 11637 * <p>The consumed-column subtraction folds names with 11638 * {@code toLowerCase(Locale.ROOT)} — the SAME identifier canonicalisation the 11639 * other semantic-IR catalog matchers use ({@link #expandBareStarOverUsing}, 11640 * {@link #lookupRelationColumnNames}); so it is aligned with the project's 11641 * catalog name folding. (A degenerate catalog holding two columns that differ 11642 * only by case would have both excluded by a single consumed name — an 11643 * inherently ambiguous catalog, not a realistic PIVOT source.) 11644 * 11645 * <p>Throws {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED} when no catalog 11646 * column list is available (mirrors slice-90 11647 * {@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED}). 11648 */ 11649 private static List<OutputColumn> expandPivotBareStar(TPivotClause pc, 11650 TTable sourceTable, 11651 String sourceAlias, 11652 NameBindingProvider provider, 11653 List<ColumnRef> aggArgRefs, 11654 TResultColumn star) { 11655 List<String> cols = lookupRelationColumnNames(sourceTable, provider); 11656 if (cols == null || cols.isEmpty()) { 11657 throw new SemanticIRBuildException( 11658 Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED, 11659 "SELECT * over a PIVOT requires catalog metadata for source '" 11660 + sourceAlias + "' to expand the implicit group columns; " 11661 + "supply a Catalog via " 11662 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star)); 11663 } 11664 // Columns consumed by the PIVOT (FOR / pivot column(s) + aggregation-arg 11665 // column(s)); these are NOT passthrough group columns. Folded to lower 11666 // case (Locale.ROOT) to match the catalog identifier canonicalisation 11667 // used by the other semantic-IR catalog matchers (expandBareStarOverUsing, 11668 // lookupRelationColumnNames). addPivotConsumedRef already filters out 11669 // null / empty column names, so getColumnName() is non-null here; the 11670 // guard below is defensive. 11671 Set<String> consumedLC = new HashSet<>(); 11672 List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias)); 11673 consumed.addAll(aggArgRefs); 11674 for (ColumnRef r : consumed) { 11675 String cn = (r == null) ? null : r.getColumnName(); 11676 if (cn != null && !cn.isEmpty()) { 11677 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 11678 } 11679 } 11680 List<OutputColumn> outs = new ArrayList<>(); 11681 // 1. group / passthrough columns (catalog order, minus consumed). 11682 for (String c : cols) { 11683 if (c == null || c.isEmpty()) { 11684 continue; 11685 } 11686 if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) { 11687 continue; 11688 } 11689 outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false, 11690 Collections.singletonList(new ColumnRef(sourceAlias, c)), 11691 /*windowSpec=*/ null)); 11692 } 11693 // 2. pivot-output columns (IN-list document order — collectPivotOutputNames 11694 // returns a LinkedHashSet whose order is the IN-list order). 11695 for (String name : collectPivotOutputNames(pc)) { 11696 outs.add(new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ true, 11697 aggArgRefs, /*windowSpec=*/ null)); 11698 } 11699 return outs; 11700 } 11701 11702 /** 11703 * Slice 155 — synthesise the full output schema for a bare {@code SELECT *} 11704 * over a MULTI-aggregation static-IN PIVOT: 11705 * <ol> 11706 * <li><b>passthrough / group columns</b> — every published source column 11707 * NOT consumed by the PIVOT (the FOR / pivot column(s) and EVERY 11708 * aggregation-argument column), in catalog / published declaration 11709 * order. Each is a direct source reference ({@code derived=false}, 11710 * {@code aggregate=false}).</li> 11711 * <li><b>cross-product output columns</b> — one per (IN-value × aggregation) 11712 * named {@code <in_value_display_name>_<agg_alias>}, in 11713 * IN-value-outer / aggregation-inner order (the SQL-standard / Oracle 11714 * column order). Each is {@code derived=true}, {@code aggregate=true}, 11715 * sourced from that ONE aggregation's argument column(s).</li> 11716 * </ol> 11717 * Like {@link #expandPivotBareStar}, the passthrough expansion needs a source 11718 * catalog (or a FROM-subquery's published columns) — otherwise 11719 * {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED}. The cross-product part, 11720 * by contrast, is fully clause-determined (mirroring slice 154's explicit 11721 * cross-product lineage), so it needs no catalog. Caller has confirmed a 11722 * static IN-list and a multi-aggregation PIVOT. 11723 */ 11724 private static List<OutputColumn> expandPivotMultiAggBareStar(TPivotClause pc, 11725 TTable sourceTable, 11726 String sourceAlias, 11727 NameBindingProvider provider, 11728 TResultColumn star) { 11729 List<String> cols = lookupRelationColumnNames(sourceTable, provider); 11730 if (cols == null || cols.isEmpty()) { 11731 throw new SemanticIRBuildException( 11732 Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED, 11733 "SELECT * over a PIVOT requires catalog metadata for source '" 11734 + sourceAlias + "' to expand the implicit group columns; " 11735 + "supply a Catalog via " 11736 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star)); 11737 } 11738 // Columns consumed by the PIVOT (FOR / pivot column(s) + EVERY 11739 // aggregation-argument column); these are NOT passthrough group columns. 11740 // Folded to lower case (Locale.ROOT) to match the catalog identifier 11741 // canonicalisation used by the other semantic-IR catalog matchers. 11742 Set<String> consumedLC = new HashSet<>(); 11743 List<ColumnRef> consumed = new ArrayList<>(collectPivotForRefs(pc, sourceAlias)); 11744 consumed.addAll(collectPivotAggArgRefs(pc, sourceAlias)); 11745 for (ColumnRef r : consumed) { 11746 String cn = (r == null) ? null : r.getColumnName(); 11747 if (cn != null && !cn.isEmpty()) { 11748 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 11749 } 11750 } 11751 List<OutputColumn> outs = new ArrayList<>(); 11752 // 1. passthrough / group columns (published order, minus consumed). 11753 for (String c : cols) { 11754 if (c == null || c.isEmpty()) { 11755 continue; 11756 } 11757 if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) { 11758 continue; 11759 } 11760 outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false, 11761 Collections.singletonList(new ColumnRef(sourceAlias, c)), 11762 /*windowSpec=*/ null)); 11763 } 11764 // 2. cross-product output columns — IN-value outer (IN-list document 11765 // order), aggregation inner (declaration order). Each traces to its 11766 // OWN aggregation's argument column(s), not the union of all aggs. 11767 TResultColumnList aggList = pc.getAggregation_function_list(); 11768 for (String inVal : collectPivotOutputNames(pc)) { 11769 if (inVal == null || inVal.isEmpty()) { 11770 continue; 11771 } 11772 for (int ai = 0; ai < aggList.size(); ai++) { 11773 TResultColumn aggRc = aggList.getResultColumn(ai); 11774 if (aggRc == null) { 11775 continue; 11776 } 11777 String aggAlias = aggRc.getDisplayName(); 11778 if (aggAlias == null || aggAlias.isEmpty()) { 11779 continue; 11780 } 11781 outs.add(new OutputColumn(inVal + "_" + aggAlias, 11782 /*derived=*/ true, /*aggregate=*/ true, 11783 collectSingleAggArgRefs(aggRc, sourceAlias), 11784 /*windowSpec=*/ null)); 11785 } 11786 } 11787 return outs; 11788 } 11789 11790 /** 11791 * Slice 129 / 130 — a NAME-only deferred PIVOT output column ({@code 11792 * derived=true}, {@code aggregate=false}, EMPTY sources). Used for the 11793 * multi-aggregation / dynamic-IN / expression cases where output lineage 11794 * cannot be attributed without risking a false source. 11795 */ 11796 private static OutputColumn pivotSkeletonOutput(String name) { 11797 return new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ false, 11798 Collections.<ColumnRef>emptyList(), /*windowSpec=*/ null); 11799 } 11800 11801 // ----------------------------------------------------------------- 11802 // Slice 131 — PIVOT sub-slice (c): UNPIVOT output-column lineage. 11803 // 11804 // UNPIVOT narrows several source columns into rows. The clause carries: 11805 // getValueColumnList() — the NEW value column(s) holding the unpivoted 11806 // values (e.g. `orders`); 11807 // getPivotColumnList() — the NEW FOR/name column holding the literal 11808 // NAMES of the unpivoted columns (e.g. `employee`); 11809 // getUnpivotInClause().getItems() — the narrowed SOURCE columns 11810 // (`emp1`, `emp2`, ...), each `getColumn()` a 11811 // base-table column with a Phase-1 sourceTable. 11812 // The value and FOR column names cannot name an existing input column 11813 // (PIVOT semantics, TPivotClause javadoc) — so they never collide with a 11814 // real passthrough source column. 11815 // 11816 // Sub-slice (c) admits the deterministic shape: a SINGLE value column over a 11817 // simple (non-column-group) IN-list. Output-column lineage mirrors the 11818 // authoritative dlineage UNPIVOT handler — each IN-list source column feeds 11819 // BOTH the value column and the FOR column. A multi-value-column or 11820 // column-group UNPIVOT stays deferred (PIVOT_UNPIVOT_NOT_SUPPORTED). 11821 // ----------------------------------------------------------------- 11822 11823 /** 11824 * Slice 131 / 132 — validate the UNPIVOT shape, deferring (reject with 11825 * {@code PIVOT_UNPIVOT_NOT_SUPPORTED}, code kept reached) the shapes whose 11826 * value↔source mapping is not deterministic. 11827 * 11828 * <p>Slice 131 admitted ONLY the single-value-column / simple-IN shape. 11829 * Slice 132 (sub-slice (c2)) generalises this to the well-formed 11830 * multi-value-column / column-group shape (Oracle / Redshift) — e.g. 11831 * {@code UNPIVOT ((s1, s2) FOR yr IN ((a1, a2) AS 'Y1', (b1, b2) AS 'Y2'))}. 11832 * The admit rule is a single width invariant: every IN-list item must have 11833 * EXACTLY one source column per value column ({@code itemWidth == valueCount}), 11834 * so each value column maps positionally to one group position. 11835 * 11836 * <p>The malformed / mixed shapes that stay deferred (and keep the code 11837 * reached): no value column; a group whose width differs from the value 11838 * count (e.g. {@code ((s1, s2) FOR yr IN ((a1, a2, a3) ...))}); a single 11839 * column item under a multi-value UNPIVOT; or a column-group item under a 11840 * single-value UNPIVOT. The harder positional mapping for those is left for 11841 * a later sub-slice. 11842 */ 11843 private static void rejectUnsupportedUnpivotShape(TPivotClause pc, TTable pivotTable) { 11844 TObjectNameList valueCols = pc.getValueColumnList(); 11845 if (valueCols == null || valueCols.size() < 1) { 11846 throw new SemanticIRBuildException( 11847 Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED, 11848 "UNPIVOT without a value column is not supported yet", pivotTable)); 11849 } 11850 int valueCount = valueCols.size(); 11851 TUnpivotInClause in = pc.getUnpivotInClause(); 11852 if (in != null && in.getItems() != null) { 11853 for (int i = 0; i < in.getItems().size(); i++) { 11854 TUnpivotInClauseItem item = in.getItems().getElement(i); 11855 if (item == null) { 11856 continue; 11857 } 11858 int itemWidth; 11859 if (item.getColumn() != null) { 11860 itemWidth = 1; 11861 } else if (item.getColumnList() != null) { 11862 itemWidth = item.getColumnList().size(); 11863 } else { 11864 itemWidth = 0; 11865 } 11866 if (itemWidth != valueCount) { 11867 throw new SemanticIRBuildException( 11868 Diagnostic.error(DiagnosticCode.PIVOT_UNPIVOT_NOT_SUPPORTED, 11869 "UNPIVOT IN-list item width does not match the value-column " 11870 + "count (each IN-list group must have exactly " 11871 + valueCount + " column(s) for the positional value " 11872 + "mapping); this shape is not supported yet", pivotTable)); 11873 } 11874 } 11875 } 11876 } 11877 11878 /** 11879 * Slice 131 / 132 — ALL columns CONSUMED by an UNPIVOT: the IN-list SOURCE 11880 * columns being narrowed (NOT the new value / FOR columns), in document 11881 * order, deduped. Built DIRECTLY against {@code sourceAlias} via the shared 11882 * {@link #addPivotConsumedRef} filter (resolver2 NOT_FOUND-in-pivot quirk, 11883 * slice 129). This is also the source list for the FOR output column (the 11884 * FOR values are the literal labels of the narrowed columns, attributed to 11885 * every source column for slice-131 / dlineage parity). 11886 * 11887 * <p>Slice 132 extends slice 131 to walk column-group items: a multi-value 11888 * UNPIVOT IN-list item is a column GROUP ({@code getColumn() == null}, 11889 * {@code getColumnList()} populated), so the group columns are flattened 11890 * into this list in document order. 11891 * 11892 * <p>Note: this does NOT reuse {@link #collectPivotConsumedRefs}, which reads 11893 * {@code getPivotColumnList()} — for an UNPIVOT that is the NEW FOR column, 11894 * not a source column, and would be wrongly captured as a consumed ref. 11895 */ 11896 private static List<ColumnRef> collectUnpivotInListRefs(TPivotClause pc, 11897 String sourceAlias) { 11898 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11899 TUnpivotInClause in = pc.getUnpivotInClause(); 11900 if (in != null && in.getItems() != null) { 11901 for (int i = 0; i < in.getItems().size(); i++) { 11902 TUnpivotInClauseItem item = in.getItems().getElement(i); 11903 if (item == null) { 11904 continue; 11905 } 11906 if (item.getColumn() != null) { 11907 addPivotConsumedRef(refs, item.getColumn(), sourceAlias); 11908 } else if (item.getColumnList() != null) { 11909 TObjectNameList group = item.getColumnList(); 11910 for (int k = 0; k < group.size(); k++) { 11911 addPivotConsumedRef(refs, group.getObjectName(k), sourceAlias); 11912 } 11913 } 11914 } 11915 } 11916 return new ArrayList<>(refs); 11917 } 11918 11919 /** 11920 * Slice 132 — the source column ref(s) for the value column at 11921 * {@code position} (positional mapping per roadmap §13): for each IN-list 11922 * item, the group column at {@code position} (or {@code getColumn()} for a 11923 * single-column item when {@code position == 0}). Document order, deduped, 11924 * built directly against {@code sourceAlias}. 11925 * 11926 * <p>This unifies the single-value and multi-value cases: for a single-value 11927 * UNPIVOT each item is a single column and {@code position} is always 0, so 11928 * this returns ALL IN-list source columns — byte-identical to the slice-131 11929 * value-column lineage. For a multi-value UNPIVOT, value column {@code s_k} 11930 * at position {@code k} draws only from group position {@code k} 11931 * ({@code s1 <- a1, b1}; {@code s2 <- a2, b2}), which is more precise than 11932 * the authoritative dlineage handler (it feeds every source column into 11933 * every output column). The shape gate 11934 * ({@link #rejectUnsupportedUnpivotShape}) guarantees every item width 11935 * equals the value count, so {@code position} is always in range; the bounds 11936 * check is defensive. 11937 */ 11938 private static List<ColumnRef> collectUnpivotValueColumnRefs(TPivotClause pc, 11939 String sourceAlias, 11940 int position) { 11941 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 11942 TUnpivotInClause in = pc.getUnpivotInClause(); 11943 if (in != null && in.getItems() != null) { 11944 for (int i = 0; i < in.getItems().size(); i++) { 11945 TUnpivotInClauseItem item = in.getItems().getElement(i); 11946 if (item == null) { 11947 continue; 11948 } 11949 TObjectName col = null; 11950 if (item.getColumn() != null) { 11951 if (position == 0) { 11952 col = item.getColumn(); 11953 } 11954 } else if (item.getColumnList() != null 11955 && position < item.getColumnList().size()) { 11956 col = item.getColumnList().getObjectName(position); 11957 } 11958 addPivotConsumedRef(refs, col, sourceAlias); 11959 } 11960 } 11961 return new ArrayList<>(refs); 11962 } 11963 11964 /** 11965 * Slice 132 — map each UNPIVOT value-column NAME to its position in the 11966 * value-column list, so a projected column can be matched to its positional 11967 * source group. First occurrence wins on a (degenerate) duplicate name. 11968 */ 11969 private static Map<String, Integer> unpivotValueColumnPositions(TPivotClause pc) { 11970 Map<String, Integer> positions = new HashMap<>(); 11971 TObjectNameList vcl = pc.getValueColumnList(); 11972 if (vcl != null) { 11973 for (int i = 0; i < vcl.size(); i++) { 11974 TObjectName n = vcl.getObjectName(i); 11975 if (n == null) { 11976 continue; 11977 } 11978 String name = n.getColumnNameOnly(); 11979 if (name != null && !name.isEmpty() && !positions.containsKey(name)) { 11980 positions.put(name, i); 11981 } 11982 } 11983 } 11984 return positions; 11985 } 11986 11987 /** Slice 131 — the UNPIVOT FOR/name-column name, or null. */ 11988 private static String unpivotForColumnName(TPivotClause pc) { 11989 TObjectNameList pcl = pc.getPivotColumnList(); 11990 if (pcl != null && pcl.size() >= 1 && pcl.getObjectName(0) != null) { 11991 return pcl.getObjectName(0).getColumnNameOnly(); 11992 } 11993 if (pc.getPivotColumn() != null) { 11994 return pc.getPivotColumn().getColumnNameOnly(); 11995 } 11996 return null; 11997 } 11998 11999 /** 12000 * Slice 131 — build output columns for an UNPIVOT SELECT (the deterministic 12001 * single-value / simple-IN shape; multi-value / column-group already 12002 * deferred by {@link #rejectUnsupportedUnpivotShape}). Each projected result 12003 * column is matched by its underlying name ({@link TResultColumn#getColumnNameOnly()}, 12004 * NOT the output alias — mirrors slice 130): 12005 * <ul> 12006 * <li><b>value column</b> — underlying name == a value-column name. Holds 12007 * the unpivoted values, so {@code derived=true}, {@code aggregate=false}. 12008 * Sources are POSITIONAL (slice 132): value column at position {@code k} 12009 * draws from group position {@code k} across all IN-list items 12010 * ({@code s1 <- a1, b1}; {@code s2 <- a2, b2}). For a single-value 12011 * UNPIVOT this collapses to ALL IN-list source columns (slice 131).</li> 12012 * <li><b>FOR/name column</b> — underlying name == the FOR-column name. Holds 12013 * the literal NAMES / labels of the unpivoted columns; attributed to ALL 12014 * IN-list source columns (slice-131 / dlineage parity — both halves of a 12015 * single-value UNPIVOT trace to every narrowed source column). 12016 * {@code derived=true}, {@code aggregate=false}.</li> 12017 * <li><b>passthrough column</b> — any other {@code simple_object_name_t} 12018 * projection. Direct reference to the source column: 12019 * {@code derived=false}, {@code aggregate=false}, single source 12020 * {@code sourceAlias.colName} (underlying name, alias respected in the 12021 * output NAME).</li> 12022 * <li><b>expression projection</b> — kept as a NAME-only skeleton 12023 * ({@link #pivotSkeletonOutput}); no single source column.</li> 12024 * </ul> 12025 * Value / FOR matching is exact (mirrors slice 130). No name-collision risk: 12026 * PIVOT/UNPIVOT semantics forbid the value / FOR column from naming an 12027 * existing input column, so a passthrough source column can never coincide 12028 * with the value / FOR name. All refs are built DIRECTLY against 12029 * {@code sourceAlias} (single base-table source; resolver2 NOT_FOUND quirk). 12030 */ 12031 private static List<OutputColumn> buildUnpivotOutputColumns(TSelectSqlStatement select, 12032 TTable pivotTable, 12033 TPivotClause pc, 12034 String sourceAlias, 12035 TTable sourceTable, 12036 NameBindingProvider provider) { 12037 TResultColumnList rcl = select.getResultColumnList(); 12038 if (rcl == null || rcl.size() == 0) { 12039 throw new SemanticIRBuildException( 12040 Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS, 12041 "SELECT has no projected columns", select)); 12042 } 12043 12044 // Slice 134 / 135 (sub-slice (b2-unpivot)) — a SOLE bare `SELECT *` over a 12045 // well-formed UNPIVOT (single-value, slice 131; or multi-value-column / 12046 // column-group, slice 132 — both already validated by the 12047 // rejectUnsupportedUnpivotShape width invariant run earlier in 12048 // buildPivotSelect) is expanded from the source table's catalog column list 12049 // into the synthesised output schema (passthrough columns then the FOR/name 12050 // column then the value column(s)). Slice 135 lifted the slice-134 12051 // valueCount==1 gate to valueCount>=1 so the multi-value-column case is 12052 // admitted too. 12053 // 12054 // Slice 152 — the SOLE star is now admitted whether it is BARE (`*`) or 12055 // QUALIFIED (`u.*` / `src.*`), mirroring slice 151 on the PIVOT path. An 12056 // admitted UNPIVOT SELECT has exactly ONE relation in scope — the unpivot 12057 // output (isPivotSelect requires a FROM-UNPIVOT with no JOIN) — so a 12058 // qualified star can only mean that one relation and is therefore 12059 // equivalent to a bare `*`; expandUnpivotBareStar synthesises the identical 12060 // schema (it never reads the star's qualifier, only the source columns and 12061 // the unpivot clause). The isBareStar gate is dropped here. A `*` mixed 12062 // with explicit columns (rcl.size() > 1) still defers via the per-column 12063 // reject below (PIVOT_STAR_NOT_SUPPORTED kept reached). 12064 TObjectNameList valueCols = pc.getValueColumnList(); 12065 if (rcl.size() == 1 && valueCols != null && valueCols.size() >= 1) { 12066 TResultColumn only = rcl.getResultColumn(0); 12067 if ("*".equals(only.getColumnNameOnly())) { 12068 // Slice 137 lifted the slice-136 subquery-source deferral. A bare 12069 // SELECT * over a FROM-subquery UNPIVOT now expands the same way as 12070 // a base-table UNPIVOT: expandUnpivotBareStar reads the source's 12071 // published columns via lookupRelationColumnNames, which the 12072 // slice-137 addRelationToInScopeMap unwrap registers under the 12073 // subquery alias. No catalog is required for a subquery source — 12074 // the subquery's explicit projection IS the published schema. 12075 return expandUnpivotBareStar(pc, sourceTable, sourceAlias, provider, only); 12076 } 12077 } 12078 12079 Map<String, Integer> valuePositions = unpivotValueColumnPositions(pc); 12080 String forName = unpivotForColumnName(pc); 12081 List<ColumnRef> inListRefs = collectUnpivotInListRefs(pc, sourceAlias); 12082 12083 List<OutputColumn> outs = new ArrayList<>(); 12084 for (int i = 0; i < rcl.size(); i++) { 12085 TResultColumn rc = rcl.getResultColumn(i); 12086 if ("*".equals(rc.getColumnNameOnly())) { 12087 throw new SemanticIRBuildException( 12088 Diagnostic.error(DiagnosticCode.PIVOT_STAR_NOT_SUPPORTED, 12089 "SELECT * over an UNPIVOT is not supported yet " 12090 + "(the synthesised output schema is deferred); " 12091 + "project explicit columns", rc)); 12092 } 12093 String outName = effectiveOutputName(rc); 12094 String colOnly = rc.getColumnNameOnly(); 12095 Integer valuePos = (colOnly == null || colOnly.isEmpty()) 12096 ? null : valuePositions.get(colOnly); 12097 if (valuePos != null) { 12098 // Value column (the unpivoted values): POSITIONAL sources — the 12099 // group column at this value column's position across every 12100 // IN-list item. 12101 outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ false, 12102 collectUnpivotValueColumnRefs(pc, sourceAlias, valuePos), 12103 /*windowSpec=*/ null)); 12104 } else if (colOnly != null && !colOnly.isEmpty() 12105 && colOnly.equals(forName)) { 12106 // FOR/name column (the source column NAMES / labels): traces to 12107 // every narrowed IN-list source column (dlineage parity). 12108 outs.add(new OutputColumn(outName, /*derived=*/ true, /*aggregate=*/ false, 12109 inListRefs, /*windowSpec=*/ null)); 12110 } else if (colOnly != null && !colOnly.isEmpty() 12111 && rc.getExpr() != null 12112 && rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t) { 12113 // Passthrough column: direct reference to the source column. 12114 outs.add(new OutputColumn(outName, /*derived=*/ false, /*aggregate=*/ false, 12115 Collections.singletonList(new ColumnRef(sourceAlias, colOnly)), 12116 /*windowSpec=*/ null)); 12117 } else { 12118 // Expression projection (or no underlying column) — keep skeleton. 12119 outs.add(pivotSkeletonOutput(outName)); 12120 } 12121 } 12122 return outs; 12123 } 12124 12125 /** 12126 * Slice 134 / 135 (sub-slice (b2-unpivot)) — synthesise the full output schema 12127 * for a bare {@code SELECT *} over a well-formed UNPIVOT (single-value, slice 12128 * 131; or multi-value-column / column-group, slice 132 — slice 135 lifted the 12129 * value-count gate), using the source table's catalog column list: 12130 * <ol> 12131 * <li><b>passthrough columns</b> — every catalog column that is NOT a 12132 * narrowed IN-list SOURCE column ({@link #collectUnpivotInListRefs}), in 12133 * CATALOG declaration order. Each is a direct source reference 12134 * ({@code derived=false}, {@code aggregate=false}, single source 12135 * {@code sourceAlias.col}).</li> 12136 * <li><b>FOR/name column</b> — {@link #unpivotForColumnName}. Holds the 12137 * literal NAMES of the unpivoted columns; traces to ALL IN-list source 12138 * columns ({@code derived=true}, {@code aggregate=false}).</li> 12139 * <li><b>value column(s)</b> — every {@code getValueColumnList()} entry, in 12140 * declaration order. Holds the unpivoted values; the value column at 12141 * position {@code k} draws POSITIONALLY from group position {@code k} 12142 * across every IN-list item ({@link #collectUnpivotValueColumnRefs}, the 12143 * slice-132 mechanism): {@code s1 <- a1, b1}; {@code s2 <- a2, b2}. For a 12144 * single-value UNPIVOT (slice 134) the sole position 0 collapses to ALL 12145 * IN-list source columns ({@code derived=true}, {@code aggregate=false}).</li> 12146 * </ol> 12147 * This is the Oracle / Redshift {@code SELECT *} UNPIVOT order: the passthrough 12148 * columns first, then the FOR/name column, then the value column(s). The 12149 * per-column lineage is byte-identical to the slice-131 / 132 explicit-projection 12150 * path ({@link #buildUnpivotOutputColumns}); only the names and order are 12151 * synthesised here from the catalog + clause. 12152 * 12153 * <p>The passthrough subtraction folds names with {@code toLowerCase(Locale.ROOT)} 12154 * — the same identifier canonicalisation the other semantic-IR catalog matchers 12155 * use ({@link #expandPivotBareStar}, {@link #lookupRelationColumnNames}). The 12156 * FOR/value column names are NEW (UNPIVOT semantics forbid them from colliding 12157 * with a source column), so they are never in the catalog and need no subtraction. 12158 * 12159 * <p>Throws {@link DiagnosticCode#PIVOT_STAR_CATALOG_REQUIRED} (reused from 12160 * slice 133) when no catalog column list is available, with an 12161 * UNPIVOT-discriminated message (slice-80 message-text-discrimination contract). 12162 */ 12163 private static List<OutputColumn> expandUnpivotBareStar(TPivotClause pc, 12164 TTable sourceTable, 12165 String sourceAlias, 12166 NameBindingProvider provider, 12167 TResultColumn star) { 12168 List<String> cols = lookupRelationColumnNames(sourceTable, provider); 12169 if (cols == null || cols.isEmpty()) { 12170 throw new SemanticIRBuildException( 12171 Diagnostic.error(DiagnosticCode.PIVOT_STAR_CATALOG_REQUIRED, 12172 "SELECT * over an UNPIVOT requires catalog metadata for source '" 12173 + sourceAlias + "' to expand the implicit passthrough columns; " 12174 + "supply a Catalog via " 12175 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", star)); 12176 } 12177 // Columns NARROWED by the UNPIVOT (the IN-list SOURCE columns); these are 12178 // removed from the passthrough set. Folded to lower case (Locale.ROOT) to 12179 // match the catalog identifier canonicalisation used by the other 12180 // semantic-IR catalog matchers. addPivotConsumedRef already filters out 12181 // null / empty names, so getColumnName() is non-null; the guard is defensive. 12182 List<ColumnRef> inListRefs = collectUnpivotInListRefs(pc, sourceAlias); 12183 Set<String> consumedLC = new HashSet<>(); 12184 for (ColumnRef r : inListRefs) { 12185 String cn = (r == null) ? null : r.getColumnName(); 12186 if (cn != null && !cn.isEmpty()) { 12187 consumedLC.add(cn.toLowerCase(Locale.ROOT)); 12188 } 12189 } 12190 List<OutputColumn> outs = new ArrayList<>(); 12191 // 1. passthrough columns (catalog order, minus the narrowed IN-list sources). 12192 for (String c : cols) { 12193 if (c == null || c.isEmpty()) { 12194 continue; 12195 } 12196 if (consumedLC.contains(c.toLowerCase(Locale.ROOT))) { 12197 continue; 12198 } 12199 outs.add(new OutputColumn(c, /*derived=*/ false, /*aggregate=*/ false, 12200 Collections.singletonList(new ColumnRef(sourceAlias, c)), 12201 /*windowSpec=*/ null)); 12202 } 12203 // 2. FOR/name column — the literal labels of the narrowed columns; traces 12204 // to every IN-list source column (slice-131 / dlineage parity). 12205 String forName = unpivotForColumnName(pc); 12206 if (forName != null && !forName.isEmpty()) { 12207 outs.add(new OutputColumn(forName, /*derived=*/ true, /*aggregate=*/ false, 12208 inListRefs, /*windowSpec=*/ null)); 12209 } 12210 // 3. value column(s) — the unpivoted values, in getValueColumnList() 12211 // declaration order. Slice 135: value column at position k draws from 12212 // group position k across every IN-list item (collectUnpivotValueColumnRefs, 12213 // the slice-132 positional mechanism): s1 <- a1, b1; s2 <- a2, b2. For a 12214 // single-value UNPIVOT (slice 134) the sole position 0 collapses to all 12215 // IN-list source columns, byte-identical to the prior behaviour. 12216 TObjectNameList valueCols = pc.getValueColumnList(); 12217 if (valueCols != null) { 12218 for (int k = 0; k < valueCols.size(); k++) { 12219 TObjectName valueCol = valueCols.getObjectName(k); 12220 String valueName = (valueCol != null) ? valueCol.getColumnNameOnly() : null; 12221 if (valueName != null && !valueName.isEmpty()) { 12222 outs.add(new OutputColumn(valueName, /*derived=*/ true, /*aggregate=*/ false, 12223 collectUnpivotValueColumnRefs(pc, sourceAlias, k), /*windowSpec=*/ null)); 12224 } 12225 } 12226 } 12227 return outs; 12228 } 12229 12230 /** 12231 * Slices 70 and 71: build per-statement row-limit metadata from 12232 * {@code TLimitClause}, {@code TTopClause}, {@code TOffsetClause}, 12233 * or {@code TFetchFirstClause}. Returns {@code null} when no 12234 * row-limit clause is present. All admit / reject decisions for 12235 * single-SELECT row-limit clauses live here; the set-op outer 12236 * row-limit path is rejected separately by 12237 * {@link #rejectSetOpRowLimit} (slice 72 lifts). 12238 * 12239 * <h4>Admitted shapes</h4> 12240 * <ul> 12241 * <li>{@link RowLimitKind#LIMIT} — {@code TLimitClause} with 12242 * non-null {@code getRow_count()}. Offset is populated when 12243 * {@code TLimitClause.getOffset() != null} (PG / MySQL / 12244 * SQLite / BigQuery / Snowflake / Redshift inline 12245 * {@code LIMIT N OFFSET M}, MySQL old-style {@code LIMIT M, N}, 12246 * Informix {@code SKIP m LIMIT n}).</li> 12247 * <li>{@link RowLimitKind#FETCH_FIRST} — {@code TLimitClause} with 12248 * non-null {@code getSelectFetchFirstValue()} (PG 12249 * {@code FETCH FIRST}, Informix {@code FIRST n}). Offset is 12250 * populated when present (PG 12251 * {@code OFFSET m FETCH FIRST n}, Informix 12252 * {@code SKIP m FIRST n}). Also fires for 12253 * {@code TFetchFirstClause} with non-null 12254 * {@code getFetchValue()} (Oracle / SQL Server 12255 * {@code FETCH FIRST/NEXT N ROWS ONLY}) when no 12256 * {@code TOffsetClause} is present.</li> 12257 * <li>{@link RowLimitKind#TOP} — {@code TTopClause} with non-null 12258 * {@code getExpr()} and neither {@code isPercent()} nor 12259 * {@code isWithties()} set. SQL Server {@code SELECT TOP N}.</li> 12260 * <li>{@link RowLimitKind#OFFSET_FETCH} — Oracle / SQL Server 12261 * {@code OFFSET m ROWS [FETCH NEXT n ROWS ONLY]} routed via 12262 * the dedicated {@code TOffsetClause} + {@code TFetchFirstClause} 12263 * pair, and PG offset-only {@code OFFSET m} routed via 12264 * {@code TLimitClause.getOffset()} when {@code row_count} and 12265 * {@code selectFetchFirstValue} are both null. 12266 * {@link RowLimit#getCount()} may be {@code null} for 12267 * offset-only forms.</li> 12268 * </ul> 12269 * 12270 * <h4>Rejects</h4> 12271 * <ul> 12272 * <li>{@link DiagnosticCode#ROW_LIMIT_TOP_PERCENT_NOT_SUPPORTED} 12273 * — {@code TOP N PERCENT}. The sampling semantics differ from 12274 * fixed-row {@code LIMIT} enough to warrant a dedicated slice.</li> 12275 * <li>{@link DiagnosticCode#ROW_LIMIT_TOP_WITH_TIES_NOT_SUPPORTED} 12276 * — {@code TOP N WITH TIES}. Requires modeling the ORDER BY 12277 * tie-handling interaction; deferred.</li> 12278 * <li>{@link DiagnosticCode#ROW_LIMIT_HIVE_LIMIT_GRAMMAR_QUIRK} — 12279 * Hive single-argument {@code LIMIT N} parser routes the 12280 * count through {@code TLimitClause.getOffset()} with 12281 * {@code row_count == null}, which is indistinguishable at 12282 * the AST level from PG offset-only {@code OFFSET m}. Pinning 12283 * this with a vendor-specific guard prevents emitting 12284 * semantically-wrong {@code OFFSET_FETCH} metadata for what 12285 * the SQL author wrote as a LIMIT. A future grammar fix 12286 * should route the count through {@code getRow_count()}; this 12287 * guard can be removed then.</li> 12288 * <li>{@link DiagnosticCode#ROW_LIMIT_LIMIT_NOT_SUPPORTED} — 12289 * Vertica TIMESERIES windowing on {@code TLimitClause} 12290 * ({@code getWindowDef() != null}). Defensive; not modeled.</li> 12291 * <li>{@link DiagnosticCode#ROW_LIMIT_COUNT_UNRESOLVED} — the 12292 * parser constructed a row-limit clause node but did not 12293 * populate any count slot: 12294 * <ul> 12295 * <li>{@code TLimitClause} with {@code row_count}, 12296 * {@code selectFetchFirstValue}, and {@code offset} all 12297 * null (defensive; not observed in probe runs).</li> 12298 * <li>{@code TFetchFirstClause} with null fetchValue — 12299 * ANSI / DB2 grammar incompleteness (the parser 12300 * constructs the clause node but does not populate the 12301 * count). Future grammar fix can lift this.</li> 12302 * <li>{@code TTopClause} with null expression (defensive).</li> 12303 * </ul></li> 12304 * </ul> 12305 */ 12306 private static RowLimit buildRowLimit(TSelectSqlStatement select) { 12307 TLimitClause limit = select.getLimitClause(); 12308 if (limit != null) { 12309 // Vertica TIMESERIES window on TLimitClause — defensive; rare. 12310 // Pre-empts the row_count / fff branches because the windowed 12311 // form is its own semantic surface. 12312 if (limit.getWindowDef() != null) { 12313 throw new SemanticIRBuildException( 12314 Diagnostic.error(DiagnosticCode.ROW_LIMIT_LIMIT_NOT_SUPPORTED, 12315 "row-limit clause LIMIT with Vertica TIMESERIES window " 12316 + "is not supported yet", limit)); 12317 } 12318 12319 TExpression rc = limit.getRow_count(); 12320 TExpression off = limit.getOffset(); 12321 TExpression fff = limit.getSelectFetchFirstValue(); 12322 12323 // Hive single-argument LIMIT parser quirk: the count ends 12324 // up on offset with row_count=null. Vendor-conditional 12325 // because the same AST shape is legitimate PG offset-only. 12326 if (select.dbvendor == EDbVendor.dbvhive 12327 && rc == null && off != null && fff == null) { 12328 throw new SemanticIRBuildException( 12329 Diagnostic.error(DiagnosticCode.ROW_LIMIT_HIVE_LIMIT_GRAMMAR_QUIRK, 12330 "Hive single-argument LIMIT N is currently mis-routed " 12331 + "by the parser (count appears on TLimitClause.getOffset() " 12332 + "with row_count=null); fix the Hive grammar to route " 12333 + "the count through getRow_count() to lift this guard", limit)); 12334 } 12335 12336 if (rc != null) { 12337 // LIMIT N with optional OFFSET M (PG/MySQL/SQLite/ 12338 // BigQuery/Snowflake/Redshift inline LIMIT-OFFSET, 12339 // MySQL old-style LIMIT M,N, Informix SKIP m LIMIT n). 12340 return new RowLimit(RowLimitKind.LIMIT, 12341 rc.toString(), 12342 off != null ? off.toString() : null); 12343 } 12344 if (fff != null) { 12345 // FETCH FIRST via the PG/Informix routing through 12346 // TLimitClause, with optional OFFSET (PG 12347 // OFFSET m FETCH FIRST n; Informix SKIP m FIRST n). 12348 return new RowLimit(RowLimitKind.FETCH_FIRST, 12349 fff.toString(), 12350 off != null ? off.toString() : null); 12351 } 12352 if (off != null) { 12353 // Offset-only via TLimitClause (PG OFFSET m [ROWS]). 12354 return new RowLimit(RowLimitKind.OFFSET_FETCH, 12355 /*count=*/ null, 12356 off.toString()); 12357 } 12358 // Defensive: TLimitClause present with all four slots null. 12359 throw new SemanticIRBuildException( 12360 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 12361 "row-limit clause LIMIT is present but no count, offset, " 12362 + "or FETCH FIRST value is populated on the parser AST", limit)); 12363 } 12364 12365 TTopClause top = select.getTopClause(); 12366 if (top != null) { 12367 if (top.isPercent()) { 12368 throw new SemanticIRBuildException( 12369 Diagnostic.error(DiagnosticCode.ROW_LIMIT_TOP_PERCENT_NOT_SUPPORTED, 12370 "row-limit clause TOP N PERCENT is not supported yet; " 12371 + "sampling semantics warrant a dedicated slice", top)); 12372 } 12373 if (top.isWithties()) { 12374 throw new SemanticIRBuildException( 12375 Diagnostic.error(DiagnosticCode.ROW_LIMIT_TOP_WITH_TIES_NOT_SUPPORTED, 12376 "row-limit clause TOP N WITH TIES is not supported yet; " 12377 + "tie-handling semantics warrant a dedicated slice", top)); 12378 } 12379 TExpression e = top.getExpr(); 12380 if (e == null) { 12381 throw new SemanticIRBuildException( 12382 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 12383 "row-limit clause TOP is present but the count expression " 12384 + "is not populated on the parser AST", top)); 12385 } 12386 return new RowLimit(RowLimitKind.TOP, e.toString(), /*offset=*/ null); 12387 } 12388 12389 TOffsetClause offClause = select.getOffsetClause(); 12390 TFetchFirstClause fetch = select.getFetchFirstClause(); 12391 if (offClause != null) { 12392 // Oracle / SQL Server OFFSET m ROWS [FETCH NEXT n ROWS ONLY]. 12393 // The optional FETCH NEXT counterpart populates 12394 // TFetchFirstClause when present. 12395 String offsetText = offClause.getSelectOffsetValue() != null 12396 ? offClause.getSelectOffsetValue().toString() 12397 : null; 12398 if (offsetText == null) { 12399 throw new SemanticIRBuildException( 12400 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 12401 "row-limit clause OFFSET is present but the offset value " 12402 + "is not populated on the parser AST", offClause)); 12403 } 12404 String countText = null; 12405 if (fetch != null && fetch.getFetchValue() != null) { 12406 countText = fetch.getFetchValue().toString(); 12407 } 12408 return new RowLimit(RowLimitKind.OFFSET_FETCH, countText, offsetText); 12409 } 12410 if (fetch != null) { 12411 if (fetch.getFetchValue() != null) { 12412 // Oracle / SQL Server FETCH FIRST/NEXT N ROWS ONLY 12413 // without OFFSET (TOffsetClause was null above). 12414 return new RowLimit(RowLimitKind.FETCH_FIRST, 12415 fetch.getFetchValue().toString(), /*offset=*/ null); 12416 } 12417 // ANSI / DB2: TFetchFirstClause is non-null but fetchValue 12418 // is null because the grammar does not pass the count into 12419 // the node initializer. Reject so the gap is visible. 12420 throw new SemanticIRBuildException( 12421 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 12422 "row-limit clause FETCH FIRST is present but the count " 12423 + "expression is not populated on the parser AST " 12424 + "(ANSI / DB2 grammar gap)", fetch)); 12425 } 12426 return null; 12427 } 12428 12429 /** 12430 * Slice 72: build the OUTER set-op statement's row-limit. Same 12431 * decision tree as {@link #buildRowLimit} for SELECT-level routing 12432 * (PG/MySQL/SQLite/BigQuery/Snowflake/Redshift via 12433 * {@code TLimitClause}, plus Hive/Vertica/ANSI-DB2 defensives), 12434 * with an additional MSSQL-only fallback that reads the OFFSET / 12435 * FETCH FIRST clauses off the outer {@code TOrderBy} node. 12436 * 12437 * <p>Empirical AST shapes (probed against the current parser): 12438 * <ul> 12439 * <li>PG / MySQL / SQLite / BigQuery / Snowflake / Redshift route 12440 * set-op outer LIMIT / OFFSET / FETCH FIRST onto 12441 * {@code setOp.getLimitClause()} — handled by the primary 12442 * {@code buildRowLimit} path.</li> 12443 * <li>MSSQL routes set-op outer {@code OFFSET m ROWS [FETCH NEXT 12444 * n ROWS ONLY]} EXCLUSIVELY onto 12445 * {@code setOp.getOrderbyClause().getOffsetClause()} / 12446 * {@code .getFetchFirstClause()} — NOT duplicated onto the 12447 * SELECT node (opposite of single-SELECT MSSQL where slice 71 12448 * saw duplication onto both). The TOrderBy fallback below 12449 * handles this.</li> 12450 * <li>Oracle drops set-op outer OFFSET / FETCH from both SELECT 12451 * and TOrderBy slots silently; nothing for slice 72 to 12452 * emit. A future Oracle grammar fix can lift this.</li> 12453 * </ul> 12454 * 12455 * <p>The TOrderBy fallback is vendor-gated to MSSQL to avoid 12456 * over-admitting on unprobed dialects (per codex round-1 B1). 12457 * Kind mapping mirrors {@link #buildRowLimit}'s single-SELECT 12458 * decision tree: 12459 * <ul> 12460 * <li>{@code TOffsetClause} + {@code TFetchFirstClause} both 12461 * populated → {@code OFFSET_FETCH/count/offset}</li> 12462 * <li>{@code TOffsetClause} only → {@code OFFSET_FETCH/null/offset}</li> 12463 * <li>{@code TFetchFirstClause} only → {@code FETCH_FIRST/count/null} 12464 * (unreachable via current MSSQL grammar which requires 12465 * OFFSET before FETCH; retained as defensive routing-shape 12466 * parity with single-SELECT)</li> 12467 * <li>Defensive null-value rejects mirror single-SELECT 12468 * {@link #buildRowLimit}: a present {@code TOffsetClause} 12469 * with a null offset value throws 12470 * {@code ROW_LIMIT_COUNT_UNRESOLVED}; a present bare 12471 * {@code TFetchFirstClause} (no companion OFFSET) with a 12472 * null fetch value throws the same code. When both clauses 12473 * are present, only the offset slot must be populated; a 12474 * null fetch value is silently treated as offset-only 12475 * (matches the single-SELECT 12476 * {@code TOffsetClause + TFetchFirstClause} branch in 12477 * {@code buildRowLimit}).</li> 12478 * </ul> 12479 */ 12480 private static RowLimit buildSetOpRowLimit(TSelectSqlStatement setOp) { 12481 // Primary path: SELECT-level routing. Covers PG/MySQL/SQLite/ 12482 // BigQuery/Snowflake/Redshift via TLimitClause, plus inherited 12483 // Hive / Vertica / ANSI-DB2 defensives from buildRowLimit. 12484 RowLimit fromSelect = buildRowLimit(setOp); 12485 if (fromSelect != null) { 12486 return fromSelect; 12487 } 12488 // MSSQL-only TOrderBy fallback (codex B1 vendor gate). 12489 if (setOp.dbvendor != EDbVendor.dbvmssql) { 12490 return null; 12491 } 12492 TOrderBy orderBy = setOp.getOrderbyClause(); 12493 if (orderBy == null) return null; 12494 TOffsetClause oc = orderBy.getOffsetClause(); 12495 TFetchFirstClause fc = orderBy.getFetchFirstClause(); 12496 if (oc == null && fc == null) return null; 12497 12498 String offsetText = (oc != null && oc.getSelectOffsetValue() != null) 12499 ? oc.getSelectOffsetValue().toString() : null; 12500 String countText = (fc != null && fc.getFetchValue() != null) 12501 ? fc.getFetchValue().toString() : null; 12502 12503 if (oc != null && fc != null) { 12504 if (offsetText == null) { 12505 // Mirrors single-SELECT buildRowLimit TOffsetClause path: 12506 // a present OFFSET clause must populate its value (the 12507 // FETCH NEXT counterpart is optional; null countText is 12508 // silently treated as offset-only). 12509 throw new SemanticIRBuildException( 12510 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 12511 "MSSQL set-op outer OFFSET clause present on TOrderBy " 12512 + "but offset value is not populated on the parser AST", orderBy)); 12513 } 12514 return new RowLimit(RowLimitKind.OFFSET_FETCH, countText, offsetText); 12515 } 12516 if (oc != null) { 12517 if (offsetText == null) { 12518 throw new SemanticIRBuildException( 12519 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 12520 "MSSQL set-op outer OFFSET clause present on TOrderBy " 12521 + "but offset value is not populated on the parser AST", orderBy)); 12522 } 12523 return new RowLimit(RowLimitKind.OFFSET_FETCH, /*count=*/ null, offsetText); 12524 } 12525 // fc only (oc == null). Defensive: not reachable via current 12526 // MSSQL grammar which requires OFFSET before FETCH NEXT. 12527 if (countText == null) { 12528 throw new SemanticIRBuildException( 12529 Diagnostic.error(DiagnosticCode.ROW_LIMIT_COUNT_UNRESOLVED, 12530 "MSSQL set-op outer FETCH FIRST clause present on TOrderBy " 12531 + "but fetch value is not populated on the parser AST", orderBy)); 12532 } 12533 return new RowLimit(RowLimitKind.FETCH_FIRST, countText, /*offset=*/ null); 12534 } 12535 12536 /** 12537 * Resolve a {@link TSelectSqlStatement}'s row-filter clause to the IR's 12538 * {@code distinct} flag. Mapping: 12539 * 12540 * <ul> 12541 * <li>no clause / {@code urfNone} / {@code urfAll}: {@code false}</li> 12542 * <li>{@code urfDistinct}: {@code true}</li> 12543 * <li>{@code urfUnique}: {@code true} — Oracle treats 12544 * {@code SELECT UNIQUE} as a deprecated synonym for 12545 * {@code SELECT DISTINCT}; both produce the same row-set.</li> 12546 * <li>{@code urfDistinctOn}: admits (slice 73). Returns 12547 * {@code true} for the boolean flag; the 12548 * {@code DISTINCT ON (cols)} partition keys are collected 12549 * separately by 12550 * {@link #buildDistinctOnColumnRefs(TSelectSqlStatement, 12551 * NameBindingProvider)} so the column-ref collection runs 12552 * AFTER {@code UsingScope} is installed (matching the timing 12553 * of {@link #buildGroupByColumnRefs} and friends).</li> 12554 * <li>{@code urfDistinctRow}, {@code urfNormalize}: rejected 12555 * (vendor-specific; not yet a documented IR shape).</li> 12556 * <li>null filter on a non-null {@code TSelectDistinct}, or a 12557 * new enum value the switch hasn't seen yet: rejected, so a 12558 * future {@code EUniqueRowFilterType} addition fails loudly 12559 * rather than silently classifying as {@code distinct=false}.</li> 12560 * </ul> 12561 */ 12562 private static boolean resolveDistinctFlag(TSelectSqlStatement select) { 12563 TSelectDistinct sd = select.getSelectDistinct(); 12564 if (sd == null) return false; 12565 EUniqueRowFilterType urf = sd.getUniqueRowFilter(); 12566 if (urf == null) { 12567 throw new SemanticIRBuildException( 12568 Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_NULL, 12569 "SELECT row-filter is null; expected one of " 12570 + "{none, all, distinct, unique}", select)); 12571 } 12572 switch (urf) { 12573 case urfNone: 12574 case urfAll: 12575 return false; 12576 case urfDistinct: 12577 case urfUnique: // Oracle deprecated synonym for DISTINCT 12578 case urfDistinctOn: // slice 73: refs collected separately 12579 return true; 12580 case urfDistinctRow: 12581 case urfNormalize: 12582 throw new SemanticIRBuildException( 12583 Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_NOT_SUPPORTED, 12584 "SELECT row-filter " + urf + " is not supported yet", select)); 12585 default: 12586 throw new SemanticIRBuildException( 12587 Diagnostic.error(DiagnosticCode.SELECT_ROW_FILTER_UNKNOWN, 12588 "unknown SELECT row-filter " + urf, select)); 12589 } 12590 } 12591 12592 /** 12593 * Slice 73: collect physical column references from a 12594 * {@code SELECT DISTINCT ON (cols)} expression list. Returns the 12595 * empty list for plain {@code DISTINCT}, {@code UNIQUE}, 12596 * {@code ALL}, and the no-filter case. Only PostgreSQL and 12597 * Greenplum expose {@code urfDistinctOn} with a populated 12598 * {@link TSelectDistinct#getExpressionList()}; Oracle, MySQL, and 12599 * Redshift silently drop the {@code ON (...)} clause and parse the 12600 * SELECT as plain {@code DISTINCT}, so this helper returns 12601 * {@code []} for those vendors regardless of the surface SQL. 12602 * 12603 * <p>Mirrors {@link #buildGroupByColumnRefs}: subqueries and window 12604 * functions in the expression list are rejected BEFORE 12605 * {@link #collectColumnRefs} descends, so inner-scope refs cannot 12606 * leak into {@code distinctOnColumnRefs}. Compound expressions 12607 * ({@code a + b}, {@code CASE WHEN ...}) and aggregate arguments 12608 * ({@code COUNT(x)}) are descended into so the underlying column 12609 * refs are captured. 12610 */ 12611 private static List<ColumnRef> buildDistinctOnColumnRefs( 12612 TSelectSqlStatement select, NameBindingProvider provider) { 12613 TSelectDistinct sd = select.getSelectDistinct(); 12614 if (sd == null 12615 || sd.getUniqueRowFilter() != EUniqueRowFilterType.urfDistinctOn) { 12616 return new ArrayList<>(); 12617 } 12618 TExpressionList el = sd.getExpressionList(); 12619 if (el == null || el.size() == 0) { 12620 // PG grammar requires at least one expression after 12621 // DISTINCT ON (; this branch is defensive — surface a 12622 // clear diagnostic rather than silently emit []. 12623 throw new SemanticIRBuildException( 12624 Diagnostic.error(DiagnosticCode.DISTINCT_ON_EMPTY_COLUMN_LIST, 12625 "DISTINCT ON requires at least one expression but the " 12626 + "AST exposes an empty list", sd)); 12627 } 12628 // Iterate items explicitly so each per-expression reject 12629 // diagnostic points at the offending expression. Equivalent 12630 // to running containsAnySubquery / rejectWindowFunctionInScope 12631 // / collectColumnRefs on the whole list (TExpressionList 12632 // inherits TParseTreeNodeList.acceptChildren which already 12633 // iterates element children), but the loop body gives 12634 // clearer rejection sites and lets us dedup refs across 12635 // expressions in declaration order. 12636 List<ColumnRef> refs = new ArrayList<>(); 12637 for (int i = 0; i < el.size(); i++) { 12638 TExpression expr = el.getExpression(i); 12639 if (containsAnySubqueryExpression(expr)) { 12640 throw new SemanticIRBuildException( 12641 Diagnostic.error(DiagnosticCode.DISTINCT_ON_HAS_SUBQUERY_NOT_SUPPORTED, 12642 "DISTINCT ON expression list contains a subquery; " 12643 + "subqueries in DISTINCT ON are not supported yet", sd)); 12644 } 12645 rejectWindowFunctionInScope(expr, "DISTINCT ON expression list"); 12646 for (ColumnRef ref : collectColumnRefs(expr, provider)) { 12647 if (!refs.contains(ref)) refs.add(ref); 12648 } 12649 } 12650 return refs; 12651 } 12652 12653 private static boolean hasNoFromSource(TSelectSqlStatement select) { 12654 return select.joins == null || select.joins.size() == 0; 12655 } 12656 12657 private static boolean allResultColumnsAreConstantExpressions(TSelectSqlStatement select) { 12658 TResultColumnList rcl = select.getResultColumnList(); 12659 if (rcl == null || rcl.size() == 0) return false; 12660 for (int i = 0; i < rcl.size(); i++) { 12661 TResultColumn rc = rcl.getResultColumn(i); 12662 if (rc == null || rc.getExpr() == null || !isConstantExpression(rc.getExpr())) { 12663 return false; 12664 } 12665 } 12666 return true; 12667 } 12668 12669 private static List<ColumnRef> buildGroupByColumnRefs(TSelectSqlStatement select, NameBindingProvider provider) { 12670 TGroupBy groupBy = select.getGroupByClause(); 12671 if (groupBy == null || groupBy.getItems() == null || groupBy.getItems().size() == 0) { 12672 return new ArrayList<>(); 12673 } 12674 TGroupByItemList items = groupBy.getItems(); 12675 // Slice 127: flatten plain, ROLLUP, CUBE, and GROUPING SETS items 12676 // into the set of grouping expressions before guarding/collecting. 12677 // A plain visitor over {@code items} silently drops every column 12678 // inside a compound grouping element, because the AST visitor links 12679 // are broken there: {@link TGroupByItem#acceptChildren} descends 12680 // ONLY into its plain {@code expr} (not {@code rollupCube}, 12681 // {@code groupingSet}, or {@code exprList}), and 12682 // {@link TRollupCube#acceptChildren} / 12683 // {@link TGroupingExpressionItem#acceptChildren} do not descend at 12684 // all. Pre-slice-127, `GROUP BY ROLLUP(a, c)` produced empty 12685 // groupByColumnRefs and a subquery/window function buried inside a 12686 // compound element bypassed the slice-61/slice-13 guards. We do NOT 12687 // patch the shared AST {@code acceptChildren} methods (that would 12688 // ripple into dlineage / resolver); the fix is localized here. 12689 List<TExpression> groupingExprs = flattenGroupByExpressions(items); 12690 // Slice 61: reject subqueries in GROUP BY before column collection 12691 // descends into them. Pre-slice-61, queries such as `SELECT 1 12692 // FROM employees GROUP BY (SELECT id FROM departments)` reached 12693 // the constant-only projection guard and failed there; with the 12694 // slice-61 lift the projection now builds and the GROUP BY 12695 // collection would leak `departments.id` into groupByColumnRefs 12696 // even though `departments` is not in {@code relations}, breaking 12697 // the IR invariant that column refs reference an in-scope 12698 // relation. Mirrors the WHERE / HAVING / ORDER BY subquery guards. 12699 // Slice 127 runs the guard over the flattened expressions so a 12700 // subquery inside ROLLUP/CUBE/GROUPING SETS is also rejected; the 12701 // anchor stays the {@code groupBy} node for contract stability. 12702 for (TExpression e : groupingExprs) { 12703 if (containsAnySubquery(e)) { 12704 throw new SemanticIRBuildException( 12705 Diagnostic.error(DiagnosticCode.GROUP_BY_HAS_SUBQUERY_NOT_SUPPORTED, 12706 "GROUP BY clause contains a subquery; subqueries in " 12707 + "GROUP BY are not supported yet", groupBy)); 12708 } 12709 } 12710 // Slice 13: reject window functions in GROUP BY before collection 12711 // (slice 127 runs the guard over the flattened expressions). 12712 for (TExpression e : groupingExprs) { 12713 rejectWindowFunctionInScope(e, "GROUP BY clause"); 12714 } 12715 // Visitor-based collection ensures column refs in any nested 12716 // expression (e.g. GROUP BY date_trunc('day', t)) are captured. 12717 // 12718 // Slice 127: collect with the Phase-1 source-table fallback enabled. 12719 // Resolver2's Phase-2 GROUP BY scope builder (processGroupBy) only 12720 // resolves columns inside a plain {@code item.getExpr()}; columns 12721 // inside ROLLUP / CUBE never reach processColumnReferences, so they 12722 // carry {@code resolution == null}. The slice-93 fallback promotes 12723 // such refs to EXACT_MATCH when Phase 1's linkColumnToTable has set 12724 // a source table and the SQL-written qualifier (if any) is 12725 // consistent with it — exactly the Hive multi-insert situation the 12726 // facet was built for. Plain grouping columns ARE resolved by 12727 // Resolver2 ({@code resolution != null}), so the fallback is inert 12728 // for them and the plain-GROUP-BY contract is unchanged. 12729 return collectColumnRefsFromExpressions( 12730 groupingExprs, provider.withSourceTableFallback(true)); 12731 } 12732 12733 /** 12734 * Flatten a {@link TGroupByItemList} into the ordered list of leaf 12735 * grouping {@link TExpression}s, descending into ROLLUP / CUBE / 12736 * GROUPING SETS compound elements that the AST visitor does not reach 12737 * (slice 127). Leaves are emitted in left-to-right document order. 12738 * 12739 * <p>Each {@link TGroupByItem} carries exactly one (mutually exclusive 12740 * per its {@code init}) of: a plain {@code expr}, an {@code exprList} 12741 * (parenthesized {@code GROUP BY (a, b)}), a {@link TRollupCube} 12742 * (ROLLUP / CUBE), or a {@link TGroupingSet} (GROUPING SETS). Per-item 12743 * descent into the compound forms uses an explicit stack (no recursion, 12744 * per the repo iterative-traversal rule) with reverse-push so leaves 12745 * pop in document order. 12746 */ 12747 private static List<TExpression> flattenGroupByExpressions(TGroupByItemList items) { 12748 List<TExpression> out = new ArrayList<>(); 12749 for (int i = 0; i < items.size(); i++) { 12750 TGroupByItem item = items.getGroupByItem(i); 12751 if (item == null) { 12752 continue; 12753 } 12754 flattenGroupByItem(item, out); 12755 } 12756 return out; 12757 } 12758 12759 /** 12760 * Flatten a single {@link TGroupByItem} into the ordered list of leaf 12761 * grouping {@link TExpression}s, appending to {@code out}. Extracted 12762 * from {@link #flattenGroupByExpressions} (slice 127) so slice 128's 12763 * {@link #buildGroupingElements} can collect the member columns of one 12764 * top-level grouping element independently. Descent into ROLLUP / CUBE 12765 * / GROUPING SETS uses an explicit stack (no recursion, per the repo 12766 * iterative-traversal rule) with reverse-push so leaves pop in 12767 * document order. 12768 */ 12769 private static void flattenGroupByItem(TGroupByItem item, List<TExpression> out) { 12770 if (item == null) { 12771 return; 12772 } 12773 if (item.getExpr() != null) { 12774 out.add(item.getExpr()); 12775 } 12776 addExpressionListItems(item.getExprList(), out); 12777 Deque<TParseTreeNode> stack = new ArrayDeque<>(); 12778 if (item.getRollupCube() != null) { 12779 stack.push(item.getRollupCube()); 12780 } 12781 if (item.getGroupingSet() != null) { 12782 stack.push(item.getGroupingSet()); 12783 } 12784 while (!stack.isEmpty()) { 12785 TParseTreeNode node = stack.pop(); 12786 if (node instanceof TRollupCube) { 12787 addExpressionListItems(((TRollupCube) node).getItems(), out); 12788 } else if (node instanceof TGroupingSet) { 12789 TGroupingSetItemList gsItems = ((TGroupingSet) node).getItems(); 12790 if (gsItems != null) { 12791 // reverse-push so set items pop in document order 12792 for (int j = gsItems.size() - 1; j >= 0; j--) { 12793 TGroupingSetItem gsi = gsItems.getGroupingSetItem(j); 12794 if (gsi != null) { 12795 stack.push(gsi); 12796 } 12797 } 12798 } 12799 } else if (node instanceof TGroupingSetItem) { 12800 TGroupingSetItem gsi = (TGroupingSetItem) node; 12801 // exactly one payload per item; add direct exprs, push 12802 // the compound child so its leaves emerge in order. 12803 if (gsi.getGrouping_expression() != null) { 12804 out.add(gsi.getGrouping_expression()); 12805 } 12806 if (gsi.getExpressionItem() != null) { 12807 stack.push(gsi.getExpressionItem()); 12808 } 12809 if (gsi.getRollupCubeClause() != null) { 12810 stack.push(gsi.getRollupCubeClause()); 12811 } 12812 } else if (node instanceof TGroupingExpressionItem) { 12813 TGroupingExpressionItem gei = (TGroupingExpressionItem) node; 12814 if (gei.getExpr() != null) { 12815 out.add(gei.getExpr()); 12816 } 12817 addExpressionListItems(gei.getExprList(), out); 12818 } 12819 } 12820 } 12821 12822 /** 12823 * Slice 128 — build the structured per-top-level-element view of the 12824 * {@code GROUP BY}: one {@link GroupingElement} per top-level 12825 * {@link TGroupByItem} in document order, tagged 12826 * {@code SIMPLE} / {@code ROLLUP} / {@code CUBE} / {@code GROUPING_SETS} 12827 * (via {@link #groupingElementKind}) and carrying that element's 12828 * flattened leaf member columns. 12829 * 12830 * <p>Returns an empty list when there is no {@code GROUP BY}. Runs 12831 * <i>after</i> {@link #buildGroupByColumnRefs} in the SELECT builder, so 12832 * the slice-61 subquery and slice-13 window-function guards have already 12833 * rejected illegal grouping expressions and the per-element member 12834 * collection (which reuses {@link #collectColumnRefsFromExpressions} 12835 * with the slice-93 source-table fallback, exactly as the flat path 12836 * does) cannot newly throw. Per-element collection means a column 12837 * appearing in two top-level elements (e.g. 12838 * {@code GROUP BY a, ROLLUP(a)}) is faithfully present in both — the 12839 * flat {@link #buildGroupByColumnRefs} dedups it to a single ref, which 12840 * is the intended difference between the two slots. 12841 */ 12842 private static List<GroupingElement> buildGroupingElements( 12843 TSelectSqlStatement select, NameBindingProvider provider) { 12844 TGroupBy groupBy = select.getGroupByClause(); 12845 if (groupBy == null || groupBy.getItems() == null || groupBy.getItems().size() == 0) { 12846 return new ArrayList<>(); 12847 } 12848 TGroupByItemList items = groupBy.getItems(); 12849 NameBindingProvider fallbackProvider = provider.withSourceTableFallback(true); 12850 List<GroupingElement> out = new ArrayList<>(); 12851 for (int i = 0; i < items.size(); i++) { 12852 TGroupByItem item = items.getGroupByItem(i); 12853 if (item == null) { 12854 continue; 12855 } 12856 List<TExpression> exprs = new ArrayList<>(); 12857 flattenGroupByItem(item, exprs); 12858 List<ColumnRef> members = collectColumnRefsFromExpressions(exprs, fallbackProvider); 12859 out.add(new GroupingElement(groupingElementKind(item), members)); 12860 } 12861 return out; 12862 } 12863 12864 /** 12865 * Classify the grouping operation of a single top-level 12866 * {@link TGroupByItem} (slice 128). The authoritative signals are the 12867 * AST node-presence checks: a non-null {@code groupingSet} is 12868 * {@code GROUPING SETS}; a non-null {@code rollupCube} carries its own 12869 * {@code rollup} / {@code cube} / {@code grouping_sets} operation 12870 * (BigQuery spells {@code GROUPING SETS} as a {@link TRollupCube} with 12871 * the {@code grouping_sets} operation). The 12872 * {@link EGroupingSetType} flag is a defensive fallback for any form 12873 * that sets the flag without populating the node; {@code gsExpr} / 12874 * {@code gsList} / {@code gsEmpty} and anything unrecognized fall 12875 * through to {@code SIMPLE} (the plain {@code expr} / {@code exprList} 12876 * grouping path). 12877 */ 12878 private static GroupingElement.Kind groupingElementKind(TGroupByItem item) { 12879 if (item.getGroupingSet() != null) { 12880 return GroupingElement.Kind.GROUPING_SETS; 12881 } 12882 TRollupCube rc = item.getRollupCube(); 12883 if (rc != null) { 12884 switch (rc.getOperation()) { 12885 case TRollupCube.cube: 12886 return GroupingElement.Kind.CUBE; 12887 case TRollupCube.grouping_sets: 12888 return GroupingElement.Kind.GROUPING_SETS; 12889 case TRollupCube.rollup: 12890 default: 12891 return GroupingElement.Kind.ROLLUP; 12892 } 12893 } 12894 EGroupingSetType gst = item.getGroupingSetType(); 12895 if (gst != null) { 12896 switch (gst) { 12897 case gsRollup: 12898 return GroupingElement.Kind.ROLLUP; 12899 case gsCube: 12900 return GroupingElement.Kind.CUBE; 12901 case gsSets: 12902 return GroupingElement.Kind.GROUPING_SETS; 12903 default: 12904 break; 12905 } 12906 } 12907 return GroupingElement.Kind.SIMPLE; 12908 } 12909 12910 /** Append every {@link TExpression} of {@code list} (if any) to {@code out}. */ 12911 private static void addExpressionListItems(TExpressionList list, List<TExpression> out) { 12912 if (list == null) { 12913 return; 12914 } 12915 for (int j = 0; j < list.size(); j++) { 12916 TExpression e = list.getExpression(j); 12917 if (e != null) { 12918 out.add(e); 12919 } 12920 } 12921 } 12922 12923 /** 12924 * Multi-root variant of {@link #collectColumnRefs}: collect EXACT_MATCH 12925 * physical {@link ColumnRef}s from each expression root in order, with 12926 * the same {@code nestedSelectDepth} guard, USING merged-key handling 12927 * via {@link #appendMergedOrBoundColumnRef}, {@link LinkedHashSet} 12928 * dedup, and {@code COLUMN_BINDING_NON_EXACT} rejection. Used by 12929 * {@link #buildGroupByColumnRefs} over the slice-127 flattened 12930 * ROLLUP / CUBE / GROUPING SETS grouping expressions. 12931 */ 12932 private static List<ColumnRef> collectColumnRefsFromExpressions( 12933 List<TExpression> exprs, final NameBindingProvider provider) { 12934 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 12935 final List<String> rejects = new ArrayList<>(); 12936 // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at 12937 // the wrong side). Such a miss is a genuine error and must stay fatal; 12938 // only all-unqualified rejects are eligible for the join-graph degrade. 12939 final boolean[] sawQualifiedReject = {false}; 12940 TParseTreeVisitor visitor = new TParseTreeVisitor() { 12941 int nestedSelectDepth = 0; 12942 12943 @Override 12944 public void preVisit(TSelectSqlStatement nested) { 12945 nestedSelectDepth++; 12946 } 12947 12948 @Override 12949 public void postVisit(TSelectSqlStatement nested) { 12950 nestedSelectDepth--; 12951 } 12952 12953 @Override 12954 public void preVisit(TObjectName node) { 12955 if (nestedSelectDepth > 0) return; 12956 appendMergedOrBoundColumnRef(node, provider, refs, rejects, 12957 sawQualifiedReject); 12958 } 12959 }; 12960 for (TExpression e : exprs) { 12961 e.acceptChildren(visitor); 12962 } 12963 if (!rejects.isEmpty()) { 12964 rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]); 12965 } 12966 return new ArrayList<>(refs); 12967 } 12968 12969 /** 12970 * Collect physical column references from the {@code HAVING} clause. 12971 * 12972 * <p>HAVING is supported regardless of whether {@code GROUP BY} is 12973 * present: standard SQL allows {@code HAVING} without {@code GROUP BY} 12974 * (the whole result set is treated as a single group), and the parser 12975 * still attaches a {@link TGroupBy} node with empty 12976 * {@code getItems()} in that case. Both shapes flow through the same 12977 * collection path. 12978 * 12979 * <p>Per-shape rejections fire <i>before</i> {@link #collectColumnRefs} 12980 * so subquery / OVER children never enter the visitor and can't leak 12981 * inner-scope refs into {@code havingColumnRefs} (mirrors slice-9 12982 * ORDER BY guards): 12983 * 12984 * <ul> 12985 * <li>Scalar subqueries ({@link EExpressionType#subquery_t}) and 12986 * predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)}, 12987 * {@code ANY/ALL/SOME}) — checked via both expression-type and 12988 * {@link TExpression#getSubQuery()}, deep-scanned through the 12989 * whole HAVING expression subtree.</li> 12990 * <li>Window functions ({@code OVER (...)}) — standard SQL forbids 12991 * window functions in HAVING, but defense in depth: the 12992 * deep-scan rejecter ensures PARTITION BY / OVER ORDER BY refs 12993 * can't leak.</li> 12994 * </ul> 12995 * 12996 * <p>Aggregate functions in HAVING are <i>not</i> rejected — they're 12997 * the most common HAVING shape ({@code HAVING SUM(salary) > 1000}). 12998 * The visitor walks into the aggregate's argument list and captures 12999 * the underlying column ref ({@code salary}) the same way slice 6 13000 * does for projection-side aggregate args. 13001 */ 13002 private static List<ColumnRef> buildHavingColumnRefs(TSelectSqlStatement select, 13003 NameBindingProvider provider) { 13004 TGroupBy groupBy = select.getGroupByClause(); 13005 if (groupBy == null) return new ArrayList<>(); 13006 TExpression having = groupBy.getHavingClause(); 13007 if (having == null) return new ArrayList<>(); 13008 rejectHavingScalarSubquery(having); 13009 rejectHavingWindowFunction(having); 13010 return collectColumnRefs(having, provider); 13011 } 13012 13013 /** 13014 * Reject HAVING expressions that contain a subquery anywhere in the 13015 * subtree. Catches both: 13016 * 13017 * <ul> 13018 * <li>Scalar subqueries ({@link EExpressionType#subquery_t}) — 13019 * e.g. {@code HAVING (SELECT MAX(salary) FROM employees) > 0}.</li> 13020 * <li>Predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)}, 13021 * {@code ANY/ALL/SOME (SELECT ...)}) — these don't appear as 13022 * {@code subquery_t} expression nodes but carry a non-null 13023 * {@link TExpression#getSubQuery()}, e.g. 13024 * {@code HAVING EXISTS (SELECT 1 FROM ...)} or 13025 * {@code HAVING d.id IN (SELECT id FROM ...)}.</li> 13026 * </ul> 13027 * 13028 * <p>Mirrors {@link #rejectOrderByScalarSubquery}: top-level fast 13029 * path + visitor deep-scan over {@link TExpression#acceptChildren}. 13030 * The deep scan is required for nested cases like 13031 * {@code HAVING flag = 1 AND EXISTS (SELECT ...)} or 13032 * {@code HAVING CASE WHEN d.id IN (SELECT ...) THEN 1 ELSE 0 END > 0}. 13033 */ 13034 private static void rejectHavingScalarSubquery(TExpression having) { 13035 if (having.getExpressionType() == EExpressionType.subquery_t 13036 || having.getSubQuery() != null) { 13037 throw new SemanticIRBuildException( 13038 Diagnostic.error(DiagnosticCode.HAVING_SUBQUERY_NOT_SUPPORTED, 13039 "HAVING subquery '" + having + "' is not supported yet " 13040 + "(subqueries in HAVING would leak inner column refs)", having)); 13041 } 13042 final boolean[] found = {false}; 13043 having.acceptChildren(new TParseTreeVisitor() { 13044 @Override 13045 public void preVisit(TExpression e) { 13046 if (found[0]) return; 13047 if (e.getExpressionType() == EExpressionType.subquery_t 13048 || e.getSubQuery() != null) { 13049 found[0] = true; 13050 } 13051 } 13052 }); 13053 if (found[0]) { 13054 throw new SemanticIRBuildException( 13055 Diagnostic.error(DiagnosticCode.HAVING_HAS_SUBQUERY_NOT_SUPPORTED, 13056 "HAVING expression '" + having + "' contains a subquery " 13057 + "(scalar, EXISTS, IN, or ANY/ALL/SOME); not supported yet", having)); 13058 } 13059 } 13060 13061 /** 13062 * Reject HAVING expressions that contain a window function. Standard 13063 * SQL forbids window functions in HAVING (analytic functions are 13064 * computed after HAVING), but defense in depth: the visitor would 13065 * descend into {@code OVER (PARTITION BY ... ORDER BY ...)} and the 13066 * inner-scope refs would otherwise leak into {@code havingColumnRefs}. 13067 * Mirrors the projection-side {@link #rejectWindowFunctions} and the 13068 * ORDER BY-side {@link #rejectOrderByWindowFunction}. 13069 */ 13070 private static void rejectHavingWindowFunction(TExpression having) { 13071 final boolean[] found = {false}; 13072 having.acceptChildren(new TParseTreeVisitor() { 13073 @Override 13074 public void preVisit(TFunctionCall fn) { 13075 if (found[0]) return; 13076 if (fn.getWindowDef() != null) found[0] = true; 13077 } 13078 }); 13079 if (!found[0] && having.getExpressionType() == EExpressionType.function_t) { 13080 TFunctionCall fn = having.getFunctionCall(); 13081 if (fn != null && fn.getWindowDef() != null) found[0] = true; 13082 } 13083 if (found[0]) { 13084 throw new SemanticIRBuildException( 13085 Diagnostic.error(DiagnosticCode.HAVING_WINDOW_FUNCTION_NOT_SUPPORTED, 13086 "HAVING window function '" + having + "' is not supported yet " 13087 + "(window OVER (...) refs would leak into havingColumnRefs)", having)); 13088 } 13089 } 13090 13091 /** 13092 * Slice 125: collect physical column references from a {@code QUALIFY} 13093 * clause's predicate (Snowflake / BigQuery / Teradata). QUALIFY filters 13094 * rows on window-function results; like {@code WHERE} / {@code HAVING} 13095 * it is row-influence, so its refs are captured into 13096 * {@link StatementGraph#getQualifyColumnRefs()}. 13097 * 13098 * <p>Two surface forms are admitted and reduce to the SAME influencing 13099 * base columns: 13100 * <ul> 13101 * <li>Inline window form 13102 * ({@code QUALIFY ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) = 1}) 13103 * — the window's PARTITION BY / ORDER BY / argument refs bind as 13104 * base columns and are collected directly; the function-name 13105 * node and {@code *} are skipped.</li> 13106 * <li>Projection-alias form ({@code QUALIFY rn = 1}) — the parser 13107 * leaves {@code rn} as a bare {@code column}-typed 13108 * {@link TObjectName} (it is NOT retyped to {@code column_alias} 13109 * the way ORDER BY operands are), so it does not bind to a base 13110 * column; it resolves to the matching {@link OutputColumn} and 13111 * contributes that output's {@code getSources()} unioned with its 13112 * window spec's partition/order refs.</li> 13113 * </ul> 13114 * 13115 * <p>Window functions are admitted (the whole point of QUALIFY) and are 13116 * NOT validated against the slice-13 projection window-function 13117 * allowlist — QUALIFY collects only the refs that influence the row 13118 * set; it does not build a {@link WindowSpec}. Subqueries (scalar, 13119 * EXISTS, IN-SELECT, ANY/ALL/SOME) are rejected before ref collection 13120 * (they would leak inner-scope refs), reusing the repurposed 13121 * {@link DiagnosticCode#QUALIFY_NOT_SUPPORTED} code. 13122 */ 13123 private static List<ColumnRef> buildQualifyColumnRefs(TSelectSqlStatement select, 13124 NameBindingProvider provider, 13125 List<OutputColumn> outputColumns) { 13126 if (select.getQualifyClause() == null) { 13127 return new ArrayList<>(); 13128 } 13129 TExpression qualify = select.getQualifyClause().getSearchConditoin(); 13130 if (qualify == null) { 13131 return new ArrayList<>(); 13132 } 13133 rejectQualifySubquery(qualify); 13134 rejectUnsupportedQualifyWindowFunction(qualify); 13135 return collectQualifyColumnRefs(qualify, provider, outputColumns); 13136 } 13137 13138 /** 13139 * Slice 126: validate the function NAME of any INLINE window function 13140 * inside a {@code QUALIFY} predicate against the slice-13 13141 * {@link #WINDOW_FUNCTION_NAMES} allowlist. Slice 125 admitted any 13142 * function in QUALIFY (collecting only its influencing refs); this guard 13143 * applies the same name check {@link #buildWindowOutputColumn} uses for 13144 * projection windows so an unfamiliar windowed function whose semantics 13145 * the engine does not model — {@code QUALIFY FOO(a) OVER (ORDER BY b) = 1} 13146 * — rejects with {@link DiagnosticCode#WINDOW_FUNCTION_UNSUPPORTED} 13147 * instead of silently admitting. 13148 * 13149 * <p>An INLINE window function is a {@link TFunctionCall} carrying 13150 * {@code OVER (...)} ({@code getWindowDef() != null}). This is the SAME 13151 * signal {@code buildWindowOutputColumn} keys on. Non-window functions 13152 * are deliberately left alone: 13153 * <ul> 13154 * <li>a plain UDF / scalar call with no {@code OVER} 13155 * ({@code QUALIFY my_udf(a) = 1}), and</li> 13156 * <li>a non-windowed aggregate ({@code QUALIFY SUM(a) = 1})</li> 13157 * </ul> 13158 * both have {@code getWindowDef() == null} and stay admitted — slice 126 13159 * validates window-function NAMES, it does not require QUALIFY to contain 13160 * a window function (that would be a broader semantic rule outside this 13161 * slice). The projection-alias form ({@code QUALIFY rn = 1}) carries no 13162 * {@link TFunctionCall} in the QUALIFY expression (the window function 13163 * lives in the projection, already slice-13 validated), so the guard is a 13164 * no-op there. 13165 * 13166 * <p>Nested SELECTs are skipped (a window function in an inner scope is 13167 * not this statement's QUALIFY window). In practice none survive — 13168 * {@link #rejectQualifySubquery} runs first and rejects any subquery — but 13169 * the {@code nestedSelectDepth} guard mirrors 13170 * {@link #collectQualifyColumnRefs} for defense-in-depth and parity. 13171 * 13172 * <p>Reuses the existing {@link DiagnosticCode#WINDOW_FUNCTION_UNSUPPORTED} 13173 * code (already reached by {@link #buildWindowOutputColumn}) so the 13174 * DiagnosticCode count stays unchanged. 13175 */ 13176 private static void rejectUnsupportedQualifyWindowFunction(TExpression qualify) { 13177 qualify.acceptChildren(new TParseTreeVisitor() { 13178 int nestedSelectDepth = 0; 13179 13180 @Override 13181 public void preVisit(TSelectSqlStatement nested) { 13182 nestedSelectDepth++; 13183 } 13184 13185 @Override 13186 public void postVisit(TSelectSqlStatement nested) { 13187 nestedSelectDepth--; 13188 } 13189 13190 @Override 13191 public void preVisit(TFunctionCall fn) { 13192 if (nestedSelectDepth > 0) return; 13193 if (fn.getWindowDef() == null) return; 13194 String fnName = fn.getFunctionName() == null 13195 ? null : fn.getFunctionName().toString(); 13196 if (fnName == null 13197 || !WINDOW_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) { 13198 throw new SemanticIRBuildException( 13199 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_UNSUPPORTED, 13200 "QUALIFY uses unsupported window function '" + fnName 13201 + "'; supported names are " + WINDOW_FUNCTION_NAMES, fn)); 13202 } 13203 } 13204 }); 13205 } 13206 13207 /** 13208 * Reject {@code QUALIFY} expressions that contain a subquery anywhere 13209 * in the subtree — scalar ({@link EExpressionType#subquery_t}) or 13210 * predicate ({@code EXISTS}, {@code IN (SELECT ...)}, 13211 * {@code ANY/ALL/SOME}, carried via {@link TExpression#getSubQuery()}). 13212 * Mirrors {@link #rejectHavingScalarSubquery}: top-level fast path plus 13213 * a visitor deep-scan. Reuses the (slice-125-repurposed) 13214 * {@link DiagnosticCode#QUALIFY_NOT_SUPPORTED} code so the 13215 * DiagnosticCode count stays unchanged and the code stays reached. 13216 */ 13217 private static void rejectQualifySubquery(TExpression qualify) { 13218 boolean topLevelSubquery = qualify.getExpressionType() == EExpressionType.subquery_t 13219 || qualify.getSubQuery() != null; 13220 final boolean[] found = {topLevelSubquery}; 13221 if (!found[0]) { 13222 qualify.acceptChildren(new TParseTreeVisitor() { 13223 @Override 13224 public void preVisit(TExpression e) { 13225 if (found[0]) return; 13226 if (e.getExpressionType() == EExpressionType.subquery_t 13227 || e.getSubQuery() != null) { 13228 found[0] = true; 13229 } 13230 } 13231 }); 13232 } 13233 if (found[0]) { 13234 throw new SemanticIRBuildException( 13235 Diagnostic.error(DiagnosticCode.QUALIFY_NOT_SUPPORTED, 13236 "QUALIFY subquery '" + qualify + "' is not supported yet " 13237 + "(subqueries in QUALIFY would leak inner column refs)", qualify)); 13238 } 13239 } 13240 13241 /** 13242 * Slice 125: QUALIFY-specific column-ref collector. Mirrors 13243 * {@link #collectColumnRefs} / {@link #appendMergedOrBoundColumnRef} 13244 * (nested-SELECT skip, UsingScope merged-key expansion, EXACT_MATCH 13245 * binding) but adds a projection-alias fallback: an unqualified 13246 * {@code column}-typed {@link TObjectName} that does not bind to a base 13247 * column but matches an {@link OutputColumn} name resolves to that 13248 * output's influencing base columns ({@code getSources()} unioned with 13249 * its window spec's partition/order refs). This captures the canonical 13250 * {@code QUALIFY rn = 1} form, where {@code rn} aliases a window 13251 * projection whose own {@code getSources()} is empty. 13252 */ 13253 private static List<ColumnRef> collectQualifyColumnRefs(TExpression qualify, 13254 final NameBindingProvider provider, 13255 final List<OutputColumn> outputColumns) { 13256 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 13257 final List<String> rejects = new ArrayList<>(); 13258 // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at 13259 // the wrong side). Such a miss is a genuine error and must stay fatal; 13260 // only all-unqualified rejects are eligible for the join-graph degrade. 13261 final boolean[] sawQualifiedReject = {false}; 13262 qualify.acceptChildren(new TParseTreeVisitor() { 13263 int nestedSelectDepth = 0; 13264 13265 @Override 13266 public void preVisit(TSelectSqlStatement nested) { 13267 nestedSelectDepth++; 13268 } 13269 13270 @Override 13271 public void postVisit(TSelectSqlStatement nested) { 13272 nestedSelectDepth--; 13273 } 13274 13275 @Override 13276 public void preVisit(TObjectName node) { 13277 if (nestedSelectDepth > 0) return; 13278 if (node.getDbObjectType() != EDbObjectType.column) return; 13279 String name = node.getColumnNameOnly(); 13280 if (name == null || "*".equals(name)) return; 13281 UsingScope scope = provider.getUsingScope(); 13282 String qualifier = node.getTableString(); 13283 boolean unqualified = qualifier == null || qualifier.isEmpty(); 13284 if (unqualified && scope.has(name)) { 13285 if (scope.isAmbiguous(name)) { 13286 throw new SemanticIRBuildException( 13287 Diagnostic.error(DiagnosticCode.UNQUALIFIED_COLUMN_AMBIGUOUS, 13288 "unqualified reference to '" + name + "' is ambiguous: " 13289 + scope.ambiguityReason(name) 13290 + "; qualify with a table alias", null)); 13291 } 13292 refs.addAll(scope.mergedSourcesFor(name)); 13293 return; 13294 } 13295 ColumnBinding binding = provider.bindColumn(node); 13296 if (binding != null && binding.getStatus() == ResolutionStatus.EXACT_MATCH) { 13297 refs.add(new ColumnRef(binding.getRelationAlias(), binding.getColumnName())); 13298 return; 13299 } 13300 // Base-column binding failed. Projection-alias fallback: 13301 // an unqualified name that matches an output column's name 13302 // (case-insensitive) resolves to that output's influencing 13303 // base columns. QUALIFY (post-window) can reference SELECT 13304 // aliases in Snowflake / BigQuery. 13305 if (unqualified) { 13306 List<ColumnRef> aliasSources = resolveQualifyProjectionAlias(name, outputColumns); 13307 if (aliasSources != null) { 13308 refs.addAll(aliasSources); 13309 return; 13310 } 13311 // Catalog-less NATURAL JOIN degrade (GSP R6): unqualified 13312 // merged NATURAL key resolves to the left-side relation. 13313 if (scope.hasNaturalDegrade()) { 13314 refs.add(new ColumnRef(scope.naturalDegradeFallbackAlias(), name)); 13315 return; 13316 } 13317 } 13318 rejects.add(node + (binding == null ? "[no binding]" 13319 : "[" + binding.getStatus() + "]")); 13320 if (!unqualified) { 13321 sawQualifiedReject[0] = true; 13322 } 13323 } 13324 }); 13325 if (!rejects.isEmpty()) { 13326 rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]); 13327 } 13328 return new ArrayList<>(refs); 13329 } 13330 13331 /** 13332 * Slice 125: resolve an unqualified QUALIFY reference name to the 13333 * influencing base columns of the matching projection output column. 13334 * The influencing columns are the output's 13335 * {@link OutputColumn#getSources()} unioned with its window spec's 13336 * {@link WindowSpec#getPartitionRefs()} and 13337 * {@link WindowSpec#getOrderRefs()} — a window output's own 13338 * {@code getSources()} is empty, so the partition/order refs are where 13339 * the row-influencing base columns live. 13340 * 13341 * <p>Return contract (the caller distinguishes the two non-throwing 13342 * cases by {@code null} vs empty): 13343 * <ul> 13344 * <li>{@code null} — no output column matches by name; the caller 13345 * records a {@code COLUMN_BINDING_NON_EXACT} reject.</li> 13346 * <li>non-empty list — the matched output's influencing base 13347 * columns.</li> 13348 * <li><b>empty list</b> — an output matched but it has no influencing 13349 * base columns (a constant projection such as {@code SELECT 1 AS x} 13350 * referenced by {@code QUALIFY x = 1}, or any sourceless / 13351 * window-less projection). This is an admitted, deliberate 13352 * boundary: a QUALIFY predicate over a column-free output has no 13353 * column dependency, exactly like a constant {@code WHERE 1 = 1} 13354 * contributes no filter refs. It is NOT treated as a reject — the 13355 * ref resolves successfully and simply contributes no base-column 13356 * dependency.</li> 13357 * </ul> 13358 * 13359 * <p>Match strategy: case-insensitive ({@link Locale#ROOT}), FIRST 13360 * (leftmost) match — identical to {@link #tryResolveOrderByProjectionAlias}. 13361 * Duplicate projection aliases ({@code SELECT a AS rn, b AS rn ... 13362 * QUALIFY rn = 1}) therefore resolve to the leftmost projection rather 13363 * than rejecting as ambiguous; this is the documented slice-69 ORDER BY 13364 * boundary applied to QUALIFY for cross-clause consistency. 13365 * 13366 * <p>Resolution order in the caller is base-column-first: a name that 13367 * binds {@code EXACT_MATCH} to a base column is emitted directly and 13368 * never reaches this fallback, which fires only for an UNQUALIFIED ref 13369 * whose base binding failed. A qualified ref ({@code QUALIFY e.rn}) is 13370 * never alias-resolved here (qualifying a projection alias is invalid 13371 * SQL); it binds through the provider like any other qualified ref. 13372 */ 13373 private static List<ColumnRef> resolveQualifyProjectionAlias(String name, 13374 List<OutputColumn> outputColumns) { 13375 String key = name.toLowerCase(Locale.ROOT); 13376 for (OutputColumn oc : outputColumns) { 13377 String outName = oc.getName(); 13378 if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) { 13379 LinkedHashSet<ColumnRef> influencing = new LinkedHashSet<>(oc.getSources()); 13380 WindowSpec ws = oc.getWindowSpec(); 13381 if (ws != null) { 13382 influencing.addAll(ws.getPartitionRefs()); 13383 influencing.addAll(ws.getOrderRefs()); 13384 } 13385 return new ArrayList<>(influencing); 13386 } 13387 } 13388 return null; 13389 } 13390 13391 /** 13392 * Collect physical column references from {@code ORDER BY} sort keys. 13393 * 13394 * <p>Per-item validation rejects shapes that would otherwise vanish 13395 * silently into an empty ref list, leak inner-scope refs, or 13396 * misrepresent presentation as a dependency: 13397 * 13398 * <ul> 13399 * <li>Ordinal references ({@code ORDER BY 1}) — the sort key is a 13400 * {@link EExpressionType#simple_constant_t}; its meaning is 13401 * "first projected column" which depends on the SELECT list, 13402 * not on a base column. A future slice can model output-position 13403 * references explicitly.</li> 13404 * <li>Constant sort keys other than ordinals ({@code ORDER BY 'x'}, 13405 * and the compound {@code ORDER BY (1)} / {@code ORDER BY 1+0} 13406 * caught by the generic no-physical-column-refs check).</li> 13407 * <li>Projection-alias references ({@code ORDER BY x} where 13408 * {@code x} is a SELECT alias) — {@link TOrderByItem#doParse} 13409 * retypes the operand to {@link TObjectName#ttobjColumnAlias}, 13410 * which lowers {@link TObjectName#getDbObjectType()} to 13411 * {@link EDbObjectType#column_alias}. Without explicit 13412 * rejection the visitor would skip it and the IR would lose 13413 * the dependency entirely. The deep-scan version of this 13414 * check catches alias nodes nested inside expressions.</li> 13415 * <li>Subqueries in sort keys — scalar 13416 * ({@link EExpressionType#subquery_t}) and predicate 13417 * ({@code EXISTS}, {@code IN (SELECT ...)}, {@code ANY/ALL/SOME}) 13418 * — would otherwise leak inner-scope column refs into the 13419 * outer statement's {@code orderByColumnRefs}.</li> 13420 * <li>Window functions in sort keys ({@code ORDER BY ROW_NUMBER() 13421 * OVER (...)}) — the OVER clause descends through the visitor 13422 * and would leak its PARTITION BY / ORDER BY refs.</li> 13423 * </ul> 13424 * 13425 * <p>Sub-clauses that change row-set semantics are also rejected 13426 * here: Oracle {@code ORDER SIBLINGS BY} (hierarchical, not yet 13427 * modelled), Teradata {@code RESET WHEN} (window-style restart), 13428 * and the {@link TOrderBy}-level {@code FETCH FIRST}/{@code OFFSET} 13429 * defensive guards (in fresh parses the SELECT-level row-limit 13430 * guards in {@link #rejectUnsupportedShape} fire first because 13431 * {@code TSelectSqlNode.setOrderbyClause()} copies in-clause OFFSET/ 13432 * FETCH onto the SELECT node). 13433 * 13434 * <p>For everything else (qualified column refs, expressions like 13435 * {@code UPPER(name)}), {@link #collectColumnRefs} runs over each 13436 * sort key and aggregates the physical column refs. A per-item 13437 * empty-refs check catches anything that slipped past the explicit 13438 * shape rejections (e.g. {@code ORDER BY (1)}, 13439 * {@code ORDER BY 1 + 0}). Sort direction ({@code ASC}/{@code DESC}) 13440 * and null placement ({@code NULLS FIRST}/{@code NULLS LAST}) are 13441 * presentation metadata and are not modelled. 13442 */ 13443 private static List<ColumnRef> buildOrderByColumnRefs(TSelectSqlStatement select, 13444 NameBindingProvider provider, 13445 List<OutputColumn> outputColumns) { 13446 TOrderBy orderBy = select.getOrderbyClause(); 13447 if (orderBy == null) { 13448 return new ArrayList<>(); 13449 } 13450 if (orderBy.isSiblings()) { 13451 throw new SemanticIRBuildException( 13452 Diagnostic.error(DiagnosticCode.ORDER_SIBLINGS_BY_NOT_SUPPORTED, 13453 "ORDER SIBLINGS BY is not supported yet " 13454 + "(Oracle hierarchical ordering)", orderBy)); 13455 } 13456 if (orderBy.getResetWhenCondition() != null) { 13457 throw new SemanticIRBuildException( 13458 Diagnostic.error(DiagnosticCode.ORDER_BY_RESET_WHEN_NOT_SUPPORTED, 13459 "ORDER BY ... RESET WHEN is not supported yet " 13460 + "(Teradata window-style restart)", orderBy)); 13461 } 13462 // Slice 71: the in-clause OFFSET/FETCH on TOrderBy is no longer 13463 // rejected. MSSQL parsers duplicate OFFSET/FETCH onto BOTH the 13464 // SELECT node AND the TOrderBy node; slice 71 admits at the 13465 // SELECT level via buildRowLimit, so the TOrderBy duplicates 13466 // are simply ignored here. Oracle parsers populate only the 13467 // SELECT-level fields, so the TOrderBy fields are typically 13468 // null there. 13469 TOrderByItemList items = orderBy.getItems(); 13470 if (items == null || items.size() == 0) { 13471 return new ArrayList<>(); 13472 } 13473 // Validate + collect per item so a sort key contributing zero 13474 // column refs (e.g. constant arithmetic, parenthesised constant) 13475 // is rejected with an item-specific message instead of silently 13476 // disappearing. 13477 LinkedHashSet<ColumnRef> all = new LinkedHashSet<>(); 13478 for (int i = 0; i < items.size(); i++) { 13479 TOrderByItem item = items.getOrderByItem(i); 13480 if (item == null) continue; 13481 TExpression sortKey = item.getSortKey(); 13482 if (sortKey == null) continue; 13483 // Slice 68: positive-integer ordinals admit. The helper: 13484 // - returns null when sortKey is not a positive-integer 13485 // literal (caller falls through to the existing 13486 // constant / alias / subquery / window rejecters and the 13487 // standard ref collection); 13488 // - returns the matching output column's sources list when 13489 // sortKey IS a positive-integer literal in range; 13490 // - throws ORDER_BY_ORDINAL_OUT_OF_RANGE when the ordinal 13491 // is 0 or exceeds the output column count. 13492 // The sourceless-output case (e.g. SELECT 1 FROM t ORDER BY 1 13493 // or SELECT COUNT(*) FROM t ORDER BY 1) returns an empty 13494 // list and falls through to the per-item empty-refs guard 13495 // below, mirroring the existing ORDER BY COUNT(*) / 13496 // ORDER BY 1 + 0 rejection. 13497 List<ColumnRef> ordinalSources = tryResolveOrderByOrdinal(sortKey, outputColumns); 13498 if (ordinalSources != null) { 13499 if (ordinalSources.isEmpty()) { 13500 throw new SemanticIRBuildException( 13501 Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS, 13502 "ORDER BY ordinal '" + sortKey 13503 + "' resolves to output column with no physical column references " 13504 + "(constant or sourceless aggregate output)", sortKey)); 13505 } 13506 all.addAll(ordinalSources); 13507 continue; 13508 } 13509 // Slice 69: top-level projection-alias references admit. The 13510 // helper returns null for non-alias shapes; an empty list 13511 // (alias of a constant / sourceless aggregate) falls through 13512 // to ORDER_BY_NO_PHYSICAL_COLUMN_REFS, mirroring the slice-68 13513 // sourceless-ordinal handling. Deep-scan alias references 13514 // (e.g. ORDER BY UPPER(<alias>)) are still caught by 13515 // rejectOrderByAliasReference below. 13516 List<ColumnRef> aliasSources = tryResolveOrderByProjectionAlias(sortKey, outputColumns); 13517 if (aliasSources != null) { 13518 if (aliasSources.isEmpty()) { 13519 throw new SemanticIRBuildException( 13520 Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS, 13521 "ORDER BY projection alias '" + sortKey 13522 + "' resolves to output column with no physical column references " 13523 + "(constant or sourceless aggregate output)", sortKey)); 13524 } 13525 all.addAll(aliasSources); 13526 continue; 13527 } 13528 // Slice 68: non-ordinal constants stay rejected. The original 13529 // helper is preserved for the set-op outer path (which keeps 13530 // its ordinal/constant rejection until slice 72). 13531 rejectOrderByNonOrdinalConstant(sortKey); 13532 // Slice 69: top-level bare alias references are consumed by 13533 // tryResolveOrderByProjectionAlias above; this helper now only 13534 // catches DEEP alias references nested inside compound 13535 // expressions (e.g. ORDER BY UPPER(<alias>)). 13536 rejectOrderByAliasReference(sortKey); 13537 // Reject scalar subqueries and window functions BEFORE 13538 // collecting refs. The visitor descends into both, so without 13539 // these guards `ORDER BY (SELECT MAX(salary) FROM employees)` 13540 // and `ORDER BY ROW_NUMBER() OVER (ORDER BY salary)` would 13541 // leak inner refs into orderByColumnRefs as if the outer 13542 // statement physically depended on them. 13543 rejectOrderByScalarSubquery(sortKey); 13544 rejectOrderByWindowFunction(sortKey); 13545 List<ColumnRef> itemRefs = collectColumnRefs(item, provider); 13546 if (itemRefs.isEmpty()) { 13547 // Anything else that produces no physical column refs: 13548 // ORDER BY (1), ORDER BY 1+0, ORDER BY NULL, ORDER BY 13549 // CASE WHEN 1=1 THEN 'a' END, etc. Reject so the IR 13550 // doesn't silently emit empty refs. 13551 throw new SemanticIRBuildException( 13552 Diagnostic.error(DiagnosticCode.ORDER_BY_NO_PHYSICAL_COLUMN_REFS, 13553 "ORDER BY sort key '" + sortKey + "' has no physical column references " 13554 + "(constant or non-column expressions are not supported yet)", sortKey)); 13555 } 13556 all.addAll(itemRefs); 13557 } 13558 return new ArrayList<>(all); 13559 } 13560 13561 /** 13562 * Slice 68: resolve a positive-integer ORDER BY ordinal to the matching 13563 * output column's sources. Returns: 13564 * 13565 * <ul> 13566 * <li>{@code null} if {@code sortKey} is not a positive-integer 13567 * literal (caller continues with the constant / alias / subquery 13568 * / window rejecters and the standard ref collection);</li> 13569 * <li>a {@link List} of {@link ColumnRef}s — the source list of the 13570 * output column at position {@code v - 1} (1-based ordinals);</li> 13571 * <li>throws {@link SemanticIRBuildException} with 13572 * {@code ORDER_BY_ORDINAL_OUT_OF_RANGE} when {@code v} is 0 or 13573 * exceeds the output column count.</li> 13574 * </ul> 13575 * 13576 * <p>{@code sortKey.getExpressionType() == simple_constant_t} for bare 13577 * positive integers; negative integers parse as a {@code unary_minus_t} 13578 * over a {@code simple_constant_t} and are not handled here. Compound 13579 * constant expressions ({@code ORDER BY 1 + 0}, {@code ORDER BY (1)}) 13580 * are {@code arithmetic_*_t} / {@code parenthesis_t} respectively and 13581 * fall through to the per-item empty-refs guard. 13582 * 13583 * <p>The empty-list case (output column resolved with 13584 * {@link OutputColumn#getSources()} empty — constant projections, 13585 * {@code COUNT(*)}, sourceless aggregates) is returned to the caller 13586 * which fires {@code ORDER_BY_NO_PHYSICAL_COLUMN_REFS}. Slice 68 13587 * boundary. 13588 * 13589 * <p>Sort direction (ASC/DESC) and null placement (NULLS FIRST/LAST) 13590 * are presentation metadata on {@link TOrderByItem}, not on the sort 13591 * key expression; this helper doesn't inspect them (slice 9 decision). 13592 */ 13593 private static List<ColumnRef> tryResolveOrderByOrdinal(TExpression sortKey, 13594 List<OutputColumn> outputs) { 13595 if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) { 13596 return null; 13597 } 13598 String txt = sortKey.toString(); 13599 if (txt == null || !txt.matches("\\d+")) { 13600 return null; 13601 } 13602 long v; 13603 try { 13604 v = Long.parseLong(txt); 13605 } catch (NumberFormatException e) { 13606 // Very-long-digit text overflows long; definitely out of range. 13607 throw new SemanticIRBuildException( 13608 Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_OUT_OF_RANGE, 13609 "ORDER BY ordinal '" + sortKey + "' is out of range " 13610 + "(must be between 1 and " + outputs.size() + ")", sortKey)); 13611 } 13612 if (v < 1 || v > outputs.size()) { 13613 throw new SemanticIRBuildException( 13614 Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_OUT_OF_RANGE, 13615 "ORDER BY ordinal '" + sortKey + "' is out of range " 13616 + "(must be between 1 and " + outputs.size() + ")", sortKey)); 13617 } 13618 return outputs.get((int) v - 1).getSources(); 13619 } 13620 13621 /** 13622 * Slice 69: resolve a top-level bare projection-alias ORDER BY sort 13623 * key to the matching output column's sources. Returns: 13624 * 13625 * <ul> 13626 * <li>{@code null} if {@code sortKey} is not a top-level bare 13627 * {@code simple_object_name_t} whose object operand has 13628 * {@code dbObjectType == EDbObjectType.column_alias} (caller 13629 * continues with {@link #rejectOrderByAliasReference} for the 13630 * deep-scan case and the standard column-ref collection);</li> 13631 * <li>a {@link List} of {@link ColumnRef}s — the matching output's 13632 * source list (which may be empty when the aliased projection 13633 * is a constant or sourceless aggregate; the caller fires 13634 * {@code ORDER_BY_NO_PHYSICAL_COLUMN_REFS} in that case);</li> 13635 * <li>throws {@link SemanticIRBuildException} with 13636 * {@code ORDER_BY_PROJECTION_ALIAS_NOT_SUPPORTED} when the 13637 * parser retyped the operand to {@code column_alias} but no 13638 * matching output exists by case-insensitive name (defensive; 13639 * theoretically unreachable for parsable SQL).</li> 13640 * </ul> 13641 * 13642 * <p>Match strategy: case-insensitive ({@link Locale#ROOT}) on 13643 * {@link OutputColumn#getName()}, returning the FIRST match. This 13644 * mirrors the set-op outer alias matcher in 13645 * {@link #processSetOpOrderByObjectName} (which uses the identical 13646 * {@code toLowerCase(Locale.ROOT)} pattern and {@code break}s on 13647 * first match) and follows MySQL / PostgreSQL ORDER BY alias 13648 * resolution semantics. Duplicate aliases (e.g. {@code SELECT a AS x, 13649 * b AS x FROM t ORDER BY x}) resolve to the leftmost matching 13650 * projection; this is the documented slice-69 boundary. 13651 * 13652 * <p>The set-op outer alias path 13653 * ({@link #buildSetOpOuterOrderByColumnRefs} → 13654 * {@link #processSetOpOrderByObjectName}) was already admitted by 13655 * slice 21 and is independent of this helper. 13656 * 13657 * <p>Deep-scan alias references inside compound expressions 13658 * ({@code ORDER BY UPPER(<alias>)}) are NOT handled here — the 13659 * parser only retypes the top-level operand to {@code column_alias}; 13660 * inside nested expressions the alias may or may not be retyped by 13661 * resolver2 depending on schema heuristics. The slice-9 13662 * {@code orderByNestedAliasReferenceIsHandledSafely} contract is 13663 * preserved: deep alias refs are caught by 13664 * {@link #rejectOrderByAliasReference} with 13665 * {@code ORDER_BY_UNSUPPORTED_SORT_KEY_SHAPE} or by a binding 13666 * failure. 13667 */ 13668 private static List<ColumnRef> tryResolveOrderByProjectionAlias( 13669 TExpression sortKey, List<OutputColumn> outputs) { 13670 if (sortKey.getExpressionType() != EExpressionType.simple_object_name_t) { 13671 return null; 13672 } 13673 TObjectName op = sortKey.getObjectOperand(); 13674 if (op == null || op.getDbObjectType() != EDbObjectType.column_alias) { 13675 return null; 13676 } 13677 String name = op.toString(); 13678 if (name == null || name.isEmpty()) { 13679 return null; 13680 } 13681 String key = name.toLowerCase(Locale.ROOT); 13682 for (OutputColumn oc : outputs) { 13683 String outName = oc.getName(); 13684 if (outName != null && outName.toLowerCase(Locale.ROOT).equals(key)) { 13685 return oc.getSources(); 13686 } 13687 } 13688 throw new SemanticIRBuildException( 13689 Diagnostic.error(DiagnosticCode.ORDER_BY_PROJECTION_ALIAS_NOT_SUPPORTED, 13690 "ORDER BY projection alias '" + sortKey 13691 + "' does not match any output column " 13692 + "(defensive — parser retyped to column_alias " 13693 + "but no output by that name)", sortKey)); 13694 } 13695 13696 /** 13697 * Slice 68: reject ORDER BY sort keys that are constants but NOT 13698 * positive-integer ordinals. The positive-integer ordinal case is 13699 * admitted separately by {@link #tryResolveOrderByOrdinal} which maps 13700 * the ordinal to the matching output column's sources. This helper 13701 * handles the remaining constant shapes ({@code ORDER BY 'x'}, 13702 * {@code ORDER BY 3.14}) — none of which reference an output position 13703 * and so contribute no column dependency. 13704 * 13705 * <p>The set-op outer ORDER BY path 13706 * ({@link #buildSetOpOuterOrderByColumnRefs}) keeps the original 13707 * {@link #rejectOrderByOrdinalOrConstant} helper so ordinals at that 13708 * scope stay rejected (slice 68 lifts only the single-SELECT case; 13709 * slice 72 will lift set-op outer). 13710 */ 13711 private static void rejectOrderByNonOrdinalConstant(TExpression sortKey) { 13712 if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) { 13713 return; 13714 } 13715 String txt = sortKey.toString(); 13716 boolean looksOrdinal = txt != null && txt.matches("\\d+"); 13717 if (looksOrdinal) { 13718 // Admitted by tryResolveOrderByOrdinal; this helper is a no-op 13719 // for positive-integer ordinals. 13720 return; 13721 } 13722 throw new SemanticIRBuildException( 13723 Diagnostic.error(DiagnosticCode.ORDER_BY_CONSTANT_NOT_SUPPORTED, 13724 "ORDER BY constant '" + sortKey + "' is not supported yet " 13725 + "(constant sort keys add no column dependency)", sortKey)); 13726 } 13727 13728 /** 13729 * Reject ORDER BY sort keys that contain a subquery anywhere in the 13730 * subtree. Catches both: 13731 * 13732 * <ul> 13733 * <li>Scalar subqueries ({@link EExpressionType#subquery_t}) — 13734 * e.g. {@code ORDER BY (SELECT MAX(salary) FROM employees)}.</li> 13735 * <li>Predicate subqueries ({@code EXISTS}, {@code IN (SELECT ...)}, 13736 * {@code ANY/ALL/SOME (SELECT ...)}) — these don't appear as a 13737 * {@code subquery_t} expression but carry a non-null 13738 * {@link TExpression#getSubQuery()}, e.g. 13739 * {@code ORDER BY CASE WHEN EXISTS (SELECT 1 FROM t WHERE ...) 13740 * THEN 0 ELSE 1 END}.</li> 13741 * </ul> 13742 * 13743 * <p>The visitor descends into the subquery body, so without an 13744 * explicit reject the inner-scope refs would leak into the outer 13745 * statement's {@code orderByColumnRefs}. The same restriction is 13746 * applied to scalar subqueries in projection (see 13747 * {@link #buildOutputColumns}). 13748 */ 13749 private static void rejectOrderByScalarSubquery(TExpression sortKey) { 13750 // Top-level fast path: scalar-subquery message for the common case. 13751 if (sortKey.getExpressionType() == EExpressionType.subquery_t 13752 || sortKey.getSubQuery() != null) { 13753 throw new SemanticIRBuildException( 13754 Diagnostic.error(DiagnosticCode.ORDER_BY_SUBQUERY_NOT_SUPPORTED, 13755 "ORDER BY subquery '" + sortKey + "' is not supported yet " 13756 + "(subqueries in sort keys would leak inner column refs)", sortKey)); 13757 } 13758 // Deep scan: any nested expression that owns a subquery (scalar, 13759 // EXISTS, IN (SELECT ...), ANY/ALL/SOME) makes the sort key 13760 // out of scope. 13761 final boolean[] found = {false}; 13762 sortKey.acceptChildren(new TParseTreeVisitor() { 13763 @Override 13764 public void preVisit(TExpression e) { 13765 if (found[0]) return; 13766 if (e.getExpressionType() == EExpressionType.subquery_t 13767 || e.getSubQuery() != null) { 13768 found[0] = true; 13769 } 13770 } 13771 }); 13772 if (found[0]) { 13773 throw new SemanticIRBuildException( 13774 Diagnostic.error(DiagnosticCode.ORDER_BY_HAS_SUBQUERY_NOT_SUPPORTED, 13775 "ORDER BY sort key '" + sortKey + "' contains a subquery " 13776 + "(scalar, EXISTS, IN, or ANY/ALL/SOME); not supported yet", sortKey)); 13777 } 13778 } 13779 13780 /** 13781 * Reject ORDER BY sort keys that contain a window function. Window 13782 * functions descend through {@link TFunctionCall#acceptChildren()} so 13783 * their PARTITION BY / ORDER BY column refs would otherwise leak into 13784 * the outer statement's {@code orderByColumnRefs}. Mirrors the 13785 * projection-side {@link #rejectWindowFunctions}, but wired through 13786 * the ORDER BY item-walk instead of the result-column list. 13787 */ 13788 private static void rejectOrderByWindowFunction(TExpression sortKey) { 13789 final boolean[] found = {false}; 13790 sortKey.acceptChildren(new TParseTreeVisitor() { 13791 @Override 13792 public void preVisit(TFunctionCall fn) { 13793 if (found[0]) return; 13794 if (fn.getWindowDef() != null) found[0] = true; 13795 } 13796 }); 13797 if (!found[0] && sortKey.getExpressionType() == EExpressionType.function_t) { 13798 TFunctionCall fn = sortKey.getFunctionCall(); 13799 if (fn != null && fn.getWindowDef() != null) found[0] = true; 13800 } 13801 if (found[0]) { 13802 throw new SemanticIRBuildException( 13803 Diagnostic.error(DiagnosticCode.ORDER_BY_WINDOW_FUNCTION_NOT_SUPPORTED, 13804 "ORDER BY window function '" + sortKey + "' is not supported yet " 13805 + "(window OVER (...) refs would leak into orderByColumnRefs)", sortKey)); 13806 } 13807 } 13808 13809 /** 13810 * Reject ORDER BY sort keys that are bare constants. Splits the 13811 * message between integer ordinals (which reference the SELECT 13812 * position) and other constants (which add no column dependency). The 13813 * generic no-physical-column-refs check in 13814 * {@link #buildOrderByColumnRefs} catches compound cases like 13815 * {@code ORDER BY (1)} or {@code ORDER BY 1 + 0}. 13816 * 13817 * <p><b>Slice 68:</b> the single-SELECT call site no longer uses this 13818 * helper because positive-integer ordinals now resolve to the matching 13819 * output column's sources (see {@link #tryResolveOrderByOrdinal}). 13820 * This helper remains for {@link #buildSetOpOuterOrderByColumnRefs}, 13821 * where ordinal lifting is deferred to slice 72 (set-op outer 13822 * ORDER BY needs output-position references against the set-op output 13823 * row type, not the single-SELECT output column list). 13824 */ 13825 private static void rejectOrderByOrdinalOrConstant(TExpression sortKey) { 13826 if (sortKey.getExpressionType() != EExpressionType.simple_constant_t) { 13827 return; 13828 } 13829 String txt = sortKey.toString(); 13830 boolean looksOrdinal = txt != null && txt.matches("\\d+"); 13831 if (looksOrdinal) { 13832 throw new SemanticIRBuildException( 13833 Diagnostic.error(DiagnosticCode.ORDER_BY_ORDINAL_NOT_SUPPORTED, 13834 "ORDER BY ordinal '" + sortKey + "' is not supported yet " 13835 + "(reference the column or expression directly)", sortKey)); 13836 } 13837 throw new SemanticIRBuildException( 13838 Diagnostic.error(DiagnosticCode.ORDER_BY_CONSTANT_NOT_SUPPORTED, 13839 "ORDER BY constant '" + sortKey + "' is not supported yet " 13840 + "(constant sort keys add no column dependency)", sortKey)); 13841 } 13842 13843 /** 13844 * Reject ORDER BY sort keys that contain a projection-alias reference 13845 * NESTED inside a compound expression (e.g. 13846 * {@code ORDER BY UPPER(<alias>)}). The visitor in 13847 * {@link #collectColumnRefs} skips column-alias nodes, so without an 13848 * explicit reject the IR would emit no column refs for them. 13849 * 13850 * <p><b>Slice 69:</b> the top-level bare-alias case (e.g. 13851 * {@code ORDER BY <alias>}) is now consumed by 13852 * {@link #tryResolveOrderByProjectionAlias} BEFORE this helper runs. 13853 * Only the deep-scan branch remains here; the top-level fast-path 13854 * was removed because it became unreachable. 13855 * 13856 * <p>{@link TOrderByItem#doParse} only retypes the top-level operand 13857 * to {@link TObjectName#ttobjColumnAlias} → dbObjectType 13858 * {@link EDbObjectType#column_alias}; inside nested expressions the 13859 * alias may or may not be retyped by resolver2 depending on schema 13860 * heuristics. Slice 9's 13861 * {@code orderByNestedAliasReferenceIsHandledSafely} documents the 13862 * three acceptable outcomes for deep aliases (reject by binding 13863 * failure, reject by this deep scan, or accept with a real column 13864 * dependency captured). 13865 */ 13866 private static void rejectOrderByAliasReference(TExpression sortKey) { 13867 // Deep scan: an alias node nested inside an expression 13868 // (e.g. ORDER BY UPPER(x) where x is an alias) would otherwise be 13869 // silently dropped by the column-only visitor. The top-level 13870 // bare-alias case is consumed earlier by 13871 // tryResolveOrderByProjectionAlias (slice 69 lift). 13872 final boolean[] foundAlias = {false}; 13873 final String[] aliasName = {null}; 13874 sortKey.acceptChildren(new TParseTreeVisitor() { 13875 @Override 13876 public void preVisit(TObjectName node) { 13877 if (foundAlias[0]) return; 13878 if (node.getDbObjectType() == EDbObjectType.column_alias) { 13879 foundAlias[0] = true; 13880 aliasName[0] = node.toString(); 13881 } 13882 } 13883 }); 13884 if (foundAlias[0]) { 13885 throw new SemanticIRBuildException( 13886 Diagnostic.error(DiagnosticCode.ORDER_BY_UNSUPPORTED_SORT_KEY_SHAPE, 13887 "ORDER BY sort key '" + sortKey 13888 + "' contains a projection alias reference '" 13889 + aliasName[0] + "'; not supported yet " 13890 + "(reference the underlying column directly)", sortKey)); 13891 } 13892 } 13893 13894 /** 13895 * Reject SELECT shapes outside current builder scope. The 13896 * {@code skipCteListCheck} flag is true only for the outer SELECT of a 13897 * WITH-bearing query whose CTEs were already extracted by 13898 * {@link #build}; nested WITH inside a CTE body is still rejected. 13899 */ 13900 private static void rejectUnsupportedShape(TSelectSqlStatement select, boolean skipCteListCheck) { 13901 // Slice 12: top-level set-ops and CTE-body set-ops are dispatched 13902 // by build() to buildSetOpProgram BEFORE buildSelectStatement is 13903 // called. This rejection still fires when buildSelectStatement 13904 // is called from a recursive context (FROM-subquery / scalar-body 13905 // extraction) where the inner SELECT happens to be a set-op — 13906 // those nested cases remain out of scope. 13907 if (select.getSetOperatorType() != null && select.getSetOperatorType() != ESetOperatorType.none) { 13908 throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SET_OPERATION_NOT_SUPPORTED_IN_CONTEXT, "set operations (UNION/INTERSECT/MINUS) are not supported in this context yet", select)); 13909 } 13910 if (!skipCteListCheck && select.getCteList() != null && select.getCteList().size() > 0) { 13911 throw new SemanticIRBuildException( 13912 Diagnostic.error(DiagnosticCode.NESTED_WITH_NOT_SUPPORTED, 13913 "nested WITH/CTE inside a CTE body or subquery is not supported yet", select)); 13914 } 13915 // DISTINCT / UNIQUE / ALL handling is done in resolveDistinctFlag() 13916 // (called from buildSelectStatement). Only rejected row-filter shapes 13917 // bubble up as a SemanticIRBuildException; the rest become the 13918 // StatementGraph.distinct flag. 13919 // Slice 6 lifted GROUP BY; slice 10 lifted HAVING. The HAVING 13920 // expression itself (and the per-shape rejections for subqueries 13921 // and window functions inside it) are handled in 13922 // buildHavingColumnRefs so the rejection messages can mention the 13923 // specific shape. 13924 // Slices 70 and 71: all single-SELECT row-limit admit/reject 13925 // decisions live in buildRowLimit(select). rejectUnsupportedShape 13926 // no longer carries any row-limit logic. Set-op outer row-limits 13927 // remain handled by rejectSetOpRowLimit (slice 72 lifts). 13928 // Slice 125 lifted the slice-13 blanket QUALIFY reject. The 13929 // QUALIFY clause is now admitted and its filter refs collected in 13930 // buildQualifyColumnRefs (called from buildSelectStatementImpl, 13931 // which has the outputColumns needed for projection-alias 13932 // resolution). Subqueries in QUALIFY still reject there, reusing 13933 // the (repurposed) QUALIFY_NOT_SUPPORTED code. 13934 // ORDER BY itself is lifted in slice 9; see buildOrderByColumnRefs. 13935 // Vendor- and clause-level guards are checked there so the rejection 13936 // message can mention the specific sub-clause. 13937 } 13938 13939 /** 13940 * Walk the FROM clause: each top-level {@link TJoin} contributes its 13941 * base table; each chained {@link TJoinItem} contributes one more base 13942 * table plus the column refs found in its ON-condition expression. 13943 * Comma-separated FROM lists (multiple top-level TJoins) and 13944 * single-source SELECTs both reduce to the same loop. 13945 */ 13946 private static List<RelationSource> buildRelations(TSelectSqlStatement select, 13947 NameBindingProvider provider, 13948 List<ColumnRef> joinRefsOut, 13949 boolean allowFromSubqueries) { 13950 return buildRelations(select, provider, joinRefsOut, allowFromSubqueries, 13951 /*allowJoinOnPredicateSubqueries=*/ false, 13952 /*stmtsForExtraction=*/ null, 13953 /*lineageForExtraction=*/ null, 13954 /*cteMapForExtraction=*/ null); 13955 } 13956 13957 /** 13958 * Slice-23/24 overload of {@link #buildRelations}. When 13959 * {@code allowJoinOnPredicateSubqueries} is {@code true} (outer-SELECT 13960 * call site only), uncorrelated EXISTS subqueries inside JOIN ON 13961 * predicates are extracted as their own {@code <predicate_subquery_<i>>} 13962 * StatementGraphs appended to {@code stmtsForExtraction}. The extracted 13963 * subtrees are then skipped by the JOIN-ON window-function rejecter and 13964 * the JOIN-ON ref collector so their inner refs do not leak into outer 13965 * {@code joinColumnRefs}. 13966 * 13967 * <p>Slice 24: {@code cteMapForExtraction} carries outer's 13968 * CTE-name-to-statement-index map so the extracted predicate body can 13969 * emit STATEMENT_OUTPUT → STATEMENT_OUTPUT edges into outer-visible CTE 13970 * bodies via {@link #emitLineageForStatement}. Non-outer call sites 13971 * (where {@code allowJoinOnPredicateSubqueries=false}) pass {@code null}. 13972 */ 13973 private static List<RelationSource> buildRelations(TSelectSqlStatement select, 13974 NameBindingProvider provider, 13975 List<ColumnRef> joinRefsOut, 13976 boolean allowFromSubqueries, 13977 boolean allowJoinOnPredicateSubqueries, 13978 List<StatementGraph> stmtsForExtraction, 13979 List<LineageEdge> lineageForExtraction, 13980 Map<String, Integer> cteMapForExtraction) { 13981 if (select.joins == null || select.joins.size() == 0) { 13982 throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SELECT_NO_FROM_SOURCE, "SELECT must have at least one FROM source", select)); 13983 } 13984 // Slice 62: comma-separated FROM lists (e.g. `FROM a, b`) 13985 // parse as multiple top-level TJoin elements and build as an 13986 // ordered cross-product relation graph with empty 13987 // {@code joinColumnRefs} (WHERE-side predicates feed 13988 // {@code filterColumnRefs} as usual). 13989 // 13990 // Slice 182 (GitHub #707 / MantisBT 4676) lifts the former 13991 // body-context reject. Slice 62 admitted comma-FROM only where 13992 // {@code allowFromSubqueries=true} (outer / CTE-body / 13993 // FROM-subquery-body) and rejected it in the synthetic body 13994 // contexts (scalar / set-op-branch / set-op-CTE / predicate) on 13995 // the theory that their shape contract "cannot host a 13996 // cross-product relation graph safely". That theory does not 13997 // hold: every one of those contracts constrains the body's 13998 // SELECT LIST (single column for scalar, column-count parity for 13999 // set-op branches, constant/single-column-ref for predicate 14000 // bodies), never its FROM relation count — which is why the same 14001 // bodies already accept the semantically identical 14002 // {@code FROM a JOIN b ON …} with two relations. The reject was 14003 // therefore purely syntactic and made Oracle's traditional 14004 // comma-FROM + WHERE-join style unanalyzable in any nested 14005 // position. 14006 // 14007 // {@code allowFromSubqueries} keeps its ONE genuine meaning: 14008 // a FROM-clause subquery operand is still rejected inside body 14009 // contexts by {@link #buildRelation}. The CROSS / USING / NATURAL 14010 // body rejects below are unchanged (separate slice contracts). 14011 // Slice 63: explicit CROSS JOIN admits at outer / CTE-body / 14012 // FROM-subquery-body call sites (allowFromSubqueries=true) but 14013 // stays rejected inside synthetic body contexts (scalar / 14014 // set-op-branch / set-op-CTE / predicate) because the body's 14015 // shape contract (single column for scalar; column-count parity 14016 // for set-op branches; constant or single column-ref for 14017 // predicates) cannot host a cross-product relation graph 14018 // safely. Predicate bodies also hit an earlier shape-specific 14019 // reject inside {@link #preflightPredicateSubqueryShape} so the 14020 // user-visible diagnostic mentions EXISTS / IN-SELECT context. 14021 if (!allowFromSubqueries) { 14022 for (TJoin join : select.joins) { 14023 TJoinItemList items = join.getJoinItems(); 14024 if (items == null) continue; 14025 for (int i = 0; i < items.size(); i++) { 14026 TJoinItem item = items.getJoinItem(i); 14027 if (item == null) continue; 14028 if (item.getJoinType() == EJoinType.cross) { 14029 throw new SemanticIRBuildException( 14030 Diagnostic.error(DiagnosticCode.CROSS_JOIN_IN_BODY_NOT_SUPPORTED, 14031 "CROSS JOIN is not supported inside scalar / " 14032 + "set-op-branch / set-op-CTE / predicate " 14033 + "body contexts yet; rewrite as INNER " 14034 + "JOIN ... ON in the body", item)); 14035 } 14036 // Slice 64: USING admitted at outer / CTE-body / 14037 // FROM-subquery-body call sites but rejected inside 14038 // synthetic body contexts. The body's shape contract 14039 // (single column for scalar, column-count parity for 14040 // set-op branches, constant/column-ref for predicate 14041 // bodies) cannot host the merged-key semantics safely. 14042 if (item.getUsingColumns() != null 14043 && item.getUsingColumns().size() > 0) { 14044 throw new SemanticIRBuildException( 14045 Diagnostic.error(DiagnosticCode.USING_IN_BODY_NOT_SUPPORTED, 14046 "JOIN ... USING (...) is not supported inside " 14047 + "scalar / set-op-branch / set-op-CTE / " 14048 + "predicate body contexts yet; rewrite " 14049 + "as JOIN ... ON in the body", item)); 14050 } 14051 // Slice 66: NATURAL JOIN inside synthetic body 14052 // contexts is rejected with a tuned diagnostic. 14053 // Predicate bodies hit preflightPredicateSubqueryShape 14054 // first, which emits a wrapper-aware message; this 14055 // reject fires for scalar / set-op-branch / 14056 // set-op-CTE bodies (and as defense-in-depth for 14057 // predicate bodies if the preflight didn't catch 14058 // a vendor-specific variant). 14059 if (isNaturalJoinType(item.getJoinType())) { 14060 throw new SemanticIRBuildException( 14061 Diagnostic.error(DiagnosticCode.NATURAL_IN_BODY_NOT_SUPPORTED, 14062 "NATURAL JOIN is not supported inside " 14063 + "scalar / set-op-branch / set-op-CTE / " 14064 + "predicate body contexts yet; rewrite " 14065 + "as JOIN ... ON in the body", item)); 14066 } 14067 } 14068 } 14069 } 14070 List<RelationSource> relations = new ArrayList<>(); 14071 // Join-graph COLUMN_BINDING_NON_EXACT degrade (GSP R6) for the JOIN ON 14072 // path. An unqualified ON-predicate operand that cannot be bound to 14073 // exactly one side (e.g. {@code JOIN cities ON city = name}) is a 14074 // which-side ambiguity, not a genuine error, WHEN the FROM is 14075 // catalog-less for at least one endpoint — exactly the case the 14076 // post-buildRelations SELECT/WHERE anchor already tolerates. The ON 14077 // refs are collected DURING this method (before that anchor is 14078 // installed), so compute the catalog-less eligibility here and hand the 14079 // ON collector an anchored provider. Every {@code collectJoinOnRefs} 14080 // call runs inside the join-item loop, where ≥2 endpoints are already 14081 // bound (driver + right), so the structural {@code relations.size() >= 2} 14082 // gate is satisfied by construction; only the catalog-less check 14083 // remains. A fully-cataloged FROM stays fatal (the catalog adjudicates). 14084 // 14085 // Whole-FROM scope is intentional and mirrors the post-buildRelations 14086 // SELECT/WHERE anchor (which gates on the same 14087 // {@code !fromRelationColumnsFullyKnown(select, provider)} for the whole 14088 // statement): a partially-cataloged FROM (one endpoint catalog-less) 14089 // degrades an unqualified ON miss because that operand MIGHT live on the 14090 // unknown side — exactly as an unqualified SELECT/WHERE column does. A 14091 // genuine typo over a fully-cataloged FROM still stays fatal. 14092 boolean joinGraphAnchorEligible = !fromRelationColumnsFullyKnown(select, provider); 14093 // Slice 179 (R4): stable FROM-order instance ordinal, assigned in the 14094 // SAME driver-then-join-items traversal order as buildJoinGraph, so 14095 // RelationSource.instanceId and JoinEndpoint.instanceId align by 14096 // construction (not by alias). Held in a one-element array so the 14097 // recursive appendJoinRelations helper can advance it across a nested 14098 // parenthesized <joined_table> sub-tree. 14099 int[] relInstanceId = {0}; 14100 for (TJoin join : select.joins) { 14101 // Slice 66: per top-level TJoin LeftOutputState. Seeded with the 14102 // top-left table's catalog; updated as JoinItems walk left-to-right. 14103 // NATURAL JoinItems consume this state (catalog intersection) and 14104 // update it. Reset between top-level TJoins so comma-FROM groups 14105 // stay independent. 14106 LeftOutputState leftState = new LeftOutputState(); 14107 appendJoinRelations(join, provider, allowFromSubqueries, 14108 allowJoinOnPredicateSubqueries, stmtsForExtraction, 14109 lineageForExtraction, cteMapForExtraction, 14110 relations, joinRefsOut, relInstanceId, leftState, 14111 joinGraphAnchorEligible); 14112 } 14113 rejectDuplicateAliases(relations, select.dbvendor, select.joins.size() > 1); 14114 return relations; 14115 } 14116 14117 /** 14118 * Append the relations of one FROM-clause {@link TJoin} (its driver operand 14119 * plus each join item) to {@code relations}, populate {@code joinRefsOut} 14120 * with the join-condition column refs, advance {@code relInstanceId[0]} in 14121 * FROM order, and update {@code leftState} (the running row type used for 14122 * NATURAL key inference). 14123 * 14124 * <p>Recurses into a parenthesized / nested {@code <joined_table>} operand — 14125 * a {@link TJoin} reached via {@link TJoin#getJoin()} (driver) or 14126 * {@link TJoinItem#getJoin()} (right operand) when the corresponding 14127 * {@code getTable()} is null. So {@code FROM a JOIN (b JOIN c ON …) ON …} 14128 * collects all three relations, emits the inner join's refs, then attaches 14129 * the outer join's ON over the accumulated endpoints. The same 14130 * {@code leftState} is threaded through the recursion — both 14131 * {@code seedLeftOutput} and {@code appendRightToLeftOutput} only ADD 14132 * columns — so a NATURAL join that follows still sees the accumulated row 14133 * type. {@code FROM_SOURCE_NO_TABLE} / {@code JOIN_ITEM_NO_TABLE} stay fatal 14134 * only for an operand that is genuinely neither a table nor a nested join. 14135 */ 14136 private static void appendJoinRelations( 14137 TJoin join, 14138 NameBindingProvider provider, 14139 boolean allowFromSubqueries, 14140 boolean allowJoinOnPredicateSubqueries, 14141 List<StatementGraph> stmtsForExtraction, 14142 List<LineageEdge> lineageForExtraction, 14143 Map<String, Integer> cteMapForExtraction, 14144 List<RelationSource> relations, 14145 List<ColumnRef> joinRefsOut, 14146 int[] relInstanceId, 14147 LeftOutputState leftState, 14148 boolean joinGraphAnchorEligible) { 14149 // ---- driver operand ---- 14150 TTable leftTable = join.getTable(); 14151 if (leftTable != null) { 14152 relations.add(buildRelation(leftTable, provider, allowFromSubqueries, relInstanceId[0]++)); 14153 seedLeftOutput(leftState, leftTable, provider); 14154 } else if (join.getJoin() != null) { 14155 // Parenthesized joined-table as the driver operand — recurse, 14156 // threading the same leftState so it accumulates the sub-tree's 14157 // columns for any NATURAL join that follows at this level. 14158 appendJoinRelations(join.getJoin(), provider, allowFromSubqueries, 14159 allowJoinOnPredicateSubqueries, stmtsForExtraction, 14160 lineageForExtraction, cteMapForExtraction, 14161 relations, joinRefsOut, relInstanceId, leftState, 14162 joinGraphAnchorEligible); 14163 } else { 14164 throw new SemanticIRBuildException(Diagnostic.error( 14165 DiagnosticCode.FROM_SOURCE_NO_TABLE, "FROM source has no table", join)); 14166 } 14167 14168 TJoinItemList items = join.getJoinItems(); 14169 if (items == null) return; 14170 for (int i = 0; i < items.size(); i++) { 14171 TJoinItem item = items.getJoinItem(i); 14172 rejectUnsupportedJoinShape(item); 14173 TTable rightTable = item.getTable(); 14174 if (rightTable == null) { 14175 if (item.getJoin() != null) { 14176 // NATURAL / USING merged-key inference needs a single right 14177 // TTable to key against; over a parenthesized joined-table 14178 // operand (a combined row type) it is not supported yet. 14179 // Fail explicitly rather than silently dropping the merged-key 14180 // refs in buildRelations while buildJoinGraph still emits a 14181 // NATURAL/USING entity (codex P2). Plain ON / CROSS over a 14182 // nested operand ARE supported. 14183 if (isNaturalJoinType(item.getJoinType()) 14184 || (item.getUsingColumns() != null 14185 && item.getUsingColumns().size() > 0)) { 14186 throw new SemanticIRBuildException(Diagnostic.error( 14187 DiagnosticCode.NESTED_JOIN_MERGED_KEY_NOT_SUPPORTED, 14188 "NATURAL / USING join over a parenthesized joined-table " 14189 + "operand is not supported yet; rewrite with an " 14190 + "explicit ON condition", item)); 14191 } 14192 // Parenthesized joined-table as the right operand — recurse to 14193 // collect its relations + inner join refs, then attach this 14194 // outer join's ON over the accumulated endpoints. Use a FRESH 14195 // row-state for the sub-tree so its inner NATURAL inference 14196 // sees only its own columns (codex P1), then merge that row 14197 // type into this level's leftState so a NATURAL join that 14198 // FOLLOWS at this level still sees the accumulated columns. 14199 LeftOutputState subState = new LeftOutputState(); 14200 appendJoinRelations(item.getJoin(), provider, allowFromSubqueries, 14201 allowJoinOnPredicateSubqueries, stmtsForExtraction, 14202 lineageForExtraction, cteMapForExtraction, 14203 relations, joinRefsOut, relInstanceId, subState, 14204 joinGraphAnchorEligible); 14205 mergeLeftOutputState(leftState, subState); 14206 collectJoinOnRefs(item.getOnCondition(), provider, 14207 allowJoinOnPredicateSubqueries, stmtsForExtraction, 14208 lineageForExtraction, cteMapForExtraction, joinRefsOut, 14209 joinGraphAnchorEligible); 14210 continue; 14211 } 14212 throw new SemanticIRBuildException(Diagnostic.error( 14213 DiagnosticCode.JOIN_ITEM_NO_TABLE, "JOIN item has no table", item)); 14214 } 14215 // Slice 17/18: subqueries on a JOIN side are extracted as their own 14216 // statements by extractFromSubqueriesAsStatements before 14217 // buildRelations runs (when allowFromSubqueries=true). Scalar / 14218 // set-op-branch / set-op-CTE-body builds pass allowFromSubqueries= 14219 // false; buildRelation rejects there. 14220 relations.add(buildRelation(rightTable, provider, allowFromSubqueries, relInstanceId[0]++)); 14221 // Slice 66: NATURAL admission via catalog inference. When catalog 14222 // metadata is missing on either side we DEGRADE: skip the merged 14223 // refs, record a NATURAL_CATALOG_REQUIRED warning, and append the 14224 // right's columns to the running state. 14225 if (isNaturalJoinType(item.getJoinType())) { 14226 NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider); 14227 if (r.kind != NaturalKeyResult.Kind.SUCCESS) { 14228 recordNaturalDegradeWarning(r, item); 14229 appendRightToLeftOutput(leftState, rightTable, provider); 14230 continue; 14231 } 14232 emitMergedJoinRefs(JoinKind.NATURAL, r.keys, join, items, i, 14233 rightTable, provider, joinRefsOut); 14234 mergeRightIntoLeftOutput(leftState, rightTable, provider, r.keys); 14235 continue; 14236 } 14237 // Slice 64: USING and ON are mutually exclusive (enforced by 14238 // rejectUnsupportedJoinShape). Populate per-key joinColumnRefs from 14239 // USING here, then skip the onCond branch. 14240 TObjectNameList usingCols = item.getUsingColumns(); 14241 if (usingCols != null && usingCols.size() > 0) { 14242 populateUsingJoinRefs(join, items, i, rightTable, 14243 usingCols, provider, joinRefsOut); 14244 // Slice 66: USING JoinItems also merge right into the 14245 // LeftOutputState so subsequent NATURAL JoinItems see the 14246 // accumulated row type (merged USING keys at their slots). 14247 List<String> usingKeyNames = new ArrayList<>(usingCols.size()); 14248 for (int k = 0; k < usingCols.size(); k++) { 14249 TObjectName usingKey = usingCols.getObjectName(k); 14250 if (usingKey == null) continue; 14251 String keyName = usingKey.getColumnNameOnly(); 14252 if (keyName != null && !keyName.isEmpty()) { 14253 usingKeyNames.add(keyName); 14254 } 14255 } 14256 mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames); 14257 continue; 14258 } 14259 // Slice 66: ON / CROSS JoinItem — append right's catalog columns to 14260 // the running LeftOutputState; NATURAL JoinItems that follow see it. 14261 appendRightToLeftOutput(leftState, rightTable, provider); 14262 collectJoinOnRefs(item.getOnCondition(), provider, 14263 allowJoinOnPredicateSubqueries, stmtsForExtraction, 14264 lineageForExtraction, cteMapForExtraction, joinRefsOut, 14265 joinGraphAnchorEligible); 14266 } 14267 } 14268 14269 /** 14270 * Collect the column refs of a JOIN item's {@code ON} expression into 14271 * {@code joinRefsOut}, applying the same predicate-subquery extraction and 14272 * window-function / subquery rejections as the inline ON path. No-op when 14273 * {@code onCond} is null (CROSS / APPLY items carry no ON). Shared by the 14274 * flat and nested ({@link #appendJoinRelations}) traversal paths. 14275 */ 14276 private static void collectJoinOnRefs(TExpression onCond, NameBindingProvider provider, 14277 boolean allowJoinOnPredicateSubqueries, 14278 List<StatementGraph> stmtsForExtraction, 14279 List<LineageEdge> lineageForExtraction, 14280 Map<String, Integer> cteMapForExtraction, 14281 List<ColumnRef> joinRefsOut, 14282 boolean joinGraphAnchorEligible) { 14283 if (onCond == null) { 14284 return; 14285 } 14286 // Join-graph COLUMN_BINDING_NON_EXACT degrade (GSP R6): the anchor wraps 14287 // ONLY the direct ON-condition ref binding below. Predicate-subquery 14288 // EXTRACTION keeps the un-anchored {@code provider} — each extracted 14289 // body is built via a nested entry that resets the anchor anyway, and 14290 // its inner binding does not enjoy the ON path's ≥2-bound-endpoint 14291 // invariant. The anchor only changes rejectNonExactBindings: an 14292 // unqualified ON operand that can't be placed on one side (a which-side 14293 // ambiguity over a catalog-less join) degrades to a warning instead of 14294 // aborting the whole analysis. 14295 NameBindingProvider refProvider = joinGraphAnchorEligible 14296 ? provider.withJoinStructureAnchor(true) : provider; 14297 if (allowJoinOnPredicateSubqueries) { 14298 // Slice 23/24/25: outer-SELECT JOIN ON path — extract uncorrelated 14299 // predicate-subquery wrappers as their own <predicate_subquery_<i>> 14300 // StatementGraphs and skip their subtrees during the window guard and 14301 // ref collection. The leak guard rejects everything that is NOT an 14302 // extracted wrapper. 14303 Set<TExpression> extractedRoots = 14304 extractUncorrelatedPredicateSubqueriesFromJoinOn(onCond, provider, 14305 stmtsForExtraction, lineageForExtraction, cteMapForExtraction); 14306 rejectAnyRemainingSubqueriesInJoinOn(onCond, extractedRoots); 14307 rejectWindowFunctionInScopeSkipping(onCond, "JOIN ON condition", extractedRoots); 14308 joinRefsOut.addAll(collectColumnRefsSkipping(onCond, refProvider, extractedRoots)); 14309 } else { 14310 // Slice 13/17: reject window functions and predicate subqueries in 14311 // JOIN ON before collectColumnRefs descends (every non-outer call 14312 // site: FROM-subquery body, CTE body, scalar body, set-op branch). 14313 rejectWindowFunctionInScope(onCond, "JOIN ON condition"); 14314 rejectSubqueriesInJoinOn(onCond); 14315 joinRefsOut.addAll(collectColumnRefs(onCond, refProvider)); 14316 } 14317 } 14318 14319 /** 14320 * Slice 17: reject predicate subqueries (EXISTS, IN-SELECT, 14321 * scalar-subquery comparisons, etc.) inside a JOIN ON expression. 14322 * Without this guard, slice-17's expanded JOIN surface (relation 14323 * subqueries on either side) would let predicate subqueries slip 14324 * past {@code collectColumnRefs} and produce incomplete IR. Applies 14325 * to every {@code buildRelations} call site; the slice-11 14326 * {@link #rejectSubqueriesInScalarBodyClauses} and slice-17 14327 * {@link #rejectSubqueriesInFromSubqueryBodyClauses} fire BEFORE 14328 * the recursive {@code buildSelectStatement}, so their context- 14329 * specific messages preempt this one. 14330 */ 14331 private static void rejectSubqueriesInJoinOn(TExpression onCond) { 14332 if (containsAnySubqueryExpression(onCond)) { 14333 throw new SemanticIRBuildException( 14334 Diagnostic.error(DiagnosticCode.JOIN_ON_TOP_LEVEL_SUBQUERY_NOT_SUPPORTED, 14335 "subquery in a top-level JOIN ON predicate is not supported yet", onCond)); 14336 } 14337 } 14338 14339 // ==================================================================== 14340 // Slice 23: uncorrelated EXISTS subqueries in top-level outer-SELECT 14341 // JOIN ON. 14342 // 14343 // Approach: walk the JOIN-ON expression looking for `exists_t` nodes 14344 // (and `not_t(exists_t(...))` for NOT EXISTS); validate each as 14345 // uncorrelated with a constant-only inner projection; build the inner 14346 // SELECT as its own `<predicate_subquery_<i>>` StatementGraph; record 14347 // the extracted `exists_t` root in a Set so the JOIN-ON window guard 14348 // and ref collector can skip its subtree. Predicate bodies are 14349 // unreachable from outer (no relation, no lineage edge) so they 14350 // contribute zero canonical edges — matching dlineage's behaviour for 14351 // EXISTS-in-JOIN-ON shapes that project a constant (the inner-shape 14352 // preflight enforces constant-only projection so this invariant 14353 // holds). 14354 // 14355 // Process: codex round 1 + round 2 plan reviews; v3 plan locked. 14356 // See roadmap §14.25 (slice-23 entry). 14357 // ==================================================================== 14358 14359 /** 14360 * True iff {@code e} is the root of an EXISTS predicate that slice 23 14361 * may extract: either an {@code exists_t} expression, or a 14362 * {@code logical_not_t} whose <b>right</b> operand is {@code exists_t}. 14363 * NOT EXISTS unwraps to its inner {@code exists_t}. 14364 * 14365 * <p>Note: the GSP parser puts the operand of {@code logical_not_t} in 14366 * {@link TExpression#getRightOperand()}, not {@code getLeftOperand()} 14367 * (verified across Oracle / PostgreSQL / MSSQL / MySQL / BigQuery). 14368 * The root fast-path for {@code NOT EXISTS} is therefore "dead" in the 14369 * sense that the descendant walker on the wrapping {@code logical_not_t} 14370 * already visits the child {@code exists_t} — we still keep it here so 14371 * the symmetry between root EXISTS and root NOT EXISTS is explicit. 14372 * 14373 * <p>Slice 25 (kept as a dedicated helper for slice-23/24 callers and 14374 * for clarity): the slice-25 generalisation lives in 14375 * {@link #unwrapToInnerExtractableSubquery(TExpression)} which 14376 * recognises four wrapper shapes — including the two EXISTS shapes 14377 * here. 14378 */ 14379 private static boolean isExistsRoot(TExpression e) { 14380 if (e == null) return false; 14381 if (e.getExpressionType() == EExpressionType.exists_t) return true; 14382 if (e.getExpressionType() == EExpressionType.logical_not_t 14383 && e.getRightOperand() != null 14384 && e.getRightOperand().getExpressionType() == EExpressionType.exists_t) { 14385 return true; 14386 } 14387 return false; 14388 } 14389 14390 /** Return the actual {@code exists_t} node — unwrap a {@code logical_not_t} parent if present. */ 14391 private static TExpression unwrapExistsRoot(TExpression e) { 14392 if (e.getExpressionType() == EExpressionType.exists_t) return e; 14393 return e.getRightOperand(); 14394 } 14395 14396 /** 14397 * Slice 25 / Slice 26: pure shape-recogniser for the predicate- 14398 * subquery wrappers admitted in TOP-LEVEL JOIN ON. Returns the inner 14399 * extractable node ({@code subquery_t} or {@code exists_t}) for the 14400 * wrapper shapes; null otherwise. Pure — performs NO validation and 14401 * throws NO exceptions. 14402 * 14403 * <p>Recognised wrappers: 14404 * <ul> 14405 * <li>{@code exists_t} (slice-23 EXISTS) — returns {@code e}.</li> 14406 * <li>{@code logical_not_t} with rightOperand {@code exists_t} 14407 * (slice-23 NOT EXISTS) — returns the inner exists_t.</li> 14408 * <li>{@code in_t} with rightOperand {@code subquery_t} 14409 * (slice 25 IN-SELECT / NOT IN-SELECT) — returns the 14410 * rightOperand. LHS-subquery {@code in_t} returns null 14411 * (slice 26 boundary: dlineage's {@code fdr clause="on"} 14412 * sources omit the outer column for IN-LHS, so admitting on 14413 * the IR side would manufacture canonical-model divergence).</li> 14414 * <li>{@code simple_comparison_t} (slice 25 + slice 26 scalar 14415 * comparison) — returns the operand on whichever side is a 14416 * {@code subquery_t}. RHS-subquery (slice 25) and LHS- 14417 * subquery (slice 26) are both admitted; both-sides subquery 14418 * returns null and falls through to {@link #findSubqueryOnLeftWrapper}'s 14419 * new "both subqueries" rejection branch.</li> 14420 * <li>{@code group_comparison_t} with rightOperand 14421 * {@code subquery_t} AND non-null {@code getQuantifier()} 14422 * (slice 25 ANY/ALL/SOME) — returns the rightOperand. 14423 * LHS-subquery {@code group_comparison_t} returns null 14424 * (slice 26 boundary: borderline grammar; not probed).</li> 14425 * </ul> 14426 * 14427 * <p>For null returns, the wrapper either is not a recognised shape 14428 * (falls through to the slice-23 generic remaining-subquery rejection 14429 * in {@link #rejectAnyRemainingSubqueriesInJoinOn}) OR has the right 14430 * outer shape but the LHS / RHS positioning is unsupported (subquery 14431 * on left side of IN/quantifier, both sides subquery for cmp, tuple 14432 * LHS / RHS, expression LHS / RHS). The walker validates the 14433 * non-subquery side via {@link #isAdmittedOuterLhsShape} or 14434 * {@link #isAdmittedOuterRhsShape} and throws a slice-25 / slice-26 14435 * tuned message before calling this helper for extraction. 14436 * 14437 * <p>The slice-23/24 EXISTS callers ({@code isExistsRoot} and 14438 * {@code unwrapExistsRoot}) remain in place — both are simple 14439 * boolean / unwrap helpers; this method consolidates the slice-25 / 14440 * slice-26 shape decision in one place. 14441 */ 14442 private static TExpression unwrapToInnerExtractableSubquery(TExpression e) { 14443 if (e == null) return null; 14444 EExpressionType t = e.getExpressionType(); 14445 if (t == EExpressionType.exists_t) return e; 14446 if (t == EExpressionType.logical_not_t 14447 && e.getRightOperand() != null 14448 && e.getRightOperand().getExpressionType() == EExpressionType.exists_t) { 14449 return e.getRightOperand(); 14450 } 14451 TExpression l = e.getLeftOperand(); 14452 TExpression r = e.getRightOperand(); 14453 boolean lhsIsSubq = l != null && l.getExpressionType() == EExpressionType.subquery_t; 14454 boolean rhsIsSubq = r != null && r.getExpressionType() == EExpressionType.subquery_t; 14455 if (t == EExpressionType.in_t) { 14456 return rhsIsSubq ? r : null; 14457 } 14458 if (t == EExpressionType.simple_comparison_t) { 14459 // Slice 26: admit subquery on either single side. Both sides 14460 // → null (rejected via findSubqueryOnLeftWrapper's new 14461 // "both subqueries" branch — see isSubqueryOnLeftOfWrapper). 14462 if (lhsIsSubq && rhsIsSubq) return null; 14463 if (rhsIsSubq) return r; 14464 if (lhsIsSubq) return l; 14465 return null; 14466 } 14467 if (t == EExpressionType.group_comparison_t 14468 && e.getQuantifier() != null) { 14469 return rhsIsSubq ? r : null; 14470 } 14471 return null; 14472 } 14473 14474 /** 14475 * Slice 25: admitted LHS shapes for non-EXISTS predicate-subquery 14476 * wrappers ({@code in_t} / {@code simple_comparison_t} / 14477 * {@code group_comparison_t}) when the subquery is on the RHS. 14478 * 14479 * <p>Admits ONLY {@link EExpressionType#simple_object_name_t} — 14480 * a single column reference, qualified or unqualified. Rejects: 14481 * tuple expressions ({@code (a, b) IN (...)}), parenthesized 14482 * wrapping ({@code (e.col) IN (...)}), arithmetic 14483 * ({@code e.col + 1 IN (...)}), function calls 14484 * ({@code UPPER(e.col) IN (...)}), scalar subqueries on LHS, and 14485 * any other non-column shape. 14486 * 14487 * <p>Slice-25 boundary; future slice may admit parenthesized 14488 * column refs (slice 26+). 14489 */ 14490 private static boolean isAdmittedOuterLhsShape(TExpression lhs) { 14491 return lhs != null 14492 && lhs.getExpressionType() == EExpressionType.simple_object_name_t; 14493 } 14494 14495 /** 14496 * Slice 26: admitted RHS shapes for {@code simple_comparison_t} 14497 * with subquery on the LHS. Mirror of 14498 * {@link #isAdmittedOuterLhsShape}: admits ONLY 14499 * {@link EExpressionType#simple_object_name_t} — a single column 14500 * reference, qualified or unqualified. Rejects tuple, parenthesized, 14501 * arithmetic, function-call, and subquery (the "both subqueries" 14502 * shape is rejected separately via 14503 * {@link #isSubqueryOnLeftOfWrapper}'s new 14504 * {@code simple_comparison_t} both-sides branch). 14505 * 14506 * <p>Slice-26 boundary: only {@code simple_comparison_t} reaches 14507 * this helper (the walker dispatches on which side is the 14508 * subquery). {@code in_t} / {@code group_comparison_t} with LHS 14509 * subquery return null from 14510 * {@link #unwrapToInnerExtractableSubquery} so they never reach 14511 * here. 14512 */ 14513 private static boolean isAdmittedOuterRhsShape(TExpression rhs) { 14514 return rhs != null 14515 && rhs.getExpressionType() == EExpressionType.simple_object_name_t; 14516 } 14517 14518 /** 14519 * Slice 25 (impl-review M1-fix): true iff {@code e} is a 14520 * {@code logical_not_t} wrapping a slice-25 IN / scalar-cmp / 14521 * ANY-ALL-SOME wrapper (i.e. NOT applied to an admitted slice-25 14522 * shape that ISN'T an EXISTS). The descendant walker would 14523 * otherwise traverse INTO this {@code logical_not_t} and find the 14524 * child wrapper, accidentally admitting 14525 * {@code NOT (e.col IN (SELECT ...))} which is NOT a slice-25 14526 * recognised shape ({@code unwrapToInnerExtractableSubquery} 14527 * matches {@code logical_not_t} only when the inner is 14528 * {@code exists_t}). 14529 * 14530 * <p>This helper is consulted by the extraction walker BEFORE it 14531 * descends into the children of a {@code logical_not_t}, so the 14532 * rejection happens at the wrapper level with a tuned message 14533 * pointing at the slice-25 boundary. 14534 */ 14535 private static boolean isLogicalNotOverNonExistsWrapper(TExpression e) { 14536 if (e == null) return false; 14537 if (e.getExpressionType() != EExpressionType.logical_not_t) return false; 14538 TExpression r = e.getRightOperand(); 14539 if (r == null) return false; 14540 // Strip parenthesis_t chain. The Oracle parser wraps 14541 // `NOT (e.col IN (SELECT...))` as 14542 // logical_not_t → parenthesis_t → in_t, so the immediate 14543 // right child is parenthesis_t. Descend through any chain of 14544 // parens to find the actual subject. Note: 14545 // {@code parenthesis_t} stores its child on 14546 // {@link TExpression#getLeftOperand()} (mirroring 14547 // {@link #isConstantExpression}'s descent). 14548 TExpression subject = r; 14549 while (subject != null 14550 && subject.getExpressionType() == EExpressionType.parenthesis_t) { 14551 subject = subject.getLeftOperand(); 14552 } 14553 if (subject == null) return false; 14554 if (subject.getExpressionType() == EExpressionType.exists_t) return false; 14555 // Either an in_t / simple_comparison_t / group_comparison_t 14556 // with subquery RHS, or any of those types directly. 14557 return unwrapToInnerExtractableSubquery(subject) != null; 14558 } 14559 14560 /** 14561 * Slice 25 / Slice 26: build a tuned outer-shape rejection message 14562 * for a non-EXISTS predicate-subquery wrapper. Called from the 14563 * extraction walker when {@link #unwrapToInnerExtractableSubquery} 14564 * returns non-null for an in_t / simple_comparison_t / 14565 * group_comparison_t but the non-subquery side is not admitted by 14566 * {@link #isAdmittedOuterLhsShape} (slice 25 — subquery on RHS) or 14567 * {@link #isAdmittedOuterRhsShape} (slice 26 — subquery on LHS). 14568 * 14569 * <p>{@code isLhsSubquery} indicates which side of the wrapper 14570 * carries the subquery: {@code true} = subquery on LHS (slice 26 14571 * path; we validate the wrapper's RHS), {@code false} = subquery 14572 * on RHS (slice 25 path; we validate the wrapper's LHS). 14573 * 14574 * <p>Returns only the reason text. The caller combines it with the 14575 * strongly typed predicate-subquery context so the rendered construct 14576 * and clause always describe the actual source wrapper. 14577 */ 14578 private static String buildOuterShapeRejectionReason(TExpression wrapper, 14579 boolean isLhsSubquery) { 14580 EExpressionType t = wrapper.getExpressionType(); 14581 String shapeLabel; 14582 if (t == EExpressionType.in_t) shapeLabel = "IN"; 14583 else if (t == EExpressionType.simple_comparison_t) shapeLabel = "comparison"; 14584 else if (t == EExpressionType.group_comparison_t) shapeLabel = "ANY/ALL/SOME"; 14585 else shapeLabel = String.valueOf(t); 14586 // Validate the side that does NOT carry the subquery. 14587 TExpression nonSubquerySide = isLhsSubquery 14588 ? wrapper.getRightOperand() 14589 : wrapper.getLeftOperand(); 14590 String sideLabel = isLhsSubquery ? "RHS" : "LHS"; 14591 EExpressionType sideType = nonSubquerySide == null 14592 ? null : nonSubquerySide.getExpressionType(); 14593 String detail; 14594 if (nonSubquerySide == null) { 14595 detail = "missing " + sideLabel; 14596 } else if (sideType == EExpressionType.list_t) { 14597 detail = "tuple " + sideLabel; 14598 } else if (sideType == EExpressionType.parenthesis_t) { 14599 detail = "parenthesized " + sideLabel; 14600 } else if (sideType == EExpressionType.simple_object_name_t) { 14601 // Defensive: should not be reached when the corresponding 14602 // admitted-shape helper returns true. 14603 detail = "unexpected admitted " + sideLabel + " shape"; 14604 } else { 14605 detail = "expression " + sideLabel + " (" + sideType + ")"; 14606 } 14607 String boundary = isLhsSubquery ? "slice 26 boundary" : "slice 25 boundary"; 14608 return shapeLabel + " wrapper has unsupported " + sideLabel + " shape (" 14609 + detail + "); only a single column reference " 14610 + "(simple_object_name_t) is admitted on the " 14611 + sideLabel 14612 + " of a comparison / IN / ANY-ALL-SOME " 14613 + "predicate subquery when the other side is a " 14614 + "subquery (" + boundary + ")"; 14615 } 14616 14617 /** 14618 * Slice 25 (rename of slice-23 14619 * {@code extractUncorrelatedExistsFromJoinOn}): walk the JOIN-ON 14620 * expression, extract every uncorrelated predicate-subquery wrapper 14621 * (EXISTS / NOT EXISTS / IN-SELECT / NOT IN-SELECT / scalar 14622 * comparison subquery / ANY-ALL-SOME) as its own 14623 * {@code <predicate_subquery_<i>>} StatementGraph, and return the 14624 * set of extracted inner nodes (the {@code exists_t} or 14625 * {@code subquery_t}, NOT the wrapping {@code in_t} / 14626 * {@code simple_comparison_t} / {@code group_comparison_t} / 14627 * {@code logical_not_t}) keyed on identity. 14628 * 14629 * <p>The set is consumed by the JOIN-ON window-function guard, the 14630 * JOIN-ON ref collector, and the slice-17 remaining-subquery 14631 * rejecter — each of those skips INTO / PAST these subtrees so 14632 * inner refs do not leak into outer joinColumnRefs. Critically, 14633 * the wrapper itself (e.g. an {@code in_t} whose RHS is the 14634 * {@code subquery_t}) is NOT in the set — this lets the LHS column 14635 * reference (e.g. {@code e.dept_id} in 14636 * {@code e.dept_id IN (SELECT ...)}) be collected normally into 14637 * outer's {@code joinColumnRefs}. 14638 * 14639 * <p>The walker handles BOTH the root-position case (the entire ON 14640 * IS one of the four wrappers, which {@code acceptChildren} would 14641 * not visit as a node) AND descendant positions (e.g. 14642 * {@code e.id = d.id AND e.dept_id IN (SELECT ...)}). Multiple 14643 * wrappers in one ON, multiple ON across multiple JOINs, and mixed 14644 * EXISTS / IN / cmp / ANY-ALL combinations are all handled. 14645 * 14646 * <p>Slice 25 / Slice 26 outer-shape validation: for non-EXISTS 14647 * wrappers, the side opposite the subquery must be a single 14648 * {@code simple_object_name_t} column ref. Slice 25 admits subquery 14649 * on RHS only and validates LHS via 14650 * {@link #isAdmittedOuterLhsShape}. Slice 26 lifts {@code 14651 * simple_comparison_t} to also admit subquery on LHS and validates 14652 * RHS via {@link #isAdmittedOuterRhsShape}. Tuple / parenthesized / 14653 * expression / function-call shapes on the validated side throw 14654 * {@link SemanticIRBuildException} with a tuned message via 14655 * {@link #buildOuterShapeRejectionReason}. The EXISTS branch has 14656 * no outer-shape gate (slice-23 carryover). 14657 * 14658 * <p>Snapshot/rollback wrapper at the outer-SELECT call site 14659 * ({@link #build}) catches a partial extraction (e.g. third 14660 * wrapper rejected after first two extracted) and truncates 14661 * {@code stmts}/{@code lineage} back to the snapshot. 14662 */ 14663 /** 14664 * Slice 110 — context bag threading clause-specific 14665 * {@link DiagnosticCode}s and a clause-label into the slice-23+ 14666 * predicate-subquery extraction pipeline so the same walker code can 14667 * power JOIN-ON (slice 23–33+) and UPDATE WHERE (slice 110) without 14668 * code duplication. 14669 * 14670 * <p>Two static instances exist: 14671 * <ul> 14672 * <li>{@link #JOIN_ON} — preserves slice-23+ JOIN-ON semantic 14673 * behavior and stable codes while diagnostics name the actual 14674 * predicate wrapper.</li> 14675 * <li>{@link #UPDATE_WHERE} — slice 110 UPDATE WHERE call site 14676 * (parallel {@code UPDATE_WHERE_*} codes; "UPDATE WHERE clause" 14677 * label).</li> 14678 * </ul> 14679 * 14680 * <p>Codes per clause are intentionally parallel (slice-80 14681 * granular-codes contract: each semantic reject reason gets its own 14682 * stable API code rather than an umbrella code with discriminating 14683 * message text). 14684 */ 14685 private static final class PredicateClauseContext { 14686 /** Used as the "in <label>" piece of every diagnostic message. */ 14687 final String clauseLabel; 14688 final DiagnosticCode existsBodyMissing; 14689 final DiagnosticCode existsInnerRelationUnknown; 14690 final DiagnosticCode existsCorrelatedUnknownOuterAlias; 14691 final DiagnosticCode predicateNotNot; 14692 final DiagnosticCode outerShapeRejected; 14693 final DiagnosticCode scalarComparisonBothSides; 14694 final DiagnosticCode predicateSubqueryOnLeft; 14695 final DiagnosticCode genericSubqueryNotSupported; 14696 14697 private PredicateClauseContext(String clauseLabel, 14698 DiagnosticCode existsBodyMissing, 14699 DiagnosticCode existsInnerRelationUnknown, 14700 DiagnosticCode existsCorrelatedUnknownOuterAlias, 14701 DiagnosticCode predicateNotNot, 14702 DiagnosticCode outerShapeRejected, 14703 DiagnosticCode scalarComparisonBothSides, 14704 DiagnosticCode predicateSubqueryOnLeft, 14705 DiagnosticCode genericSubqueryNotSupported) { 14706 this.clauseLabel = clauseLabel; 14707 this.existsBodyMissing = existsBodyMissing; 14708 this.existsInnerRelationUnknown = existsInnerRelationUnknown; 14709 this.existsCorrelatedUnknownOuterAlias = existsCorrelatedUnknownOuterAlias; 14710 this.predicateNotNot = predicateNotNot; 14711 this.outerShapeRejected = outerShapeRejected; 14712 this.scalarComparisonBothSides = scalarComparisonBothSides; 14713 this.predicateSubqueryOnLeft = predicateSubqueryOnLeft; 14714 this.genericSubqueryNotSupported = genericSubqueryNotSupported; 14715 } 14716 14717 static final PredicateClauseContext JOIN_ON = new PredicateClauseContext( 14718 "JOIN ON", 14719 DiagnosticCode.JOIN_ON_EXISTS_BODY_MISSING, 14720 DiagnosticCode.JOIN_ON_EXISTS_INNER_RELATION_UNKNOWN, 14721 DiagnosticCode.JOIN_ON_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14722 DiagnosticCode.JOIN_ON_PREDICATE_NOT_NOT_SUPPORTED, 14723 DiagnosticCode.JOIN_ON_OUTER_SHAPE_REJECTED, 14724 DiagnosticCode.JOIN_ON_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14725 DiagnosticCode.JOIN_ON_PREDICATE_NOT_LIFTABLE, 14726 DiagnosticCode.JOIN_ON_PREDICATE_GENERIC_NOT_SUPPORTED); 14727 14728 static final PredicateClauseContext UPDATE_WHERE = new PredicateClauseContext( 14729 "UPDATE WHERE clause", 14730 DiagnosticCode.UPDATE_WHERE_EXISTS_BODY_MISSING, 14731 DiagnosticCode.UPDATE_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14732 DiagnosticCode.UPDATE_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14733 DiagnosticCode.UPDATE_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14734 DiagnosticCode.UPDATE_WHERE_OUTER_SHAPE_REJECTED, 14735 DiagnosticCode.UPDATE_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14736 DiagnosticCode.UPDATE_WHERE_PREDICATE_NOT_LIFTABLE, 14737 DiagnosticCode.UPDATE_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14738 14739 static final PredicateClauseContext DELETE_WHERE = new PredicateClauseContext( 14740 "DELETE WHERE clause", 14741 DiagnosticCode.DELETE_WHERE_EXISTS_BODY_MISSING, 14742 DiagnosticCode.DELETE_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14743 DiagnosticCode.DELETE_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14744 DiagnosticCode.DELETE_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14745 DiagnosticCode.DELETE_WHERE_OUTER_SHAPE_REJECTED, 14746 DiagnosticCode.DELETE_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14747 DiagnosticCode.DELETE_WHERE_PREDICATE_NOT_LIFTABLE, 14748 DiagnosticCode.DELETE_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14749 14750 static final PredicateClauseContext SELECT_WHERE = new PredicateClauseContext( 14751 "SELECT WHERE clause", 14752 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14753 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14754 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14755 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14756 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14757 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14758 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14759 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14760 14761 /** 14762 * Slice 113 — uncorrelated WHERE-side predicate subqueries on 14763 * set-op branches (UNION / INTERSECT / EXCEPT / MINUS branches). 14764 * Reuses every {@link DiagnosticCode} from {@link #SELECT_WHERE} 14765 * because a branch IS a SELECT — the shape rejects are 14766 * semantically identical to top-level SELECT WHERE. Only the 14767 * {@code clauseLabel} differs so diagnostic messages distinguish 14768 * the nested context (helpful when a multi-branch query reports 14769 * a reject and the user needs to know which branch). Keeping the 14770 * codes shared frees consumers from a new code-family migration 14771 * and preserves the enum count at 279. 14772 */ 14773 static final PredicateClauseContext SET_OP_BRANCH_WHERE = new PredicateClauseContext( 14774 "set-op branch WHERE clause", 14775 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14776 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14777 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14778 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14779 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14780 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14781 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14782 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14783 14784 /** 14785 * Slice 114 — uncorrelated WHERE-side predicate subqueries 14786 * inside a non-set-op CTE body (the SELECT body of a single CTE 14787 * in a WITH list on SELECT / MERGE / UPDATE / DELETE). Reuses 14788 * every {@link DiagnosticCode} from {@link #SELECT_WHERE} 14789 * because a CTE body IS a SELECT — the shape rejects are 14790 * semantically identical to top-level SELECT WHERE. Only the 14791 * {@code clauseLabel} differs so a reject in a 14792 * {@code WITH cte AS (SELECT ... WHERE NOT (...))} shape can 14793 * identify the CTE-body host context in the diagnostic message. 14794 * Keeping the codes shared (slice 113 precedent) preserves the 14795 * enum count at 279 and frees consumers from another 14796 * code-family migration. 14797 */ 14798 static final PredicateClauseContext CTE_BODY_WHERE = new PredicateClauseContext( 14799 "CTE body WHERE clause", 14800 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14801 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14802 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14803 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14804 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14805 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14806 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14807 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14808 14809 /** 14810 * Slice 120 — uncorrelated WHERE-side predicate subqueries inside 14811 * a FROM-subquery body (the inner SELECT of a {@code FROM (...)} 14812 * derived table). Reuses every {@link DiagnosticCode} from 14813 * {@link #SELECT_WHERE} (slice 113/114/116 precedent) because a 14814 * FROM-subquery body IS a SELECT — the shape rejects are 14815 * semantically identical to top-level SELECT WHERE. Only the 14816 * {@code clauseLabel} differs so a reject inside a 14817 * {@code FROM (SELECT ... WHERE NOT (...)) sub} shape can identify 14818 * the FROM-subquery host context in the diagnostic message. The 14819 * FROM-subquery body builder {@code processDirectSubqueryTable} is 14820 * shared by the SELECT, UPDATE (slice 83), and DELETE (slice 84) 14821 * FROM-subquery extractors, so this single context lifts all three. 14822 * Keeping the codes shared preserves the enum count at 279 and 14823 * frees consumers from another code-family migration. 14824 */ 14825 static final PredicateClauseContext FROM_SUBQUERY_BODY_WHERE = new PredicateClauseContext( 14826 "FROM-subquery body WHERE clause", 14827 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14828 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14829 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14830 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14831 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14832 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14833 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14834 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14835 14836 /** 14837 * Slice 121 — uncorrelated WHERE-side predicate subqueries inside 14838 * a scalar projection subquery body (the inner SELECT of a 14839 * {@code SELECT (SELECT ...) AS m FROM ...} scalar projection). 14840 * Reuses every {@link DiagnosticCode} from {@link #SELECT_WHERE} 14841 * (slice 113/114/120 precedent) because a scalar body IS a SELECT — 14842 * the shape rejects are semantically identical to top-level SELECT 14843 * WHERE. Only the {@code clauseLabel} differs so a reject inside a 14844 * {@code (SELECT ... WHERE NOT (...)) AS m} shape can identify the 14845 * scalar-body host context in the diagnostic message. The lift is 14846 * applied at the shared projection scalar-body build in 14847 * {@code extractScalarSubqueriesAsStatementsInternal}, covering both 14848 * the recursive (outer / CTE-body) and non-recursive (set-op-branch) 14849 * scalar contexts. The UPDATE SET-RHS scalar path keeps the slice-11 14850 * WHERE reject ({@code SCALAR_SUBQUERY_INNER_SUBQUERY_IN_WHERE}). 14851 * Keeping the codes shared preserves the enum count at 279 and 14852 * frees consumers from another code-family migration. 14853 */ 14854 static final PredicateClauseContext SCALAR_BODY_WHERE = new PredicateClauseContext( 14855 "scalar subquery body WHERE clause", 14856 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14857 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14858 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14859 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14860 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14861 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14862 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14863 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14864 14865 /** 14866 * Slice 116 — uncorrelated WHERE-side predicate subqueries on 14867 * MERGE per-WHEN action WHEREs ({@code TMergeUpdateClause.updateWhereClause}, 14868 * {@code TMergeUpdateClause.deleteWhereClause}, 14869 * {@code TMergeInsertClause.insertWhereClause}). Reuses every 14870 * {@link DiagnosticCode} from {@link #SELECT_WHERE} (slice 113/114 14871 * precedent) because a MERGE-action WHERE predicate IS a SELECT 14872 * WHERE in shape — the shape rejects are semantically identical to 14873 * top-level SELECT WHERE. Only the {@code clauseLabel} differs so a 14874 * reject inside a MERGE WHEN can identify the host context in the 14875 * diagnostic message. Keeping the codes shared preserves the enum 14876 * count at 279 and frees consumers from another code-family 14877 * migration. 14878 */ 14879 static final PredicateClauseContext MERGE_WHEN_WHERE = new PredicateClauseContext( 14880 "MERGE WHEN action WHERE clause", 14881 DiagnosticCode.SELECT_WHERE_EXISTS_BODY_MISSING, 14882 DiagnosticCode.SELECT_WHERE_EXISTS_INNER_RELATION_UNKNOWN, 14883 DiagnosticCode.SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS, 14884 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_NOT_SUPPORTED, 14885 DiagnosticCode.SELECT_WHERE_OUTER_SHAPE_REJECTED, 14886 DiagnosticCode.SELECT_WHERE_PREDICATE_SCALAR_COMPARISON_NOT_LIFTABLE, 14887 DiagnosticCode.SELECT_WHERE_PREDICATE_NOT_LIFTABLE, 14888 DiagnosticCode.SELECT_WHERE_PREDICATE_GENERIC_NOT_SUPPORTED); 14889 } 14890 14891 /** Syntactic wrapper kind captured before predicate-subquery lowering. */ 14892 private enum PredicateSubqueryKind { 14893 EXISTS, 14894 IN, 14895 SCALAR_COMPARISON, 14896 QUANTIFIED_COMPARISON 14897 } 14898 14899 /** 14900 * Immutable provenance for one recognised predicate-subquery wrapper. 14901 * 14902 * <p>The context is constructed from AST enums and tokens at wrapper 14903 * recognition time, then threaded through every later validation stage. 14904 * Diagnostics therefore never have to infer the original construct from 14905 * a partially lowered inner statement or re-scan SQL text. 14906 */ 14907 private static final class PredicateSubqueryContext { 14908 final PredicateSubqueryKind kind; 14909 final boolean negated; 14910 final String comparisonOperator; 14911 final EQuantifierType quantifier; 14912 final PredicateClauseContext clause; 14913 final SourceSpan wrapperSpan; 14914 final SourceSpan innerSpan; 14915 14916 private PredicateSubqueryContext(PredicateSubqueryKind kind, 14917 boolean negated, 14918 String comparisonOperator, 14919 EQuantifierType quantifier, 14920 PredicateClauseContext clause, 14921 SourceSpan wrapperSpan, 14922 SourceSpan innerSpan) { 14923 this.kind = kind; 14924 this.negated = negated; 14925 this.comparisonOperator = comparisonOperator; 14926 this.quantifier = quantifier; 14927 this.clause = clause; 14928 this.wrapperSpan = wrapperSpan; 14929 this.innerSpan = innerSpan; 14930 } 14931 14932 static PredicateSubqueryContext from(TExpression wrapper, 14933 TExpression extractableNode, 14934 PredicateClauseContext clause) { 14935 EExpressionType type = wrapper == null 14936 ? null : wrapper.getExpressionType(); 14937 PredicateSubqueryKind kind; 14938 boolean negated = false; 14939 String operator = null; 14940 EQuantifierType quantifier = EQuantifierType.none; 14941 14942 if (type == EExpressionType.exists_t) { 14943 kind = PredicateSubqueryKind.EXISTS; 14944 } else if (type == EExpressionType.logical_not_t 14945 && extractableNode != null 14946 && extractableNode.getExpressionType() == EExpressionType.exists_t) { 14947 kind = PredicateSubqueryKind.EXISTS; 14948 negated = true; 14949 } else if (type == EExpressionType.in_t) { 14950 kind = PredicateSubqueryKind.IN; 14951 negated = wrapper.getNotToken() != null; 14952 } else if (type == EExpressionType.simple_comparison_t) { 14953 kind = PredicateSubqueryKind.SCALAR_COMPARISON; 14954 operator = comparisonOperatorOf(wrapper); 14955 } else if (type == EExpressionType.group_comparison_t) { 14956 kind = PredicateSubqueryKind.QUANTIFIED_COMPARISON; 14957 operator = comparisonOperatorOf(wrapper); 14958 quantifier = wrapper.getQuantifierType(); 14959 } else { 14960 throw invalidPredicateSubqueryContext(wrapper, clause, 14961 "recognised predicate-subquery wrapper has unexpected type " + type); 14962 } 14963 14964 if ((kind == PredicateSubqueryKind.SCALAR_COMPARISON 14965 || kind == PredicateSubqueryKind.QUANTIFIED_COMPARISON) 14966 && (operator == null || operator.isEmpty())) { 14967 throw invalidPredicateSubqueryContext(wrapper, clause, 14968 "comparison predicate-subquery wrapper has no operator token"); 14969 } 14970 if (kind == PredicateSubqueryKind.QUANTIFIED_COMPARISON 14971 && (quantifier == null || quantifier == EQuantifierType.none)) { 14972 throw invalidPredicateSubqueryContext(wrapper, clause, 14973 "quantified predicate-subquery wrapper has no quantifier"); 14974 } 14975 14976 TSelectSqlStatement inner = extractableNode == null 14977 ? null : extractableNode.getSubQuery(); 14978 return new PredicateSubqueryContext(kind, negated, operator, 14979 quantifier, clause, SourceSpan.of(wrapper), SourceSpan.of(inner)); 14980 } 14981 14982 private static String comparisonOperatorOf(TExpression wrapper) { 14983 return wrapper.getOperatorToken() == null 14984 ? null : wrapper.getOperatorToken().toString().trim(); 14985 } 14986 14987 String constructDisplayName() { 14988 switch (kind) { 14989 case EXISTS: 14990 return negated ? "NOT EXISTS subquery" : "EXISTS subquery"; 14991 case IN: 14992 return negated ? "NOT IN subquery" : "IN subquery"; 14993 case SCALAR_COMPARISON: 14994 return comparisonOperator + " scalar subquery comparison"; 14995 case QUANTIFIED_COMPARISON: 14996 return comparisonOperator + " " 14997 + quantifier.name().toUpperCase(Locale.ROOT) 14998 + " subquery comparison"; 14999 default: 15000 throw new IllegalStateException("unhandled predicate-subquery kind " + kind); 15001 } 15002 } 15003 } 15004 15005 /** 15006 * Central renderer for unsupported predicate-subquery diagnostics. 15007 * Stable codes continue to identify the failed semantic capability; 15008 * message text and provenance identify the actual wrapper and clause. 15009 */ 15010 private static final class PredicateSubqueryDiagnostics { 15011 private static final String WRAPPER_ROLE = "predicate-subquery-wrapper"; 15012 15013 static Diagnostic unsupported(DiagnosticCode code, 15014 PredicateSubqueryContext context, 15015 String reason, 15016 TParseTreeNode failureAnchor) { 15017 return unsupportedWithSpan(code, context, reason, 15018 SourceSpan.of(failureAnchor)); 15019 } 15020 15021 static Diagnostic unsupportedWithSpan(DiagnosticCode code, 15022 PredicateSubqueryContext context, 15023 String reason, 15024 SourceSpan failureSpan) { 15025 SourceSpan primary = failureSpan != null 15026 ? failureSpan 15027 : (context.innerSpan != null 15028 ? context.innerSpan : context.wrapperSpan); 15029 Diagnostic.Builder builder = Diagnostic.builder(code, Severity.ERROR, 15030 context.constructDisplayName() + " in " 15031 + context.clause.clauseLabel + ": " + reason) 15032 .span(primary); 15033 if (context.wrapperSpan != null 15034 && !context.wrapperSpan.equals(primary)) { 15035 builder.addRelatedLocation(RelatedLocation.of( 15036 WRAPPER_ROLE, 15037 "predicate subquery wrapper is here", 15038 context.wrapperSpan)); 15039 } 15040 return builder.build(); 15041 } 15042 } 15043 15044 private static SemanticIRBuildException invalidPredicateSubqueryContext( 15045 TExpression wrapper, 15046 PredicateClauseContext clause, 15047 String reason) { 15048 return new SemanticIRBuildException( 15049 Diagnostic.error(clause.genericSubqueryNotSupported, 15050 "predicate subquery in " + clause.clauseLabel + ": " + reason, 15051 wrapper)); 15052 } 15053 15054 /** 15055 * Slice 110 — preserved entry-point alias for the JOIN-ON walker. 15056 * Delegates to {@link #extractUncorrelatedPredicateSubqueriesFromClause} 15057 * with {@link PredicateClauseContext#JOIN_ON} so existing JOIN-ON 15058 * callers (single site in {@code buildRelations}) need no change and 15059 * the slice-23+ diagnostic byte-shape is preserved exactly. 15060 */ 15061 private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromJoinOn( 15062 TExpression onCond, 15063 final NameBindingProvider provider, 15064 final List<StatementGraph> stmts, 15065 final List<LineageEdge> lineage, 15066 final Map<String, Integer> cteMapForExtraction) { 15067 return extractUncorrelatedPredicateSubqueriesFromClause( 15068 onCond, provider, stmts, lineage, cteMapForExtraction, 15069 PredicateClauseContext.JOIN_ON, 15070 /*correlationScope=*/ null); 15071 } 15072 15073 /** 15074 * Slice 118 — overload preserved for the slice-110 / 111 / 112 / 113 / 15075 * 114 / 116 call sites that don't admit correlation. Delegates to the 15076 * 8-arg form with {@code correlationScope=null}. 15077 */ 15078 private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause( 15079 TExpression onCond, 15080 final NameBindingProvider provider, 15081 final List<StatementGraph> stmts, 15082 final List<LineageEdge> lineage, 15083 final Map<String, Integer> cteMapForExtraction, 15084 final PredicateClauseContext ctx) { 15085 return extractUncorrelatedPredicateSubqueriesFromClause(onCond, 15086 provider, stmts, lineage, cteMapForExtraction, ctx, 15087 /*correlationScope=*/ null, /*semiFactsOut=*/ null, 15088 /*outerRelationAliases=*/ Collections.<String>emptySet()); 15089 } 15090 15091 /** 15092 * R8 — delegating overload preserving the pre-R8 7-arg signature 15093 * (correlationScope, no semi-join collector). MERGE / UPDATE / DELETE 15094 * predicate-WHERE callers route here; semi-join facts are collected 15095 * only on the SELECT WHERE path (which calls the 8-arg overload with a 15096 * non-null {@code semiFactsOut}). 15097 */ 15098 private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause( 15099 TExpression onCond, 15100 final NameBindingProvider provider, 15101 final List<StatementGraph> stmts, 15102 final List<LineageEdge> lineage, 15103 final Map<String, Integer> cteMapForExtraction, 15104 final PredicateClauseContext ctx, 15105 final EnclosingScope correlationScope) { 15106 return extractUncorrelatedPredicateSubqueriesFromClause(onCond, 15107 provider, stmts, lineage, cteMapForExtraction, ctx, 15108 correlationScope, /*semiFactsOut=*/ null, 15109 /*outerRelationAliases=*/ Collections.<String>emptySet()); 15110 } 15111 15112 /** 15113 * Slice 118 — same as the 7-arg overload but threads an optional 15114 * {@code correlationScope} (target + USING source + outer CTEs) into 15115 * {@link #extractOnePredicateSubqueryBody}. When non-null, the inner 15116 * predicate-body build uses tolerant outer binding and the post-build 15117 * correlation walk PROMOTES outer-aliased refs into synthesised 15118 * OUTER_REFERENCE relations instead of rejecting them. The FILTER and 15119 * WITHIN GROUP correlation walks remain active (codex round-1 Q2 15120 * BLOCKING fix) so refs hidden inside FILTER subtrees or PG 15121 * {@code fn.withinGroup.orderBy} continue to reject. 15122 * 15123 * <p>All non-MERGE callers pass {@code correlationScope=null} and 15124 * therefore see the same semantic behaviour. Only 15125 * {@link #collectMergeActionWhere} passes a non-null scope (built once 15126 * per MERGE in {@code buildMerge} via 15127 * {@link #buildMergeEnclosingScope}). 15128 */ 15129 private static Set<TExpression> extractUncorrelatedPredicateSubqueriesFromClause( 15130 TExpression onCond, 15131 final NameBindingProvider provider, 15132 final List<StatementGraph> stmts, 15133 final List<LineageEdge> lineage, 15134 final Map<String, Integer> cteMapForExtraction, 15135 final PredicateClauseContext ctx, 15136 final EnclosingScope correlationScope, 15137 final List<SemiJoinFact> semiFactsOut, 15138 final Set<String> outerRelationAliases) { 15139 // Defensive null assertions — the extraction path can only be 15140 // reached from buildRelations when 15141 // allowJoinOnPredicateSubqueries=true (outer-SELECT only), which 15142 // guarantees stmts/lineage/cteMap are non-null. Failing here means 15143 // a future refactor wired a non-outer call site through the slice-25 15144 // path without supplying the required state. 15145 if (onCond == null) { 15146 return Collections.newSetFromMap(new java.util.IdentityHashMap<TExpression, Boolean>()); 15147 } 15148 if (stmts == null || lineage == null || cteMapForExtraction == null) { 15149 throw new IllegalStateException( 15150 "extractUncorrelatedPredicateSubqueriesFromClause(" 15151 + ctx.clauseLabel + ") activated without required state — " 15152 + "stmts=" + (stmts == null ? "null" : "ok") 15153 + " lineage=" + (lineage == null ? "null" : "ok") 15154 + " cteMap=" + (cteMapForExtraction == null ? "null" : "ok") 15155 + "; caller misconfiguration"); 15156 } 15157 final Set<TExpression> extractedRoots = 15158 Collections.newSetFromMap(new java.util.IdentityHashMap<TExpression, Boolean>()); 15159 // Slice 25 (impl-review M1-fix): explicit reject for 15160 // {@code logical_not_t} over a slice-25 IN / scalar-cmp / 15161 // ANY-ALL-SOME wrapper at the root. The slice-23/24 15162 // {@code logical_not_t} over {@code exists_t} (NOT EXISTS) 15163 // remains admitted by unwrapToInnerExtractableSubquery. 15164 if (isLogicalNotOverNonExistsWrapper(onCond)) { 15165 throw new SemanticIRBuildException( 15166 Diagnostic.error(ctx.predicateNotNot, 15167 "predicate subquery in " + ctx.clauseLabel + ": NOT applied to " 15168 + "a non-EXISTS predicate subquery wrapper " 15169 + "(" + onCond.getRightOperand().getExpressionType() 15170 + ") is not supported yet — the slice-25 boundary " 15171 + "admits NOT only over EXISTS; " 15172 + "rewrite e.g. NOT (a IN (SELECT ...)) as " 15173 + "a NOT IN (SELECT ...)", onCond)); 15174 } 15175 // Root fast path: acceptChildren never visits the root node, so 15176 // a clause whose entire expression IS a wrapper would be missed 15177 // by the descendant walker. 15178 TExpression rootExtractable = unwrapToInnerExtractableSubquery(onCond); 15179 if (rootExtractable != null) { 15180 PredicateSubqueryContext subqueryContext = 15181 PredicateSubqueryContext.from(onCond, rootExtractable, ctx); 15182 // M1-fix + slice-26 dual-side: validate the non-subquery 15183 // side of non-EXISTS wrappers BEFORE extracting (so partial 15184 // extraction never lands). Slice 25 carryover: subquery on 15185 // RHS → validate LHS via isAdmittedOuterLhsShape. 15186 // Slice 26 NEW: subquery on LHS (simple_comparison_t only) 15187 // → validate RHS via isAdmittedOuterRhsShape. 15188 if (onCond.getExpressionType() != EExpressionType.exists_t 15189 && onCond.getExpressionType() != EExpressionType.logical_not_t) { 15190 boolean isLhsSubquery = (rootExtractable == onCond.getLeftOperand()); 15191 boolean nonSubquerySideOk = isLhsSubquery 15192 ? isAdmittedOuterRhsShape(onCond.getRightOperand()) 15193 : isAdmittedOuterLhsShape(onCond.getLeftOperand()); 15194 if (!nonSubquerySideOk) { 15195 throw new SemanticIRBuildException( 15196 PredicateSubqueryDiagnostics.unsupported( 15197 ctx.outerShapeRejected, 15198 subqueryContext, 15199 buildOuterShapeRejectionReason( 15200 onCond, isLhsSubquery), 15201 isLhsSubquery 15202 ? onCond.getRightOperand() 15203 : onCond.getLeftOperand())); 15204 } 15205 } 15206 extractOnePredicateSubqueryBody(onCond, rootExtractable, provider, stmts, lineage, 15207 cteMapForExtraction, subqueryContext, correlationScope, semiFactsOut, 15208 outerRelationAliases); 15209 extractedRoots.add(rootExtractable); 15210 } 15211 // Descendant walk: find every wrapper at any depth. Skip into 15212 // already-extracted subtrees (so we don't re-enter the body 15213 // looking for nested wrappers — covered by the inner-shape 15214 // preflight's "no nested predicate subqueries in body" 15215 // rejection). 15216 onCond.acceptChildren(new TParseTreeVisitor() { 15217 // Track depth into already-extracted roots and into wrapper 15218 // subtrees we've extracted. preVisit increments on the 15219 // wrapper (the parent that contained the inner extractable); 15220 // postVisit decrements on either the inner extractable 15221 // (extractedRoots.contains) or the wrapper 15222 // (unwrapToInnerExtractableSubquery != null). The 15223 // {@code skipDepth > 0} guard prevents the second 15224 // decrement from going negative when both apply (e.g. NOT 15225 // EXISTS — both the logical_not_t wrapper and the inner 15226 // exists_t fire). 15227 int skipDepth = 0; 15228 15229 @Override 15230 public void preVisit(TExpression e) { 15231 if (skipDepth > 0) return; 15232 if (extractedRoots.contains(e)) { 15233 // Already-extracted inner being re-visited shouldn't 15234 // happen in normal traversal but defensive guard 15235 // avoids double-extraction if it ever did. 15236 skipDepth++; 15237 return; 15238 } 15239 // Slice 25 (impl-review M1-fix): explicit reject for 15240 // {@code logical_not_t} over a slice-25 wrapper at any 15241 // depth. Without this, the visitor would descend into 15242 // the wrapper child and silently extract — admitting 15243 // a shape (`NOT (a IN (SELECT ...))`) that the 15244 // slice-25 boundary does NOT admit. 15245 if (isLogicalNotOverNonExistsWrapper(e)) { 15246 throw new SemanticIRBuildException( 15247 Diagnostic.error(ctx.predicateNotNot, 15248 "predicate subquery in " + ctx.clauseLabel + ": NOT applied to " 15249 + "a non-EXISTS predicate subquery wrapper " 15250 + "(" + e.getRightOperand().getExpressionType() 15251 + ") is not supported yet — the slice-25 " 15252 + "boundary admits NOT only over EXISTS; " 15253 + "rewrite e.g. NOT (a IN (SELECT ...)) as " 15254 + "a NOT IN (SELECT ...)", e)); 15255 } 15256 TExpression toExtract = unwrapToInnerExtractableSubquery(e); 15257 if (toExtract != null) { 15258 PredicateSubqueryContext subqueryContext = 15259 PredicateSubqueryContext.from(e, toExtract, ctx); 15260 // M1-fix + slice-26 dual-side: validate the non- 15261 // subquery side BEFORE extracting (so partial 15262 // extraction never lands). The slice-23/24 15263 // NOT-EXISTS path uses logical_not_t, which has no 15264 // outer-shape gate. 15265 if (e.getExpressionType() != EExpressionType.exists_t 15266 && e.getExpressionType() != EExpressionType.logical_not_t) { 15267 boolean isLhsSubquery = (toExtract == e.getLeftOperand()); 15268 boolean nonSubquerySideOk = isLhsSubquery 15269 ? isAdmittedOuterRhsShape(e.getRightOperand()) 15270 : isAdmittedOuterLhsShape(e.getLeftOperand()); 15271 if (!nonSubquerySideOk) { 15272 throw new SemanticIRBuildException( 15273 PredicateSubqueryDiagnostics.unsupported( 15274 ctx.outerShapeRejected, 15275 subqueryContext, 15276 buildOuterShapeRejectionReason( 15277 e, isLhsSubquery), 15278 isLhsSubquery 15279 ? e.getRightOperand() 15280 : e.getLeftOperand())); 15281 } 15282 } 15283 if (extractedRoots.contains(toExtract)) return; 15284 extractOnePredicateSubqueryBody(e, toExtract, provider, stmts, lineage, 15285 cteMapForExtraction, subqueryContext, correlationScope, semiFactsOut, 15286 outerRelationAliases); 15287 extractedRoots.add(toExtract); 15288 skipDepth++; 15289 } 15290 } 15291 15292 @Override 15293 public void postVisit(TExpression e) { 15294 // M2-fix: decrement on EITHER the extracted inner 15295 // (extractedRoots.contains) OR the wrapper 15296 // (unwrapToInnerExtractableSubquery != null). The 15297 // {@code skipDepth > 0} guard prevents going negative 15298 // when both apply. 15299 if (skipDepth > 0 15300 && (extractedRoots.contains(e) 15301 || unwrapToInnerExtractableSubquery(e) != null)) { 15302 skipDepth--; 15303 } 15304 } 15305 }); 15306 return extractedRoots; 15307 } 15308 15309 /** 15310 * R8 — a predicate-derived semi-join discovered during WHERE 15311 * predicate-subquery extraction. Carries the polarity (semi vs 15312 * anti-semi), the lifted inner block's statement index + label, the 15313 * optional outer relation alias (for the left endpoint), the 15314 * correlated outer↔inner column conditions (may be empty), and the 15315 * wrapper's source span / verbatim text. Converted to a 15316 * {@link JoinEntity} at JoinGraph-assembly time (which has the outer 15317 * relation list to resolve the left endpoint's qualified name). 15318 */ 15319 private static final class SemiJoinFact { 15320 final SemanticJoinType polarity; 15321 final int innerStatementIndex; 15322 final String innerLabel; 15323 // Distinct outer relation aliases referenced by the correlation 15324 // (insertion order). Empty for an uncorrelated subquery. When a 15325 // single alias, the left endpoint is that relation; when several, 15326 // the left endpoint is the accumulated outer rowset (codex R8). 15327 final List<String> outerAliases; 15328 final List<Predicate> conditions; // never null; may be empty 15329 final SourceSpan span; // nullable 15330 final String conditionText; // nullable 15331 /** 15332 * Degrade only (GitHub #708): the body was never analyzed, so WHICH 15333 * outer relation the subquery correlates to is unknown — as opposed to 15334 * known-to-be-none, which is what an empty {@link #outerAliases} means 15335 * on the normal path. Anchors the left endpoint to the whole outer 15336 * rowset instead of letting the single-relation fallback name one. 15337 */ 15338 final boolean outerAnchorUnknown; 15339 15340 SemiJoinFact(SemanticJoinType polarity, int innerStatementIndex, 15341 String innerLabel, List<String> outerAliases, 15342 List<Predicate> conditions, SourceSpan span, 15343 String conditionText) { 15344 this(polarity, innerStatementIndex, innerLabel, outerAliases, 15345 conditions, span, conditionText, /*outerAnchorUnknown=*/ false); 15346 } 15347 15348 SemiJoinFact(SemanticJoinType polarity, int innerStatementIndex, 15349 String innerLabel, List<String> outerAliases, 15350 List<Predicate> conditions, SourceSpan span, 15351 String conditionText, boolean outerAnchorUnknown) { 15352 this.outerAnchorUnknown = outerAnchorUnknown; 15353 this.polarity = polarity; 15354 this.innerStatementIndex = innerStatementIndex; 15355 this.innerLabel = innerLabel; 15356 this.outerAliases = outerAliases == null 15357 ? Collections.<String>emptyList() : outerAliases; 15358 this.conditions = conditions == null 15359 ? Collections.<Predicate>emptyList() : conditions; 15360 this.span = span; 15361 this.conditionText = conditionText; 15362 } 15363 } 15364 15365 /** 15366 * R8 — semi-join polarity for a predicate wrapper. {@code EXISTS} / 15367 * {@code IN} → {@link SemanticJoinType#SEMI}; {@code NOT EXISTS} 15368 * (logical_not over exists) / {@code NOT IN} ({@code in_t} with a NOT 15369 * token) → {@link SemanticJoinType#ANTI_SEMI}. Returns {@code null} 15370 * for wrappers that are not modelled as semi-joins (scalar comparison, 15371 * ANY/ALL/SOME) so the caller emits no semi-join fact for them. 15372 */ 15373 private static SemanticJoinType semiJoinPolarity(TExpression wrapper) { 15374 if (wrapper == null) return null; 15375 EExpressionType t = wrapper.getExpressionType(); 15376 if (t == EExpressionType.logical_not_t) { 15377 // NOT EXISTS — the slice-25 boundary admits NOT only over EXISTS. 15378 TExpression inner = wrapper.getRightOperand(); 15379 if (inner != null && inner.getExpressionType() == EExpressionType.exists_t) { 15380 return SemanticJoinType.ANTI_SEMI; 15381 } 15382 return null; 15383 } 15384 if (t == EExpressionType.exists_t) { 15385 return SemanticJoinType.SEMI; 15386 } 15387 if (t == EExpressionType.in_t) { 15388 return wrapper.getNotToken() != null 15389 ? SemanticJoinType.ANTI_SEMI : SemanticJoinType.SEMI; 15390 } 15391 return null; 15392 } 15393 15394 /** 15395 * R8 — build the {@link SemiJoinFact} for an extracted predicate 15396 * subquery, or {@code null} when the wrapper is not a semi-join shape 15397 * ({@link #semiJoinPolarity} null) or no collector is requested. 15398 * 15399 * <p>Correlation pairing: 15400 * <ul> 15401 * <li>{@code IN}: outer column = the non-subquery side of the 15402 * {@code in_t}; inner column = the subquery's first output 15403 * column source.</li> 15404 * <li>{@code EXISTS} / {@code NOT EXISTS}: outer↔inner pairs are 15405 * lifted from equi-conjuncts in the inner WHERE that compare an 15406 * inner-local column to an outer (non-local) column.</li> 15407 * </ul> 15408 */ 15409 private static SemiJoinFact buildSemiJoinFact(TExpression wrapper, 15410 TExpression extractableNode, 15411 TSelectSqlStatement inner, 15412 StatementGraph innerStmt, 15413 int innerIndex, String innerLabel, 15414 Set<String> innerLocalAliases, 15415 NameBindingProvider provider) { 15416 SemanticJoinType polarity = semiJoinPolarity(wrapper); 15417 if (polarity == null) return null; 15418 List<Predicate> conditions = new ArrayList<>(); 15419 // Distinct outer aliases, insertion-ordered (codex R8: drives the 15420 // single-relation-vs-joined-rowset left-endpoint choice). 15421 java.util.LinkedHashSet<String> outerAliases = new java.util.LinkedHashSet<>(); 15422 if (wrapper.getExpressionType() == EExpressionType.in_t) { 15423 // Membership equality: outer side (the non-subquery operand) = 15424 // the subquery's first output column. Both must be bare columns. 15425 boolean lhsIsSubquery = (extractableNode == wrapper.getLeftOperand()); 15426 TExpression outerSide = lhsIsSubquery 15427 ? wrapper.getRightOperand() : wrapper.getLeftOperand(); 15428 ColumnRef outerCol = bareColumnRefOf(outerSide, provider); 15429 // codex R8 [P2 round 2]: the inner membership column must ALSO 15430 // be a bare column. `a.id IN (SELECT b.id + 1 FROM b)` projects 15431 // a compound expression — emitting `a.id = b.id` would be a 15432 // fabricated equality, so omit the membership pair there. 15433 ColumnRef innerCol = isFirstProjectionBareColumn(inner) 15434 ? firstOutputColumnRefOf(innerStmt) : null; 15435 // codex R8 [P2 round 3]: the inner membership column must be a 15436 // genuine inner-local column. `a.id IN (SELECT a.id FROM t2 b)` 15437 // projects an OUTER column — emitting `a.id = a.id` would 15438 // misrepresent the predicate and not connect the right endpoint. 15439 if (outerCol != null && innerCol != null 15440 && isLocalAlias(innerCol, innerLocalAliases)) { 15441 addOuterAlias(outerAliases, outerCol); 15442 conditions.add(equiPredicate(outerCol, innerCol, 15443 SourceSpan.of(wrapper))); 15444 } 15445 // codex R8 [P1]: a correlated IN (e.g. `a.id IN (SELECT b.id 15446 // FROM b WHERE b.k = a.k)`) also carries correlation in the 15447 // inner WHERE — fold those pairs in too so they are not lost. 15448 for (ColumnRef[] pair : extractCorrelationPairs(inner, 15449 innerLocalAliases, provider)) { 15450 addOuterAlias(outerAliases, pair[0]); 15451 conditions.add(equiPredicate(pair[0], pair[1], null)); 15452 } 15453 } else { 15454 // EXISTS / NOT EXISTS — correlation lives in the inner WHERE. 15455 for (ColumnRef[] pair : extractCorrelationPairs(inner, 15456 innerLocalAliases, provider)) { 15457 addOuterAlias(outerAliases, pair[0]); 15458 conditions.add(equiPredicate(pair[0], pair[1], null)); 15459 } 15460 } 15461 return new SemiJoinFact(polarity, innerIndex, innerLabel, 15462 new ArrayList<>(outerAliases), 15463 conditions, SourceSpan.of(wrapper), verbatimText(wrapper)); 15464 } 15465 15466 private static void addOuterAlias(java.util.LinkedHashSet<String> aliases, 15467 ColumnRef outerCol) { 15468 if (outerCol != null && outerCol.getRelationAlias() != null 15469 && !outerCol.getRelationAlias().isEmpty()) { 15470 aliases.add(outerCol.getRelationAlias()); 15471 } 15472 } 15473 15474 /** 15475 * R8 — the resolved {@link ColumnRef} for an expression that is a 15476 * <em>bare, qualified column reference</em> ({@code simple_object_name_t} 15477 * with a relation alias), or {@code null} otherwise. Strict by design 15478 * (codex R8 review): a compound operand such as {@code b.k + 1} or 15479 * {@code lower(a.n)} returns {@code null} so we never synthesise a 15480 * misleading EQUI column predicate from a side that is not a plain 15481 * column (the pair is omitted instead). 15482 */ 15483 private static ColumnRef bareColumnRefOf(TExpression expr, 15484 NameBindingProvider provider) { 15485 if (expr == null 15486 || expr.getExpressionType() != EExpressionType.simple_object_name_t) { 15487 return null; 15488 } 15489 try { 15490 List<ColumnRef> refs = collectColumnRefs(expr, provider); 15491 for (ColumnRef r : refs) { 15492 if (r != null && r.getRelationAlias() != null 15493 && !r.getRelationAlias().isEmpty()) { 15494 return r; 15495 } 15496 } 15497 } catch (RuntimeException ignore) { 15498 // Tolerant: an un-extractable column ref just yields no column. 15499 } 15500 return null; 15501 } 15502 15503 /** 15504 * R8 — true iff the inner SELECT's first projection is a bare column 15505 * reference ({@code simple_object_name_t}). Guards the IN membership 15506 * equality so a compound projection ({@code SELECT b.id + 1 ...}) does 15507 * not produce a fabricated bare-column equality. 15508 */ 15509 private static boolean isFirstProjectionBareColumn(TSelectSqlStatement inner) { 15510 if (inner == null || inner.getResultColumnList() == null 15511 || inner.getResultColumnList().size() == 0) { 15512 return false; 15513 } 15514 TResultColumn rc = inner.getResultColumnList().getResultColumn(0); 15515 if (rc == null || rc.getExpr() == null) { 15516 return false; 15517 } 15518 return rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t; 15519 } 15520 15521 /** R8 — the first source {@link ColumnRef} of a block's first output column. */ 15522 private static ColumnRef firstOutputColumnRefOf(StatementGraph innerStmt) { 15523 if (innerStmt == null) return null; 15524 List<OutputColumn> outs = innerStmt.getOutputColumns(); 15525 if (outs == null || outs.isEmpty()) return null; 15526 List<ColumnRef> sources = outs.get(0).getSources(); 15527 if (sources == null || sources.isEmpty()) return null; 15528 return sources.get(0); 15529 } 15530 15531 /** R8 — synthesise an EQUI {@link Predicate} from two column refs. */ 15532 private static Predicate equiPredicate(ColumnRef left, ColumnRef right, 15533 SourceSpan span) { 15534 return new Predicate(PredicateKind.EQUI, "=", 15535 PredicateOperand.column(left, null), 15536 PredicateOperand.column(right, null), span); 15537 } 15538 15539 /** 15540 * R8 — lift outer↔inner correlation pairs from a correlated EXISTS 15541 * inner WHERE. Walks the top-level AND conjuncts iteratively (the 15542 * left-leaning binary tree of {@code logical_and_t} — never recurse, 15543 * per the project StackOverflow guard) and, for each {@code =} 15544 * comparison whose two sides are one inner-local column and one 15545 * outer (non-local) column, emits the pair {@code [outerCol, innerCol]}. 15546 * Returns an empty list for an uncorrelated EXISTS. 15547 */ 15548 private static List<ColumnRef[]> extractCorrelationPairs( 15549 TSelectSqlStatement inner, Set<String> innerLocalAliases, 15550 NameBindingProvider provider) { 15551 List<ColumnRef[]> out = new ArrayList<>(); 15552 if (inner == null || inner.getWhereClause() == null 15553 || inner.getWhereClause().getCondition() == null) { 15554 return out; 15555 } 15556 // Iterative DFS over AND-conjuncts (left-leaning tree). 15557 Deque<TExpression> stack = new ArrayDeque<>(); 15558 stack.push(inner.getWhereClause().getCondition()); 15559 int guard = 0; 15560 while (!stack.isEmpty() && guard++ < 100000) { 15561 TExpression e = stack.pop(); 15562 if (e == null) continue; 15563 if (e.getExpressionType() == EExpressionType.logical_and_t) { 15564 if (e.getRightOperand() != null) stack.push(e.getRightOperand()); 15565 if (e.getLeftOperand() != null) stack.push(e.getLeftOperand()); 15566 continue; 15567 } 15568 if (e.getExpressionType() != EExpressionType.simple_comparison_t) { 15569 continue; 15570 } 15571 String op = e.getOperatorToken() != null 15572 ? e.getOperatorToken().toString() : null; 15573 if (op == null || !"=".equals(op)) continue; 15574 ColumnRef l = bareColumnRefOf(e.getLeftOperand(), provider); 15575 ColumnRef r = bareColumnRefOf(e.getRightOperand(), provider); 15576 if (l == null || r == null) continue; 15577 boolean lLocal = isLocalAlias(l, innerLocalAliases); 15578 boolean rLocal = isLocalAlias(r, innerLocalAliases); 15579 // Exactly one side local → the other is the outer correlation. 15580 if (lLocal && !rLocal) { 15581 out.add(new ColumnRef[]{ r, l }); // [outer, inner] 15582 } else if (rLocal && !lLocal) { 15583 out.add(new ColumnRef[]{ l, r }); 15584 } 15585 } 15586 return out; 15587 } 15588 15589 private static boolean isLocalAlias(ColumnRef ref, Set<String> innerLocalAliases) { 15590 return ref.getRelationAlias() != null 15591 && innerLocalAliases.contains( 15592 ref.getRelationAlias().toLowerCase(Locale.ROOT)); 15593 } 15594 15595 /** 15596 * R8 — true iff {@code alias} names a relation in the host SELECT's FROM 15597 * list ({@code outerRelationAliases}, lower-cased). Gates the 15598 * correlated-EXISTS degrade so only a genuine outer-relation correlation 15599 * degrades; an alias that is neither inner-local nor a known outer 15600 * relation (a typo) still rejects. 15601 */ 15602 private static boolean isKnownOuterAlias(String alias, 15603 Set<String> outerRelationAliases) { 15604 return alias != null && outerRelationAliases != null 15605 && outerRelationAliases.contains(alias.toLowerCase(Locale.ROOT)); 15606 } 15607 15608 /** 15609 * Slice 25 (rename of slice-23 {@code extractOneExistsBody}): 15610 * extract a single predicate-subquery body's inner SELECT as its 15611 * own {@code <predicate_subquery_<i>>} StatementGraph. Runs the 15612 * inner-shape preflight before recursive build, then post-build 15613 * correlation check. 15614 * 15615 * <p>{@code extractableNode} is either an {@code exists_t} 15616 * (slice-23 EXISTS / slice-24 column-bearing EXISTS) or a 15617 * {@code subquery_t} (slice-25 IN-SELECT / scalar comparison / 15618 * ANY-ALL-SOME). Both expose the inner SELECT via 15619 * {@link TExpression#getSubQuery()}. 15620 * 15621 * <p>R8 — {@code wrapper} is the predicate wrapper containing 15622 * {@code extractableNode} ({@code exists_t} / {@code logical_not_t} 15623 * over exists / {@code in_t} / comparison). When {@code semiFactsOut} 15624 * is non-null and the wrapper is an EXISTS/IN shape, a 15625 * {@link SemiJoinFact} is appended for the JoinGraph. When 15626 * {@code correlationScope} is null a correlated EXISTS now DEGRADES 15627 * (records the correlation, does not throw) instead of failing with 15628 * {@code *_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS} (R8 degrade). 15629 */ 15630 private static int extractOnePredicateSubqueryBody(TExpression wrapper, 15631 TExpression extractableNode, 15632 NameBindingProvider provider, 15633 List<StatementGraph> stmts, 15634 List<LineageEdge> lineage, 15635 Map<String, Integer> cteMapForExtraction, 15636 PredicateSubqueryContext context, 15637 EnclosingScope correlationScope, 15638 List<SemiJoinFact> semiFactsOut, 15639 Set<String> outerRelationAliases) { 15640 if (!activeOptions().isDegradeUnsupportedNestedBlocks()) { 15641 return extractOnePredicateSubqueryBodyStrict(wrapper, extractableNode, provider, 15642 stmts, lineage, cteMapForExtraction, context, correlationScope, 15643 semiFactsOut, outerRelationAliases); 15644 } 15645 // Degrade mode. Mark every list the strict extractor appends to, so a 15646 // reject thrown midway (e.g. inside emitLineageForStatement, after the 15647 // body was already appended) cannot leave a half-built body behind. 15648 // `cteMapForExtraction` is not marked: the predicate body is built with 15649 // null extraction maps and registers no CTE of its own. 15650 final int stmtsMark = stmts.size(); 15651 final int lineageMark = lineage.size(); 15652 final int semiFactsMark = (semiFactsOut == null) ? 0 : semiFactsOut.size(); 15653 try { 15654 return extractOnePredicateSubqueryBodyStrict(wrapper, extractableNode, provider, 15655 stmts, lineage, cteMapForExtraction, context, correlationScope, 15656 semiFactsOut, outerRelationAliases); 15657 } catch (SemanticIRBuildException e) { 15658 // Recovery is a capability policy, not an error-suppression 15659 // policy. Invalid syntax/dialect semantics must reach the caller 15660 // as the original ERROR instead of becoming a successful opaque 15661 // block with only NESTED_BLOCK_UNANALYZED. 15662 if (!DiagnosticDescriptorCatalog 15663 .isUnsupportedNestedBlockRecoverable(e.getDiagnostics())) { 15664 throw e; 15665 } 15666 truncateTo(stmts, stmtsMark); 15667 truncateTo(lineage, lineageMark); 15668 if (semiFactsOut != null) { 15669 truncateTo(semiFactsOut, semiFactsMark); 15670 } 15671 return emitUnanalyzedPredicateBody(wrapper, extractableNode, 15672 e.getDiagnostic(), stmts, semiFactsOut); 15673 } 15674 } 15675 15676 /** Drop everything appended to {@code list} past {@code mark}. */ 15677 private static void truncateTo(List<?> list, int mark) { 15678 while (list.size() > mark) { 15679 list.remove(list.size() - 1); 15680 } 15681 } 15682 15683 /** 15684 * Degrade path — stand a {@link StatementGraph#KIND_UNANALYZED} 15685 * placeholder in for a predicate-subquery body the builder rejected, and 15686 * record the event as a {@link DiagnosticCode#NESTED_BLOCK_UNANALYZED} 15687 * warning. 15688 * 15689 * <p>This is sound precisely because a predicate body is unreachable from 15690 * its host block: no host relation names it and no lineage edge targets 15691 * it (see the "UNREACHABLE from outer" note in 15692 * {@link #extractOnePredicateSubqueryBodyStrict}). Dropping the body's own 15693 * facts therefore removes nothing the host block publishes — every fact 15694 * still returned was proved the same way it is proved today. 15695 * 15696 * <p>What IS lost is the body's own analysis plus the semi-join fact that 15697 * would have linked it to the host. That loss is explicit, not silent: 15698 * the placeholder occupies the body's slot with its synthetic name, its 15699 * kind reads {@code UNANALYZED} rather than {@code SELECT}, and it carries 15700 * the original ERROR diagnostic — anchored, when the reject site had no 15701 * span of its own, at the rejected inner block. 15702 * 15703 * @return the index of the placeholder in {@code stmts} 15704 */ 15705 private static int emitUnanalyzedPredicateBody(TExpression wrapper, 15706 TExpression extractableNode, 15707 Diagnostic originalReason, 15708 List<StatementGraph> stmts, 15709 List<SemiJoinFact> semiFactsOut) { 15710 Diagnostic reason = originalReason; 15711 SourceSpan span = reason.getSpan(); 15712 if (span == null) { 15713 // The reject site had no anchor. Fall back to the inner block 15714 // itself, then to the predicate wrapper node, so the caller can 15715 // still point at the offending text (the reporter's ask on 15716 // MantisBT 4676 / GitHub #708). 15717 TSelectSqlStatement inner = extractableNode.getSubQuery(); 15718 span = SourceSpan.of(inner); 15719 if (span == null) { 15720 span = SourceSpan.of(extractableNode); 15721 } 15722 // Re-stamp the reason with the fallback anchor so 15723 // getUnanalyzedReason().getSpan() points at the skipped block even 15724 // when the reject site itself had nothing to anchor to. Code, 15725 // message and ERROR severity are preserved verbatim. 15726 if (span != null) { 15727 reason = reason.withSpan(span); 15728 } 15729 } 15730 int idx = stmts.size(); 15731 String name = PREDICATE_BODY_PREFIX + idx + ">"; 15732 recordBuildWarning(Diagnostic.warnWithSpan( 15733 DiagnosticCode.NESTED_BLOCK_UNANALYZED, 15734 "predicate subquery body '" + name + "' was not analyzed: " 15735 + reason.getMessage(), span)); 15736 stmts.add(StatementGraph.unanalyzed(name, reason, span)); 15737 // Keep the semi-join. Its POLARITY is a property of the wrapper alone 15738 // ({@code EXISTS}/{@code IN} -> SEMI, {@code NOT EXISTS}/{@code NOT IN} 15739 // -> ANTI_SEMI): {@link #semiJoinPolarity} reads only `wrapper` and 15740 // never touches the body, so "the host has a semi-join against block 15741 // N" stays provable even though the body did not analyze. Dropping it 15742 // would delete a fact we can prove — the one thing degrade mode must 15743 // never do — and would leave the host JoinGraph claiming the EXISTS 15744 // simply is not there. 15745 // 15746 // The entity is deliberately OPAQUE: empty conditions and empty 15747 // outerAliases. Both are unknowable without the body, and empty 15748 // outerAliases routes {@link #outerEndpointFor} through the same 15749 // first-FROM-relation fallback an uncorrelated EXISTS already uses. 15750 // No predicate and no correlation pair is invented; the right endpoint 15751 // is the placeholder block, whose UNANALYZED kind tells the consumer 15752 // the other side was not analyzed. 15753 if (semiFactsOut != null) { 15754 SemanticJoinType polarity = semiJoinPolarity(wrapper); 15755 if (polarity != null) { 15756 semiFactsOut.add(new SemiJoinFact(polarity, idx, name, 15757 Collections.<String>emptyList(), 15758 Collections.<Predicate>emptyList(), 15759 SourceSpan.of(wrapper), 15760 // The wrapper is fully parsed, so its lexical text is 15761 // provable even though its structure is not. Keep it — 15762 // the supported path records the same thing. 15763 verbatimText(wrapper), 15764 /*outerAnchorUnknown=*/ true)); 15765 } 15766 } 15767 return idx; 15768 } 15769 15770 private static int extractOnePredicateSubqueryBodyStrict(TExpression wrapper, 15771 TExpression extractableNode, 15772 NameBindingProvider provider, 15773 List<StatementGraph> stmts, 15774 List<LineageEdge> lineage, 15775 Map<String, Integer> cteMapForExtraction, 15776 PredicateSubqueryContext context, 15777 EnclosingScope correlationScope, 15778 List<SemiJoinFact> semiFactsOut, 15779 Set<String> outerRelationAliases) { 15780 PredicateClauseContext ctx = context.clause; 15781 TSelectSqlStatement inner = extractableNode.getSubQuery(); 15782 if (inner == null) { 15783 // Degenerate node with no subquery; defensive. 15784 throw new SemanticIRBuildException( 15785 PredicateSubqueryDiagnostics.unsupported( 15786 ctx.existsBodyMissing, context, 15787 "subquery body is missing", wrapper)); 15788 } 15789 // (a–g) Inner-shape preflight (slice-23 boundary; slice 24 widens 15790 // (e) to admit single column-ref projection in addition to constant). 15791 preflightPredicateSubqueryShape(inner, context); 15792 15793 // Slice 118 — when an enclosing correlation scope is supplied 15794 // (MERGE per-WHEN action WHERE only), decorate `provider` with 15795 // tolerant outer binding so the inner build admits qualified refs 15796 // to outer aliases (target / USING source / outer CTEs) as 15797 // synthetic EXACT_MATCH bindings instead of rejecting them as 15798 // COLUMN_BINDING_NON_EXACT. Mirrors the slice-117 pattern for 15799 // UPDATE SET-RHS correlated scalars. Computed BEFORE 15800 // buildSelectStatementImpl so the inner build's bindColumn calls 15801 // see the tolerant fallback already populated (codex round-5 15802 // ordering fix from slice 117). Qualifiers IN the inner's local 15803 // FROM aliases still strict-reject so real typos (`o.bad_col` 15804 // where `o` IS the inner FROM alias) still surface as 15805 // COLUMN_BINDING_NON_EXACT. 15806 final NameBindingProvider effectiveProvider; 15807 if (correlationScope != null) { 15808 Set<String> innerLocalAliasesForTolerant = 15809 precomputeInnerLocalAliases(inner); 15810 effectiveProvider = innerLocalAliasesForTolerant.isEmpty() 15811 ? provider 15812 : provider.withTolerantOuterBinding( 15813 innerLocalAliasesForTolerant); 15814 } else { 15815 effectiveProvider = provider; 15816 } 15817 15818 // Build the inner SELECT as its own StatementGraph. SAME provider 15819 // as outer (codex round-1 MUST 3 — outer CTEs remain visible). 15820 // Slice 118: tolerant-decorated provider when correlationScope 15821 // != null (MERGE per-WHEN action WHERE only); same provider as 15822 // before otherwise. 15823 // hasOuterCteListAlreadyProcessed=false (codex round-2 SHOULD 1 — 15824 // generic nested-WITH guard remains active as belt-and-braces). 15825 // allowFromSubqueries=false (no FROM-subqueries in inner body for 15826 // slice 23). isPredicateBody=true: for constant-only inner emits one 15827 // synthetic OutputColumn (slice-23 path); for column-ref inner the 15828 // §4.1.2 short-circuit falls through to the normal column-ref path 15829 // (slice-24 widening). 15830 String predName = PREDICATE_BODY_PREFIX + stmts.size() + ">"; 15831 StatementGraph innerStmt; 15832 if (isPivotSelect(inner)) { 15833 // Slice 141: a nested PIVOT / UNPIVOT in a predicate subquery body 15834 // (`WHERE k IN (SELECT ... PIVOT(...))` / EXISTS / scalar-comparison 15835 // / ANY-ALL-SOME). The slice-129 pivot router in 15836 // {@link #buildSelectStatementImpl} is gated to the OUTER SELECT 15837 // context (`name == null && !isPredicateBody`); the predicate body 15838 // carries `isPredicateBody=true` and the synthetic 15839 // `<predicate_subquery_N>` name, so the gate misses and the body 15840 // would otherwise fall to the normal path which can't bind a 15841 // `pivoted_table` (rejects with the misleading 15842 // TABLE_BINDING_UNRESOLVED "null(piviot_table)"). Route it to 15843 // {@link #buildPivotSelect} with the synthetic predicate-body name 15844 // so the body becomes a proper pivot StatementGraph. 15845 // 15846 // PIVOT extra-clause (WHERE/GROUP BY/etc.) / chained / malformed- 15847 // UNPIVOT / bare-`*`-without-catalog rejects stay deferred via 15848 // {@code buildPivotSelect}'s own guards. A bare `SELECT *` over a 15849 // pivot in a predicate body is rejected EARLIER by 15850 // {@link #preflightPredicateSubqueryShape}'s slice-23 / 24 single-column 15851 // contract — the preflight {@code "*"} check fires before this 15852 // routing site is reached. PIVOT-then-JOIN stays on the normal 15853 // path because {@code isPivotSelect} requires 15854 // `items.size() == 0`. A subquery-source pivot in a predicate body 15855 // is rejected by the slice-24 belt-and-braces relation-kind walk 15856 // BELOW (admits TABLE / CTE only) via the existing 15857 // {@code ctx.existsInnerRelationUnknown} code. 15858 // 15859 // Direct analogue of slice 138 ({@link #processDirectSubqueryTable} 15860 // FROM-subquery body), slice 139 (the four CTE walkers), and 15861 // slice 140 ({@link #buildSetOpProgramInternal} set-op branch). 15862 // This is the SIXTH (and final) SemanticIRBuilder routing site 15863 // that previously missed the pivot router. 15864 // 15865 // Slice-23 contract preserved: the predicate body remains 15866 // UNREACHABLE from outer (no outer relation points at it; no 15867 // STATEMENT_OUTPUT edge from outer to the body). The body's own 15868 // {@link #emitLineageForStatement} call BELOW emits the pivot 15869 // output -> source edges (TABLE_COLUMN for base-table source, 15870 // STATEMENT_OUTPUT for CTE source when the body sits inside an 15871 // outer-WITH). For correlation purposes (MERGE per-WHEN action 15872 // WHERE — slice 118), the pivot body's outputs / consumed-refs are 15873 // built directly against the LOCAL source alias by 15874 // {@code buildPivotSelect}; any outer-alias ref inside the inner 15875 // would surface via the post-build correlation walk below (the 15876 // walk traverses `innerStmt`'s clause-ref slots, which for a pivot 15877 // body are exactly the pivot's local TABLE / CTE relation). 15878 innerStmt = buildPivotSelect(inner, effectiveProvider, predName); 15879 } else { 15880 innerStmt = buildSelectStatementImpl(inner, effectiveProvider, predName, 15881 /*hasOuterCteListAlreadyProcessed=*/ false, 15882 /*allowFromSubqueries=*/ false, 15883 /*allowScalarProjectionSubqueries=*/ false, 15884 /*allowWindowProjection=*/ false, 15885 /*allowJoinOnPredicateSubqueries=*/ false, 15886 /*stmtsForExtraction=*/ null, 15887 /*lineageForExtraction=*/ null, 15888 /*cteMapForExtraction=*/ null, 15889 /*isPredicateBody=*/ true, 15890 /*whereClauseContext=*/ PredicateClauseContext.SELECT_WHERE, 15891 /*allowWherePredicateSubqueries=*/ false); 15892 } 15893 15894 // Slice 24 (codex impl-review SHOULD 1): defensive relation-kind 15895 // walk. The preflight rejects FROM-subqueries; the post-build 15896 // correlation check below rejects OUTER_REFERENCE relations 15897 // (synthesised by promoteCorrelatedRefsToOuterReference for 15898 // outer refs we don't see). Belt-and-braces: the predicate body 15899 // must contain ONLY TABLE or CTE-bound relations. SUBQUERY / 15900 // OUTER_REFERENCE / UNION leaking through here would mean the 15901 // emitLineageForStatement call below routes through code paths 15902 // (e.g. the SUBQUERY-alias map) that we deliberately pass empty, 15903 // producing a SemanticIRBuildException about an unregistered 15904 // alias. Failing fast here surfaces the architectural violation 15905 // with a slice-24-tuned message instead. 15906 for (RelationSource r : innerStmt.getRelations()) { 15907 RelationKind kind = r.getBinding().getKind(); 15908 if (kind != RelationKind.TABLE && kind != RelationKind.CTE) { 15909 throw new SemanticIRBuildException( 15910 PredicateSubqueryDiagnostics.unsupported( 15911 ctx.existsInnerRelationUnknown, 15912 context, 15913 "inner SELECT relation '" 15914 + r.getAlias() + "' has unsupported binding kind " 15915 + kind + "; only TABLE or CTE relations are admitted " 15916 + "(slice 24 boundary)", 15917 inner)); 15918 } 15919 } 15920 // Post-build correlation check (codex round-1 MUST 2 + round-2 SHOULD 2). 15921 // Use the existing collectAllInnerRefs helper so clause coverage 15922 // stays in sync with promoteCorrelatedRefsToOuterReference. 15923 // Slice 24: collectAllInnerRefs includes OutputColumn.sources, so 15924 // a column-ref projection like `EXISTS (SELECT e.id FROM x)` where 15925 // `e` is the OUTER's alias trips the same correlation rejection — 15926 // no extra slice-24 code needed. 15927 // 15928 // Slice 118 — when correlationScope != null (MERGE per-WHEN action 15929 // WHERE only), instead of REJECTING outer-aliased refs we PROMOTE 15930 // them into synthesised OUTER_REFERENCE relations via 15931 // promoteCorrelatedRefsToOuterReference. Mirrors the slice-14 / 15932 // slice-117 pattern. Unknown outer aliases (not in target / USING 15933 // source / outer CTEs) still throw SCALAR_SUBQUERY_UNKNOWN_RELATION_ALIAS 15934 // (the promoter's existing boundary; diagnostic message says 15935 // "scalar subquery" — acceptable cosmetic limitation, slice 117 15936 // precedent). The slice-118 lift covers refs landing in 15937 // collectAllInnerRefs clauses (output sources, filter, join, 15938 // groupBy, having, orderBy, distinctOn); the FILTER and WITHIN 15939 // GROUP walks BELOW remain active so outer-aliased refs hidden 15940 // inside FILTER subtrees or PG fn.withinGroup.orderBy still 15941 // reject with SELECT_WHERE_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS 15942 // (codex round-1 Q2 BLOCKING preserved this boundary). 15943 Set<String> innerLocalAliases = new HashSet<>(); 15944 for (RelationSource r : innerStmt.getRelations()) { 15945 innerLocalAliases.add(r.getAlias().toLowerCase(Locale.ROOT)); 15946 } 15947 // R8 — DEGRADE GATE. A catalog-less correlated EXISTS / IN in a 15948 // top-level SELECT WHERE now analyzes (the correlation is surfaced 15949 // on the SEMI / ANTI_SEMI join entity) instead of failing with 15950 // *_EXISTS_CORRELATED_UNKNOWN_OUTER_ALIAS. The degrade is narrowly 15951 // scoped: only SELECT_WHERE, only non-MERGE (correlationScope == 15952 // null), only genuine EXISTS/IN semi-join shapes. JOIN-ON, scalar 15953 // comparison, ANY/ALL, FROM-subquery / scalar / set-op-branch / 15954 // CTE bodies, UPDATE/DELETE WHERE, and MERGE all keep the prior 15955 // correlation reject. 15956 boolean degradeCorrelated = correlationScope == null 15957 && ctx == PredicateClauseContext.SELECT_WHERE 15958 && semiJoinPolarity(wrapper) != null; 15959 if (correlationScope != null) { 15960 // Pass a descriptive "outerAlias" so the promoter's diagnostic 15961 // messages identify the MERGE predicate-body host context if 15962 // promotion fails on an unknown alias. 15963 innerStmt = promoteCorrelatedRefsToOuterReference( 15964 innerStmt, 15965 "<merge predicate subquery " + (stmts.size()) + ">", 15966 correlationScope); 15967 } else { 15968 for (ColumnRef ref : collectAllInnerRefs(innerStmt)) { 15969 if (!innerLocalAliases.contains(ref.getRelationAlias().toLowerCase(Locale.ROOT))) { 15970 if (degradeCorrelated 15971 && isKnownOuterAlias(ref.getRelationAlias(), outerRelationAliases)) { 15972 // A genuine correlation to an outer FROM relation: 15973 // the ref stays in the inner block's filterColumnRefs 15974 // and the outer↔inner pairing is surfaced on the 15975 // semi-join entity via buildSemiJoinFact. No throw. 15976 // codex R8 [P1 round 2]: an alias that is NEITHER 15977 // inner-local NOR a known outer relation (a typo like 15978 // `z`) still rejects below. 15979 continue; 15980 } 15981 throw new SemanticIRBuildException( 15982 PredicateSubqueryDiagnostics.unsupportedWithSpan( 15983 ctx.existsCorrelatedUnknownOuterAlias, 15984 context, 15985 "correlated reference to outer alias '" 15986 + ref.getRelationAlias() 15987 + "' is not supported yet (this context accepts " 15988 + "uncorrelated predicate subqueries only)", 15989 ref.getSourceSpan())); 15990 } 15991 } 15992 } 15993 // Slice 28: projection-only FILTER-aware correlation walk. The 15994 // slice-28 source-skip in buildOutputColumns removes column refs 15995 // inside FILTER (WHERE ...) subtrees from OutputColumn.sources, so 15996 // a correlated FILTER ref in the inner projection (e.g. 15997 // `EXISTS (SELECT SUM(x.s) FILTER (WHERE e.region='EU') FROM x)` 15998 // where `e` is the outer alias) would slip past the loop above. 15999 // Existing collectAllInnerRefs continues to cover correlated 16000 // FILTER refs landing in inner WHERE / HAVING / GROUP BY / ORDER BY 16001 // / JOIN-ON because those clauses still collect via plain 16002 // collectColumnRefs which descends into FILTER subtrees. 16003 TResultColumnList rclForFilterWalk = inner.getResultColumnList(); 16004 if (rclForFilterWalk != null) { 16005 for (int rci = 0; rci < rclForFilterWalk.size(); rci++) { 16006 TResultColumn rc = rclForFilterWalk.getResultColumn(rci); 16007 Set<TExpression> filterClauses = collectFilterClauses(rc); 16008 for (TExpression fclause : filterClauses) { 16009 // Slice 118 — use effectiveProvider so under 16010 // correlationScope != null, outer-aliased refs come 16011 // back as synthetic EXACT_MATCH ColumnRefs (rather 16012 // than throwing on tolerant fallback being absent). 16013 // The alias-membership rejection below then fires 16014 // for outer-aliased FILTER-inner refs, preserving 16015 // the slice-118 boundary (codex round-1 Q2 BLOCKING 16016 // fix: FILTER-inner correlation still rejects). 16017 for (ColumnRef ref : collectColumnRefs(fclause, effectiveProvider)) { 16018 boolean nonLocal = !innerLocalAliases.contains( 16019 ref.getRelationAlias().toLowerCase(Locale.ROOT)); 16020 boolean degradeThisRef = degradeCorrelated 16021 && isKnownOuterAlias(ref.getRelationAlias(), 16022 outerRelationAliases); 16023 if (nonLocal && !degradeThisRef) { 16024 // R8: the SELECT_WHERE semi-join degrade skips a 16025 // genuine outer-relation correlation here; every 16026 // other context (and unknown aliases) still reject. 16027 throw new SemanticIRBuildException( 16028 PredicateSubqueryDiagnostics.unsupportedWithSpan( 16029 ctx.existsCorrelatedUnknownOuterAlias, 16030 context, 16031 "correlated reference to outer alias '" 16032 + ref.getRelationAlias() 16033 + "' inside FILTER (WHERE ...) is not supported yet", 16034 ref.getSourceSpan() != null 16035 ? ref.getSourceSpan() 16036 : SourceSpan.of(fclause))); 16037 } 16038 } 16039 } 16040 } 16041 } 16042 // Slice 30: projection-only direct WITHIN GROUP ORDER BY correlation 16043 // walk. PostgreSQL attaches WITHIN GROUP to fn.withinGroup directly, 16044 // and TFunctionCall.acceptChildren does NOT descend into that field — 16045 // so collectColumnRefs (and therefore collectAllInnerRefs above) is 16046 // blind to outer references inside `fn.withinGroup.orderBy`. A 16047 // correlated reference like 16048 // `mode() WITHIN GROUP (ORDER BY e.region)` (where `e` is the 16049 // outer alias) would slip past the slice-23 correlation loop above. 16050 // Catch it explicitly with a per-result-column WG ORDER BY scan. 16051 // Mirrors the slice-28 FILTER walk pattern. Also closes the same 16052 // correlation gap retroactively for slice-29-admitted aggregates 16053 // (LISTAGG / STRING_AGG / GROUP_CONCAT / ARRAY_AGG WITHIN GROUP), 16054 // see Slice30Test.pgCorrelatedListaggWithinGroupOrderByNowAlsoRejected. 16055 // 16056 // IMPORTANT: this walk uses a qualifier-only collector 16057 // ({@link #collectQualifierAliases}) instead of 16058 // {@link #collectColumnRefs}. Resolver2 also doesn't attach 16059 // ResolutionResult to TObjectName nodes inside PG's direct 16060 // fn.withinGroup field (it shares the AST asymmetry that lets 16061 // slice 29 admit these without a source-skip). Going through 16062 // {@code collectColumnRefs} → {@code provider.bindColumn} would 16063 // throw {@code non-exact column bindings} on legitimate 16064 // non-correlated refs (status=NOT_FOUND because Resolver2 skipped 16065 // them). The qualifier-only collector reads the qualifier alias 16066 // straight off the TObjectName, which matches slice-23's 16067 // correlation invariant: only qualified refs that name an outer 16068 // alias are caught — unqualified refs remain a documented 16069 // schema-less limitation. 16070 TResultColumnList rclForWgWalk = inner.getResultColumnList(); 16071 if (rclForWgWalk != null) { 16072 for (int rci = 0; rci < rclForWgWalk.size(); rci++) { 16073 TResultColumn rc = rclForWgWalk.getResultColumn(rci); 16074 Set<TOrderBy> wgOrderBys = collectDirectWithinGroupOrderBys(rc); 16075 for (TOrderBy wgOrderBy : wgOrderBys) { 16076 for (String alias : collectQualifierAliases(wgOrderBy)) { 16077 boolean nonLocal = !innerLocalAliases.contains( 16078 alias.toLowerCase(Locale.ROOT)); 16079 boolean degradeThisRef = degradeCorrelated 16080 && isKnownOuterAlias(alias, outerRelationAliases); 16081 if (nonLocal && !degradeThisRef) { 16082 // R8: the SELECT_WHERE semi-join degrade skips a 16083 // genuine outer-relation correlation here. 16084 throw new SemanticIRBuildException( 16085 PredicateSubqueryDiagnostics.unsupported( 16086 ctx.existsCorrelatedUnknownOuterAlias, 16087 context, 16088 "correlated reference to outer alias '" 16089 + alias 16090 + "' inside WITHIN GROUP (ORDER BY ...) is not supported yet", 16091 wgOrderBy)); 16092 } 16093 } 16094 } 16095 } 16096 } 16097 int idx = stmts.size(); 16098 stmts.add(innerStmt); 16099 // Slice 24: emit lineage edges for the predicate body. For 16100 // constant-only inner (slice-23 carryover), the synthetic 16101 // OutputColumn has empty sources and emitLineageForStatement 16102 // emits zero edges — no shape change for slice 23. For 16103 // column-ref inner (slice 24), the real OutputColumn carries 16104 // one ColumnRef source pointing at the inner's local relation; 16105 // emitLineageForStatement emits a STATEMENT_OUTPUT → TABLE_COLUMN 16106 // edge (TABLE-bound inner) or STATEMENT_OUTPUT → STATEMENT_OUTPUT 16107 // edge (CTE-bound inner) that the projector's slice-24 pass uses 16108 // to resolve the JOIN canonical edge. 16109 // 16110 // SUBQUERY map is empty: inner-shape preflight rejects FROM-subqueries. 16111 // ScalarInfo map is empty: inner-shape preflight rejects scalar 16112 // projections (and column-ref projection is single-source, not a 16113 // scalar-subquery extraction). 16114 // Slice 118 — pass the enclosing scope's flattened SUBQUERY-alias 16115 // map under correlation mode so OUTER_REFERENCE-of-SUBQUERY refs 16116 // resolve to the enclosing MERGE's USING-subquery statement index 16117 // for cross-stmt lineage emission (mirrors slice 117 UPDATE-side 16118 // emit dispatch). 16119 Map<String, Integer> subqueryAliasMap = (correlationScope != null) 16120 ? correlationScope.flattenSubqueryAliasToIndex() 16121 : Collections.<String, Integer>emptyMap(); 16122 emitLineageForStatement(innerStmt, idx, lineage, 16123 cteMapForExtraction, 16124 subqueryAliasMap, 16125 Collections.<Integer, ScalarInfo>emptyMap()); 16126 // The predicate body remains UNREACHABLE from outer: no relation 16127 // in outer points at it, and no STATEMENT_OUTPUT lineage edge has 16128 // it as its `to`. Inner WHERE / inner JOIN refs of the predicate 16129 // body therefore cannot enter outer's row-influence walker. The 16130 // slice-24 projector pass iterates predicate bodies directly via 16131 // `isPredicateSubquerySyntheticName` to emit JOIN canonical edges 16132 // from their OutputColumn sources only (slice-24 §4.2.1). 16133 // 16134 // R8 — record the semi-join fact (EXISTS/IN → SEMI, NOT EXISTS/NOT 16135 // IN → ANTI_SEMI) so JoinGraph assembly can emit a JoinEntity 16136 // linking the outer relation to this lifted subquery block. 16137 if (semiFactsOut != null) { 16138 SemiJoinFact fact = buildSemiJoinFact(wrapper, extractableNode, inner, 16139 innerStmt, idx, innerStmt.getName(), innerLocalAliases, 16140 effectiveProvider); 16141 if (fact != null) { 16142 semiFactsOut.add(fact); 16143 } 16144 } 16145 return idx; 16146 } 16147 16148 /** 16149 * Shared inner-shape preflight for every recognised predicate-subquery 16150 * wrapper. The context preserves the original wrapper and host clause 16151 * while this method validates the common lowered body shape. 16152 */ 16153 private static void preflightPredicateSubqueryShape( 16154 TSelectSqlStatement inner, 16155 PredicateSubqueryContext context) { 16156 // (a) No set-op 16157 if (inner.getSetOperatorType() != null 16158 && inner.getSetOperatorType() != ESetOperatorType.none) { 16159 throw new SemanticIRBuildException( 16160 PredicateSubqueryDiagnostics.unsupported( 16161 DiagnosticCode.JOIN_ON_EXISTS_INNER_IS_SET_OP, 16162 context, 16163 "inner SELECT may not be a set operation", 16164 inner)); 16165 } 16166 // (b) No nested CTE list 16167 if (inner.getCteList() != null && inner.getCteList().size() > 0) { 16168 throw new SemanticIRBuildException( 16169 PredicateSubqueryDiagnostics.unsupported( 16170 DiagnosticCode.JOIN_ON_EXISTS_INNER_WITH, 16171 context, 16172 "inner SELECT may not have its own WITH clause", 16173 inner.getCteList())); 16174 } 16175 // (c) No row-limit (delegated to rejectUnsupportedShape's row-limit 16176 // guards which fire during buildSelectStatement). For an early, 16177 // specific message we also fast-fail here. 16178 if (inner.getLimitClause() != null 16179 || inner.getTopClause() != null 16180 || inner.getFetchFirstClause() != null 16181 || inner.getOffsetClause() != null) { 16182 throw new SemanticIRBuildException( 16183 PredicateSubqueryDiagnostics.unsupported( 16184 DiagnosticCode.JOIN_ON_EXISTS_INNER_ROW_LIMIT, 16185 context, 16186 "inner SELECT may not have a row-limit clause", 16187 predicateRowLimitAnchor(inner))); 16188 } 16189 // (d) Inner FROM is required (codex round-2 MUST 2). 16190 if (inner.joins == null || inner.joins.size() == 0) { 16191 throw new SemanticIRBuildException( 16192 PredicateSubqueryDiagnostics.unsupported( 16193 DiagnosticCode.JOIN_ON_EXISTS_INNER_MISSING_FROM, 16194 context, 16195 "inner SELECT must have a FROM clause " 16196 + "(degenerate predicate subquery SELECT is not in scope)", 16197 inner)); 16198 } 16199 // Slice 182 (GitHub #707 / MantisBT 4676): a comma-separated FROM 16200 // list in the predicate body is now built as an ordered 16201 // cross-product relation graph, exactly as the semantically 16202 // identical `FROM a JOIN b ON …` already was. The slice-62 16203 // predicate-body reject that used to live here (and its 16204 // {@link DiagnosticCode#JOIN_ON_EXISTS_INNER_COMMA_FROM} code) is 16205 // gone; the body's shape contract is enforced by the 16206 // single-result-column check in (e) below, which is independent 16207 // of the FROM relation count. 16208 // Slice 63: predicate body must not contain explicit CROSS JOIN 16209 // either. Surfaces a predicate-body-tuned diagnostic before the 16210 // gated reject inside buildRelations would fire with the 16211 // generic "scalar / set-op-branch / set-op-CTE / predicate" 16212 // message. The same shared preflight is used by EXISTS / 16213 // IN-SELECT / cmp-subquery / ANY-ALL-SOME wrappers. 16214 // Slice 64: same treatment for JOIN ... USING. 16215 for (TJoin j : inner.joins) { 16216 TJoinItemList items = j.getJoinItems(); 16217 if (items == null) continue; 16218 for (int i = 0; i < items.size(); i++) { 16219 TJoinItem item = items.getJoinItem(i); 16220 if (item == null) continue; 16221 if (item.getJoinType() == EJoinType.cross) { 16222 throw new SemanticIRBuildException( 16223 PredicateSubqueryDiagnostics.unsupported( 16224 DiagnosticCode.JOIN_ON_EXISTS_INNER_CROSS_JOIN, 16225 context, 16226 "CROSS JOIN in inner SELECT is not supported yet", 16227 item)); 16228 } 16229 if (item.getUsingColumns() != null 16230 && item.getUsingColumns().size() > 0) { 16231 throw new SemanticIRBuildException( 16232 PredicateSubqueryDiagnostics.unsupported( 16233 DiagnosticCode.JOIN_ON_EXISTS_INNER_USING, 16234 context, 16235 "JOIN ... USING (...) in inner SELECT is not supported yet", 16236 item)); 16237 } 16238 // Slice 66: NATURAL JOIN inside a predicate-subquery body 16239 // is rejected with a wrapper-aware diagnostic. The 16240 // gated reject inside buildRelations would fire later 16241 // with the generic body-context message; surfacing here 16242 // gives users an EXISTS / IN-SELECT / cmp-subquery 16243 // friendly error. 16244 if (isNaturalJoinType(item.getJoinType())) { 16245 throw new SemanticIRBuildException( 16246 PredicateSubqueryDiagnostics.unsupported( 16247 DiagnosticCode.JOIN_ON_EXISTS_INNER_NATURAL, 16248 context, 16249 "NATURAL JOIN in inner SELECT is not supported yet", 16250 item)); 16251 } 16252 } 16253 } 16254 // (d') No FROM-subquery on inner FROM/JOIN list. The recursive build 16255 // passes allowFromSubqueries=false, so buildRelation would also 16256 // reject; we surface a slice-23 specific message here. 16257 for (TJoin j : inner.joins) { 16258 if (j.getTable() != null 16259 && j.getTable().getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 16260 throw new SemanticIRBuildException( 16261 PredicateSubqueryDiagnostics.unsupported( 16262 DiagnosticCode.JOIN_ON_EXISTS_INNER_FROM_SUBQUERY, 16263 context, 16264 "FROM-clause subquery in inner SELECT is not supported yet", 16265 j.getTable())); 16266 } 16267 TJoinItemList items = j.getJoinItems(); 16268 if (items == null) continue; 16269 for (int i = 0; i < items.size(); i++) { 16270 TTable r = items.getJoinItem(i).getTable(); 16271 if (r != null && r.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 16272 throw new SemanticIRBuildException( 16273 PredicateSubqueryDiagnostics.unsupported( 16274 DiagnosticCode.JOIN_ON_EXISTS_INNER_FROM_SUBQUERY_ON_JOIN, 16275 context, 16276 "FROM-clause subquery on JOIN side in inner SELECT is not supported yet", 16277 r)); 16278 } 16279 } 16280 } 16281 // (e) Result-column list: exactly one column projecting either a 16282 // constant expression (slice 23), a single column reference 16283 // (slice 24), an expression / function call / CASE / 16284 // aggregate over inner columns (slice 27), an aggregate with 16285 // FILTER (WHERE ...) over inner columns (slice 28), or — on 16286 // PostgreSQL only — a whitelisted WITHIN GROUP aggregate 16287 // (slice 29 admits LISTAGG / STRING_AGG / GROUP_CONCAT / 16288 // ARRAY_AGG / count / sum / avg / min / max / stddev / 16289 // variance family; slice 30 extends with `mode`). 16290 // Multi-column / star / window function / scalar subquery / 16291 // non-whitelisted WITHIN GROUP aggregate projections are 16292 // rejected with shape-specific tuned messages — see 16293 // {@link #findUnsupportedWithinGroupFunctionName} for the 16294 // vendor + name gate. 16295 TResultColumnList rcl = inner.getResultColumnList(); 16296 if (rcl == null || rcl.size() != 1) { 16297 throw new SemanticIRBuildException( 16298 PredicateSubqueryDiagnostics.unsupported( 16299 DiagnosticCode.JOIN_ON_EXISTS_INNER_COLUMN_COUNT, 16300 context, 16301 "inner SELECT must project exactly one column, got " 16302 + (rcl == null ? 0 : rcl.size()), 16303 rcl == null ? inner : rcl)); 16304 } 16305 TResultColumn rc0 = rcl.getResultColumn(0); 16306 if ("*".equals(rc0.getColumnNameOnly())) { 16307 throw new SemanticIRBuildException( 16308 PredicateSubqueryDiagnostics.unsupported( 16309 DiagnosticCode.JOIN_ON_EXISTS_INNER_NON_CONSTANT_PROJECTION, 16310 context, 16311 "inner SELECT must project a constant expression " 16312 + "or a single column reference, got SELECT *", 16313 rc0)); 16314 } 16315 TExpression projExpr = rc0.getExpr(); 16316 if (projExpr == null || !isAdmittedPredicateProjection(projExpr)) { 16317 throw new SemanticIRBuildException( 16318 PredicateSubqueryDiagnostics.unsupported( 16319 DiagnosticCode.JOIN_ON_EXISTS_INNER_NON_CONSTANT_PROJECTION, 16320 context, 16321 "inner SELECT must project a constant expression " 16322 + "(e.g. SELECT 1), a single column reference " 16323 + "(e.g. SELECT x.id), an expression / function call / " 16324 + "CASE / aggregate over inner columns (e.g. SELECT x.id + 1, " 16325 + "UPPER(x.region), MAX(x.id), CASE WHEN ...), an aggregate " 16326 + "with FILTER (WHERE ...) over inner columns " 16327 + "(e.g. SUM(x.id) FILTER (WHERE x.region = 'EU')), or a " 16328 + "WITHIN GROUP (ORDER BY ...) aggregate over inner columns " 16329 + "(PostgreSQL admits the direct fn.withinGroup attachment; " 16330 + "Oracle and SQL Server admit the windowDef.withinGroup " 16331 + "attachment via slice 31 when no OVER clause is present " 16332 + "— see TWindowDef.isIncludingOverClause(); slice 44 also " 16333 + "admits Snowflake hypothetical-set ordered-set aggregates " 16334 + "(rank / dense_rank / percent_rank / cume_dist) via direct " 16335 + "fn.withinGroup attachment); DB2 / Snowflake LISTAGG / " 16336 + "STRING_AGG WITHIN GROUP remain rejected pending a probe " 16337 + "of their parser-specific argument storage; window " 16338 + "functions (any OVER-bearing form) and scalar subqueries " 16339 + "are not supported yet (slice 31 boundary)", 16340 projExpr == null ? rc0 : projExpr)); 16341 } 16342 // Slice 29 / Slice 31: vendor-gated WITHIN GROUP rejecter. 16343 // 16344 // Two attachment styles, gated by vendor: 16345 // * Direct attachment ({@code fn.getWithinGroup()}): PG admits 16346 // because its visitor descent (TFunctionCall.acceptChildren) 16347 // does NOT walk fn.withinGroup, leaving OutputColumn.sources 16348 // populated exactly with the function's column-bearing args. 16349 // Snowflake and DB2 use the same field but their parser- 16350 // specific arg storage (DB2's stringExpr / separatorExpr for 16351 // LISTAGG) may not be visitor-visible — silently-empty 16352 // sources while dlineage walks fdd to the base column = 16353 // manufactured IR_MISSING_DEPENDENCY divergence; rejected. 16354 // * WindowDef attachment ({@code fn.getWindowDef().getWithinGroup()} 16355 // with WITHIN-GROUP-only windowDef): Oracle / MSSQL admit via 16356 // slice 31. The visitor DOES descend through 16357 // {@code windowDef.withinGroup.orderBy}, so the slice-31 16358 // source-skip in 16359 // {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} 16360 // keeps OutputColumn.sources from leaking the WITHIN GROUP 16361 // ORDER BY column refs (probe Q1 / Q3 / Q4 / Q5 in 16362 // {@code /tmp/probe31}). 16363 // 16364 // Probed: PG (Q1, Q5, Q6, Q9, Q10), Oracle (Q1-Q5 in 16365 // {@code /tmp/probe31}), MSSQL (Q11-Q12), SparkSQL (parser drops 16366 // WITHIN GROUP attachment, so containsAggregateWithWithinGroup 16367 // returns false and the lift applies). 16368 if (containsAggregateWithWithinGroup(projExpr)) { 16369 EDbVendor v = inner.dbvendor; 16370 // Slice 44 / 45: Snowflake admitted at this gate ONLY when 16371 // every WITHIN GROUP-bearing call in the inner projection 16372 // is an admitted Snowflake direct-attachment shape: 16373 // * hypothetical-set (rank / dense_rank / percent_rank / 16374 // cume_dist with fn.getWithinGroup()!=null and 16375 // fn.getWindowDef()==null) — slice 44; or 16376 // * mode() with the same direct-attachment shape — slice 45. 16377 // Snowflake LISTAGG / STRING_AGG / percentile_cont / 16378 // percentile_disc share the direct-attachment shape but 16379 // their parser-specific argument storage (stringExpr / 16380 // separatorExpr) and / or name-whitelist exclusion keep 16381 // the slice-31/44 rejection (see Slice44Test §C and 16382 // Slice45Test §C boundary tests). 16383 boolean snowflakeAdmittedShape = (v == EDbVendor.dbvsnowflake) 16384 && allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment(projExpr); 16385 if (v != EDbVendor.dbvpostgresql 16386 && v != EDbVendor.dbvoracle 16387 && v != EDbVendor.dbvmssql 16388 && !snowflakeAdmittedShape) { 16389 throw new SemanticIRBuildException( 16390 PredicateSubqueryDiagnostics.unsupported( 16391 DiagnosticCode.JOIN_ON_EXISTS_WITHIN_GROUP_AGGREGATE, 16392 context, 16393 "WITHIN GROUP aggregate inner projection on vendor=" + v 16394 + " is not supported yet — slice 31 admits PostgreSQL " 16395 + "(direct fn.withinGroup attachment), Oracle, and " 16396 + "SQL Server (windowDef.withinGroup attachment with " 16397 + "isIncludingOverClause=false); slice 44 additionally " 16398 + "admits Snowflake hypothetical-set ordered-set " 16399 + "aggregates (rank / dense_rank / percent_rank / " 16400 + "cume_dist) via direct fn.withinGroup attachment; " 16401 + "slice 45 additionally admits Snowflake mode() via " 16402 + "the same direct attachment; " 16403 + "DB2 / Snowflake LISTAGG / STRING_AGG / " 16404 + "percentile_cont / other direct-attachment vendors " 16405 + "remain rejected pending a probe of their parser-" 16406 + "specific argument storage", 16407 projExpr)); 16408 } 16409 // Codex impl-review round-3 MUST: name-whitelist guard. The PG 16410 // parser attaches WITHIN GROUP to generic `func_application`, 16411 // not only to whitelisted aggregate names. Without this check, 16412 // a non-whitelisted call like `foo(x.id) WITHIN GROUP (...)` 16413 // would slip through. Slice 31: same protection applies on 16414 // Oracle / MSSQL — the windowDef-attachment grammar admits 16415 // any function name (PERCENTILE_CONT, RANK, user-defined 16416 // foo); the name guard rejects them so the IR never sees a 16417 // shape whose canonical model is unverified. 16418 String unsupportedName = findUnsupportedWithinGroupFunctionName( 16419 projExpr, inner.dbvendor); 16420 if (unsupportedName != null) { 16421 throw new SemanticIRBuildException( 16422 PredicateSubqueryDiagnostics.unsupported( 16423 DiagnosticCode.JOIN_ON_EXISTS_WITHIN_GROUP_NON_WHITELISTED, 16424 context, 16425 "WITHIN GROUP attached to non-whitelisted " 16426 + "function '" + unsupportedName + "' is not supported yet — " 16427 + "slice 31 admits whitelisted aggregates only " 16428 + "(see SemanticIRBuilder.AGGREGATE_FUNCTION_NAMES); " 16429 + "slice 43 additionally admits PostgreSQL hypothetical-set " 16430 + "ordered-set aggregates (rank / dense_rank / percent_rank / " 16431 + "cume_dist) via direct fn.getWithinGroup attachment", 16432 projExpr)); 16433 } 16434 } 16435 // (f) No subqueries in inner WHERE / inner JOIN ON / inner GROUP BY / 16436 // inner HAVING / inner ORDER BY. Reuses the slice-11 helper 16437 // style with a slice-23-specific message prefix. 16438 rejectSubqueriesInPredicateBodyClauses(inner, context); 16439 // (g) Window functions are caught by the rejecters that fire inside 16440 // buildSelectStatement (rejectWindowFunctionInScope on WHERE / 16441 // GROUP BY / HAVING / ORDER BY); the inner projection itself is 16442 // a constant expression and cannot contain a window call. 16443 } 16444 16445 private static TParseTreeNode predicateRowLimitAnchor(TSelectSqlStatement inner) { 16446 if (inner.getLimitClause() != null) return inner.getLimitClause(); 16447 if (inner.getTopClause() != null) return inner.getTopClause(); 16448 if (inner.getFetchFirstClause() != null) return inner.getFetchFirstClause(); 16449 return inner.getOffsetClause(); 16450 } 16451 16452 /** 16453 * Slice 27: true iff {@code e} is an admitted predicate-subquery inner 16454 * projection shape. Admits (in priority order): 16455 * <ul> 16456 * <li>{@link EExpressionType#simple_object_name_t} — single column 16457 * ref (slice 24 carryover); one JOIN canonical edge per inner- 16458 * column lineage terminal.</li> 16459 * <li>{@link #isConstantExpression}-shaped constant (slice-23 16460 * carryover); zero canonical contribution.</li> 16461 * <li>Slice 27 widenings via {@link #isAdmittedSlice27ShapeRoot}: 16462 * expression / function call / CASE / aggregate over inner 16463 * columns. Probes 27 / 27b confirmed dlineage's 16464 * {@code fdr clause="on"} canonical model walks fdd to the 16465 * underlying base columns identically to the IR's 16466 * slice-24 predicate-body sweep — so canonical equivalence 16467 * holds. Aggregate-over-constants (e.g. {@code COUNT(*)}, 16468 * {@code SUM(1)}) produce empty {@code OutputColumn.sources} 16469 * and zero predicate-body JOIN edges; canonical-equivalent 16470 * to the slice-23 constant projection.</li> 16471 * </ul> 16472 * Hard rejecters fire BEFORE the {@link #isAdmittedSlice27ShapeRoot} 16473 * admit-list to keep the surface tight: 16474 * <ul> 16475 * <li>{@link #containsAnySubqueryExpression} — slice-23 invariant.</li> 16476 * <li>{@link #containsWindowFunction} — slice-13 invariant. 16477 * Slice 31 narrowed the rejecter via {@link #isWindowDefBearingFunction} 16478 * so a WITHIN-GROUP-only windowDef (Oracle / MSSQL plain 16479 * WITHIN GROUP attachment) is NOT classified as a window 16480 * function. Real OVER-bearing windowDef shapes ({@code OVER ()}, 16481 * {@code OVER (PARTITION BY ...)}, KEEP DENSE_RANK) continue to 16482 * fire the rejecter. The complementary slice-31 source-skip in 16483 * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} 16484 * removes the WITHIN GROUP ORDER BY column refs from 16485 * {@code OutputColumn.sources} on Oracle / MSSQL so the visitor's 16486 * descent through {@code windowDef.withinGroup.orderBy} doesn't 16487 * leak into projection sources.</li> 16488 * <li>Slice 29 / 31's vendor-gated WITHIN GROUP rejecter at the 16489 * {@link #preflightPredicateSubqueryShape} call site — see that 16490 * method's vendor gate. Slice 31 admits Oracle and MSSQL 16491 * (windowDef.withinGroup attachment with 16492 * {@code !isIncludingOverClause()}) alongside PostgreSQL. 16493 * Snowflake and DB2 both attach {@code WITHIN GROUP} to the 16494 * direct {@code fn.getWithinGroup()} field (same as PG), but 16495 * their parser-specific argument storage may not be 16496 * visitor-visible (DB2 stores LISTAGG args in 16497 * {@code stringExpr} / {@code separatorExpr}, which 16498 * {@code TFunctionCall.acceptChildren} does NOT walk); they 16499 * remain rejected. SparkSQL silently drops the WITHIN GROUP 16500 * attachment at parse time (both {@code fn.withinGroup} and 16501 * {@code fn.windowDef} are null); after slice 29 SparkSQL 16502 * admits the same shape as PG, parity-friendly per probe Q1 16503 * SparkSQL.</li> 16504 * </ul> 16505 * 16506 * <p>Slice 28 lifted the prior {@code containsAggregateWithFilter} 16507 * rejecter; FILTER aggregates are now admitted, with the FILTER 16508 * predicate column refs excluded from {@code OutputColumn.sources} 16509 * globally via the FILTER-aware variant of {@link #collectColumnRefs} 16510 * used in {@link #buildOutputColumns}. See the slice-28 entry in 16511 * §14.5 of the unified roadmap and §B / §C of the slice history 16512 * archive for the load-bearing decision. 16513 * 16514 * <p>Slice 29 lifted the prior unconditional 16515 * {@code containsAggregateWithWithinGroup} rejecter and replaced it 16516 * with a vendor-gated rejecter at the 16517 * {@link #preflightPredicateSubqueryShape} call site (Snowflake / DB2 / 16518 * other non-PostgreSQL vendors that use the direct 16519 * {@code fn.getWithinGroup()} attachment remain rejected). PG 16520 * attaches {@code WITHIN GROUP} to the direct 16521 * {@code fn.getWithinGroup()} field, and 16522 * {@code TFunctionCall.acceptChildren} does NOT descend into that 16523 * field, so {@link #collectColumnRefs} never picks up the ORDER BY 16524 * column refs — no source-skip is needed. dlineage probes Q1–Q10 16525 * confirmed canonical-model JOIN-on edges include only the aggregate's 16526 * primary argument across all four vendors (the WITHIN GROUP ORDER 16527 * BY ref appears as {@code fdr clauseType="orderby"} on PG only, and 16528 * {@code DlineageXmlProjector.projectColumn} follows fdd not fdr). 16529 * Slice 29 is restricted to whitelisted aggregates whose names 16530 * appear in {@link #AGGREGATE_FUNCTION_NAMES}. As of slice 30 the 16531 * whitelist is: {@code count}, {@code sum}, {@code avg}, {@code min}, 16532 * {@code max}, {@code stddev}, {@code variance}, {@code var_samp}, 16533 * {@code var_pop}, {@code stddev_samp}, {@code stddev_pop}, 16534 * {@code listagg}, {@code string_agg}, {@code group_concat}, 16535 * {@code array_agg}, {@code mode} (slice-30 addition — PG 16536 * ordered-set aggregate, gated for the WITHIN GROUP path only; 16537 * see {@code DlineageXmlProjector.ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES}). The predicate-body short-circuit's 16538 * {@code aggregate=true} branch fires for these regardless of 16539 * {@code OutputColumn.sources} content — column-bearing args 16540 * (e.g. {@code LISTAGG(x.id, ',')}) produce a synthesized 16541 * {@code OutputColumn} with {@code sources=[x.id]} that the slice-24 16542 * sweep walks to base-column terminals, while literal-only args 16543 * (e.g. {@code LISTAGG('hello', ',')}) produce {@code sources=[]} 16544 * with zero JOIN canonical edges — canonically equivalent to 16545 * slice-23's constant projection. 16546 * 16547 * <p>Functions NOT in the whitelist (which on PG includes 16548 * {@code percentile_cont}, {@code percentile_disc}, {@code rank}, 16549 * {@code dense_rank}, {@code percent_rank}, {@code cume_dist}, plus 16550 * any user-defined function with a direct {@code fn.withinGroup} 16551 * attachment) remain rejected by the 16552 * {@link #findUnsupportedWithinGroupFunctionName} guard at the 16553 * {@link #preflightPredicateSubqueryShape} call site. Slice 30 lifted 16554 * {@code mode} only — the one PG ordered-set aggregate with no 16555 * documented window form in any GSP-supported vendor. Lifting 16556 * {@code percentile_cont} / {@code percentile_disc} requires either 16557 * a vendor-scoped projector OR a structural discriminator strong 16558 * enough to distinguish the cross-vendor windowed forms (Redshift / 16559 * Vertica / BigQuery / Oracle / SQL Server emit 16560 * {@code PERCENTILE_CONT WITHIN GROUP OVER (...)} variants). Lifting 16561 * {@code rank}/{@code dense_rank}/{@code percent_rank}/{@code cume_dist} 16562 * requires distinguishing window form {@code RANK() OVER (ORDER BY)} 16563 * from hypothetical-set form {@code rank(0.5) WITHIN GROUP (ORDER BY)} 16564 * — dlineage XML for the two is structurally identical on PG. See 16565 * §14.6 of the unified roadmap. 16566 */ 16567 private static boolean isAdmittedPredicateProjection(TExpression e) { 16568 if (e == null) return false; 16569 if (e.getExpressionType() == EExpressionType.simple_object_name_t) { 16570 return true; // slice 24 (column ref) 16571 } 16572 if (isConstantExpression(e)) return true; // slice 23 (constant) 16573 // Slice 27: hard rejecters before admit-list. Slice 28 lifted the 16574 // FILTER rejecter; slice 29 replaced the unconditional WITHIN 16575 // GROUP rejecter with a vendor-gated rejecter at the 16576 // preflightPredicateSubqueryShape call site (see slice-29 §3.2). 16577 if (containsAnySubqueryExpression(e)) return false; // slice 23 invariant 16578 if (containsWindowFunction(e)) return false; // slice 13 invariant 16579 return isAdmittedSlice27ShapeRoot(e); 16580 } 16581 16582 /** 16583 * Slice 29: detect a {@code WITHIN GROUP (ORDER BY ...)} attachment 16584 * on the direct {@code fn.getWithinGroup()} field anywhere in the 16585 * subtree. This is the PG / Snowflake / DB2 attachment style. Used 16586 * as a vendor-gated rejecter in {@link #preflightPredicateSubqueryShape}: 16587 * non-admitted vendors with this attachment remain rejected because 16588 * their parser-specific argument storage may not be visitor-visible 16589 * (DB2's {@code LISTAGG} stores args in {@code stringExpr} / 16590 * {@code separatorExpr}, which the default 16591 * {@code TFunctionCall.acceptChildren} does NOT walk — 16592 * {@code OutputColumn.sources} would be silently empty while dlineage 16593 * walks fdd to the base column, manufacturing 16594 * {@code IR_MISSING_DEPENDENCY} divergence). 16595 * 16596 * <p>Slice 31: also detects WITHIN GROUP attached to 16597 * {@code fn.getWindowDef().getWithinGroup()} when the windowDef is 16598 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} — the 16599 * Oracle / MSSQL attachment style. Both attachments are routed 16600 * through {@link #hasWithinGroupAnyAttachment}. 16601 */ 16602 private static boolean containsAggregateWithWithinGroup(TExpression e) { 16603 if (e == null) return false; 16604 final boolean[] found = {false}; 16605 e.acceptChildren(new TParseTreeVisitor() { 16606 @Override 16607 public void preVisit(TFunctionCall fn) { 16608 if (found[0]) return; 16609 if (hasWithinGroupAnyAttachment(fn)) found[0] = true; 16610 } 16611 }); 16612 if (!found[0] && e.getExpressionType() == EExpressionType.function_t) { 16613 TFunctionCall fn = e.getFunctionCall(); 16614 if (hasWithinGroupAnyAttachment(fn)) found[0] = true; 16615 } 16616 return found[0]; 16617 } 16618 16619 /** 16620 * Slice 31: shared predicate used by {@link #containsAggregateWithWithinGroup} 16621 * and {@link #findUnsupportedWithinGroupFunctionName}. Returns 16622 * {@code true} iff {@code fn} carries {@code WITHIN GROUP} via 16623 * either: 16624 * <ul> 16625 * <li>direct {@code fn.getWithinGroup()} field (PG / Snowflake / 16626 * DB2 / SparkSQL parser style);</li> 16627 * <li>{@code fn.getWindowDef().getWithinGroup()} when the 16628 * windowDef is {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} 16629 * (Oracle / MSSQL parser style).</li> 16630 * </ul> 16631 */ 16632 private static boolean hasWithinGroupAnyAttachment(TFunctionCall fn) { 16633 if (fn == null) return false; 16634 if (fn.getWithinGroup() != null) return true; 16635 return isWithinGroupOnlyWindowDef(fn.getWindowDef()); 16636 } 16637 16638 /** 16639 * Slice 29 (codex impl-review round-3 MUST): walk the expression 16640 * subtree and return the (lower-cased) function name of any 16641 * {@code TFunctionCall} that carries WITHIN GROUP — via direct 16642 * {@code fn.getWithinGroup()} (PG style) or via 16643 * {@code fn.getWindowDef().getWithinGroup()} when the windowDef is 16644 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only} (Oracle / 16645 * MSSQL style; slice 31) — whose name is NOT in 16646 * {@link #AGGREGATE_FUNCTION_NAMES}. Returns {@code null} if every 16647 * WITHIN GROUP-bearing call uses a whitelisted aggregate name. 16648 * Used at the {@code preflightPredicateSubqueryShape} call site to reject 16649 * {@code foo(x.id) WITHIN GROUP (...)}-shaped projections where 16650 * {@code foo} isn't an aggregate the IR knows how to model. 16651 * 16652 * <p>Slice 43: now takes the inner {@link EDbVendor} so the 16653 * {@link #isAdmittedWithinGroupName} delegate can apply the 16654 * PG-only hypothetical-set carve-out 16655 * ({@link #isDirectAttachmentHypotheticalSetCall}; widened to 16656 * Snowflake by slice 44). 16657 */ 16658 private static String findUnsupportedWithinGroupFunctionName( 16659 TExpression e, final EDbVendor vendor) { 16660 if (e == null) return null; 16661 final String[] firstUnsupported = {null}; 16662 e.acceptChildren(new TParseTreeVisitor() { 16663 @Override 16664 public void preVisit(TFunctionCall fn) { 16665 if (firstUnsupported[0] != null) return; 16666 if (!hasWithinGroupAnyAttachment(fn)) return; 16667 String name = fn.getFunctionName() == null 16668 ? null : fn.getFunctionName().toString(); 16669 if (isAdmittedWithinGroupName(fn, name, vendor)) return; 16670 firstUnsupported[0] = name == null ? "<unnamed>" : name; 16671 } 16672 }); 16673 if (firstUnsupported[0] == null 16674 && e.getExpressionType() == EExpressionType.function_t) { 16675 TFunctionCall fn = e.getFunctionCall(); 16676 if (hasWithinGroupAnyAttachment(fn)) { 16677 String name = fn.getFunctionName() == null 16678 ? null : fn.getFunctionName().toString(); 16679 if (!isAdmittedWithinGroupName(fn, name, vendor)) { 16680 firstUnsupported[0] = name == null ? "<unnamed>" : name; 16681 } 16682 } 16683 } 16684 return firstUnsupported[0]; 16685 } 16686 16687 /** 16688 * Slice 42 helper used by {@link #findUnsupportedWithinGroupFunctionName}. 16689 * Returns {@code true} iff {@code name} is in the regular 16690 * {@link #AGGREGATE_FUNCTION_NAMES} whitelist, OR — under the 16691 * AST-shape constraint 16692 * {@link #isHypotheticalSetWithinGroupCall} — in the slice-42 16693 * {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES} whitelist (Oracle / 16694 * MSSQL windowDef-bearing attachment), OR — under the slice-43 16695 * AST-shape constraint 16696 * {@link #isDirectAttachmentHypotheticalSetCall} — in the same 16697 * hypothetical-set whitelist on PostgreSQL (slice 43) or Snowflake 16698 * (slice 44) via direct {@code fn.getWithinGroup()} attachment. 16699 * 16700 * <p>The shape constraints pin the carve-outs by parser flavor: 16701 * Oracle / MSSQL produce {@code fn.getWindowDef()!=null} with 16702 * {@code wd.getWithinGroup()!=null} and {@code !wd.isIncludingOverClause()}; 16703 * PG produces {@code fn.getWithinGroup()!=null} with 16704 * {@code fn.getWindowDef()==null}. Slice 43 admits that direct- 16705 * attachment hypothetical-set carve-out for PostgreSQL; slice 44 16706 * widens the same probe-confirmed shape to Snowflake. DB2 and other 16707 * direct-attachment vendors remain outside this helper until their 16708 * AST / dlineage parity is explicitly probed and covered. 16709 */ 16710 private static boolean isAdmittedWithinGroupName( 16711 TFunctionCall fn, String name, EDbVendor vendor) { 16712 if (name == null || name.isEmpty()) return false; 16713 String lower = name.toLowerCase(Locale.ROOT); 16714 if (AGGREGATE_FUNCTION_NAMES.contains(lower)) return true; 16715 if (isHypotheticalSetWithinGroupCall(fn)) return true; 16716 return isDirectAttachmentHypotheticalSetCall(fn, vendor); 16717 } 16718 16719 /** 16720 * Slice 43 / 44: true iff {@code fn} is a direct-attachment 16721 * hypothetical-set ordered-set aggregate call shape — {@code rank} / 16722 * {@code dense_rank} / {@code percent_rank} / {@code cume_dist} with 16723 * {@code fn.getWithinGroup()!=null} AND {@code fn.getWindowDef()==null}, 16724 * AND {@code vendor} is in {PostgreSQL, Snowflake}. 16725 * 16726 * <p>Used as a name-whitelist exception inside 16727 * {@link #isAdmittedWithinGroupName} for predicate-body inner 16728 * projections only. Top-level admission is deliberately not granted: 16729 * top-level lifting requires a vendor-scoped projector override 16730 * (slice 43 introduces the API but defers the override to a future 16731 * slice because PG / Snowflake dlineage XML is structurally 16732 * indistinguishable between the WG and OVER forms — naive override 16733 * breaks {@code rank() OVER (ORDER BY x)} classification). 16734 * 16735 * <p>Vendor gate: PG (slice 43) and Snowflake (slice 44 — probe- 16736 * confirmed AST + dlineage XML byte-identical to PG for all 16737 * four hypothetical-set names). DB2 / Greenplum / Redshift parse-fail 16738 * on the syntax. Other direct-attachment vendors (e.g. SparkSQL drops 16739 * WITHIN GROUP attachment at parse time) remain rejected pending a 16740 * fresh probe. 16741 * 16742 * <p>Probe: {@code /tmp/probe43/Probe43.java} (slice 43) and 16743 * {@code probe44.Probe44Test} (slice 44, captured during slice-44 16744 * implementation) confirmed the AST predicate matches PG / Snowflake 16745 * hypothetical-set forms (and not the OVER form), and confirmed the 16746 * dlineage XML for {@code EXISTS (SELECT rank(0.5) WITHIN GROUP 16747 * (ORDER BY x.salary) FROM locations x)} contributes zero base-table 16748 * edges from the inner predicate body (literal arg + WG ORDER BY ref 16749 * via {@code clauseType="orderby"} fdr that the projector's 16750 * {@code clauseTypeToRole} does not map to FILTER/JOIN). Both 16751 * projectors therefore agree on zero predicate-body lineage edges 16752 * for the slice-43 / slice-44 shape — no projector change required. 16753 */ 16754 private static boolean isDirectAttachmentHypotheticalSetCall( 16755 TFunctionCall fn, EDbVendor vendor) { 16756 if (fn == null) return false; 16757 if (vendor != EDbVendor.dbvpostgresql 16758 && vendor != EDbVendor.dbvsnowflake) return false; 16759 if (!isDirectAttachmentHypotheticalSetCallShape(fn)) return false; 16760 return true; 16761 } 16762 16763 /** 16764 * Slice 44: vendor-agnostic shape predicate for the direct-attachment 16765 * hypothetical-set call form ({@code fn.getWithinGroup()!=null} AND 16766 * {@code fn.getWindowDef()==null} AND function name in 16767 * {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES}). Used together with 16768 * {@link #isDirectAttachmentModeCallShape} (slice 45) by 16769 * {@link #allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment} to 16770 * gate Snowflake admission. Snowflake LISTAGG / STRING_AGG / 16771 * percentile_cont WITHIN GROUP share this attachment style but their 16772 * parser-specific argument storage ({@code stringExpr} / 16773 * {@code separatorExpr}) and dlineage XML parity remain unprobed 16774 * (slice-31 boundary preserved). 16775 */ 16776 private static boolean isDirectAttachmentHypotheticalSetCallShape( 16777 TFunctionCall fn) { 16778 if (fn == null) return false; 16779 if (fn.getWithinGroup() == null) return false; 16780 if (fn.getWindowDef() != null) return false; 16781 if (fn.getFunctionName() == null) return false; 16782 String name = fn.getFunctionName().toString(); 16783 if (name == null || name.isEmpty()) return false; 16784 return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains( 16785 name.toLowerCase(Locale.ROOT)); 16786 } 16787 16788 /** 16789 * Slice 45: vendor-agnostic shape predicate for the direct-attachment 16790 * {@code mode()} ordered-set aggregate call form 16791 * ({@code fn.getWithinGroup()!=null} AND 16792 * {@code fn.getWindowDef()==null} AND function name equals 16793 * {@code mode}). Parallel to 16794 * {@link #isDirectAttachmentHypotheticalSetCallShape}; used by 16795 * {@link #allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment} 16796 * to admit Snowflake {@code mode() WITHIN GROUP (ORDER BY ...)} 16797 * predicate-body inner projections. 16798 * 16799 * <p>Probe-confirmed (see {@code /tmp/Probe45c.java} captured during 16800 * slice-45 implementation): Snowflake parses {@code mode() WITHIN 16801 * GROUP (ORDER BY x.salary)} with {@code fn.getWithinGroup() != null} 16802 * and {@code fn.getWindowDef() == null}, identical to PG. The 16803 * Snowflake dlineage XML for the predicate-body wrapper shape is 16804 * byte-equivalent to PG (same {@code resultset name="mode" 16805 * type="function"} wrapper, same {@code orderby} fdr that 16806 * {@code clauseTypeToRole} does not map to FILTER/JOIN); the 16807 * canonical model has zero predicate-body lineage edges, matching 16808 * the IR side (mode has no args, default visitor descent does not 16809 * walk direct {@code fn.withinGroup}). 16810 * 16811 * <p>Why mode is admitted but Snowflake LISTAGG / STRING_AGG / 16812 * percentile_cont aren't (slice-44/45 boundaries): mode has no 16813 * positional argument, so the OutputColumn.sources collection is 16814 * trivially empty and matches the dlineage zero-edge canonical model. 16815 * LISTAGG / STRING_AGG store args in parser-specific 16816 * {@code stringExpr} / {@code separatorExpr} fields whose visitor 16817 * descent has not been probed; admitting them risks 16818 * silently-empty IR sources against a non-empty dlineage column-arg 16819 * fdd. percentile_cont / percentile_disc use a literal arg 16820 * (slice-44 §C boundary preserved) but are not in 16821 * {@link #AGGREGATE_FUNCTION_NAMES}, so the slice-29 name-whitelist 16822 * guard fires inside {@link #findUnsupportedWithinGroupFunctionName} 16823 * and rejects regardless of vendor gate. 16824 */ 16825 private static boolean isDirectAttachmentModeCallShape( 16826 TFunctionCall fn) { 16827 if (fn == null) return false; 16828 if (fn.getWithinGroup() == null) return false; 16829 if (fn.getWindowDef() != null) return false; 16830 if (fn.getFunctionName() == null) return false; 16831 String name = fn.getFunctionName().toString(); 16832 if (name == null || name.isEmpty()) return false; 16833 return "mode".equals(name.toLowerCase(Locale.ROOT)) 16834 && hasNoFunctionArgs(fn); 16835 } 16836 16837 private static boolean hasNoFunctionArgs(TFunctionCall fn) { 16838 return fn != null && (fn.getArgs() == null || fn.getArgs().size() == 0); 16839 } 16840 16841 /** 16842 * Slice 45 (renamed and widened from the slice-44 helper 16843 * {@code allWithinGroupCallsAreDirectAttachmentHypotheticalSet}): 16844 * returns {@code true} iff {@code e} contains at least one WITHIN 16845 * GROUP-bearing function call AND every such call uses an 16846 * <i>admitted</i> Snowflake direct-attachment shape — either 16847 * hypothetical-set ({@link #isDirectAttachmentHypotheticalSetCallShape}, 16848 * slice 44) or mode ({@link #isDirectAttachmentModeCallShape}, 16849 * slice 45). Used to gate the predicate-body vendor whitelist widen 16850 * at the {@code preflightPredicateSubqueryShape} call site so Snowflake is 16851 * admitted only on these probe-confirmed shapes — Snowflake LISTAGG / 16852 * STRING_AGG / percentile_cont / percentile_disc / other names 16853 * remain rejected (their parser-specific argument storage and 16854 * dlineage XML parity are unprobed; slice-31/44 boundary 16855 * preserved). 16856 * 16857 * <p>Mixed expressions (e.g. {@code mode() WG (...) || rank(0.5) 16858 * WG (...)} in a single predicate-body inner projection) are 16859 * admitted when every WG-bearing call is admitted-shape; 16860 * one non-admitted-shape call blocks the whole expression 16861 * (Slice45Test §D). 16862 */ 16863 private static boolean allWithinGroupCallsAreAdmittedSnowflakeDirectAttachment( 16864 TExpression e) { 16865 if (e == null) return false; 16866 final boolean[] sawAny = {false}; 16867 final boolean[] sawNonAdmitted = {false}; 16868 e.acceptChildren(new TParseTreeVisitor() { 16869 @Override 16870 public void preVisit(TFunctionCall fn) { 16871 if (!hasWithinGroupAnyAttachment(fn)) return; 16872 sawAny[0] = true; 16873 if (!isDirectAttachmentHypotheticalSetCallShape(fn) 16874 && !isDirectAttachmentModeCallShape(fn)) { 16875 sawNonAdmitted[0] = true; 16876 } 16877 } 16878 }); 16879 if (e.getExpressionType() == EExpressionType.function_t) { 16880 TFunctionCall fn = e.getFunctionCall(); 16881 if (fn != null && hasWithinGroupAnyAttachment(fn)) { 16882 sawAny[0] = true; 16883 if (!isDirectAttachmentHypotheticalSetCallShape(fn) 16884 && !isDirectAttachmentModeCallShape(fn)) { 16885 sawNonAdmitted[0] = true; 16886 } 16887 } 16888 } 16889 return sawAny[0] && !sawNonAdmitted[0]; 16890 } 16891 16892 /** 16893 * Slice 27: fail-closed enumeration of admitted projection root shapes 16894 * after the slice-23/24 fast paths and the hard-rejecter guards have 16895 * been considered by {@link #isAdmittedPredicateProjection}. 16896 * Open-ended type checks are intentionally avoided 16897 * (slice-history §C / codex round-1 SHOULD 5). 16898 * 16899 * <p>Admits: 16900 * <ul> 16901 * <li>{@code function_t} — any function call (aggregate or scalar). 16902 * OVER-bearing window functions are rejected by the caller's 16903 * {@code containsWindowFunction} guard (slice 31 narrowed via 16904 * {@link #isWindowDefBearingFunction} so WITHIN-GROUP-only 16905 * windowDef passes; OVER-bearing forms still rejected). 16906 * {@code FILTER (WHERE ...)} was admitted in slice 28 (with 16907 * FILTER predicate refs excluded from {@code OutputColumn.sources} 16908 * via {@link #collectColumnRefsExcludingFilterClauses} — 16909 * slice 31 widens to 16910 * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}). 16911 * PG-style direct {@code fn.withinGroup} attachment was admitted 16912 * in slice 29 via the vendor-gated rejecter at the 16913 * {@link #preflightPredicateSubqueryShape} call site; slice 31 extends 16914 * admission to Oracle / MSSQL windowDef-bearing WITHIN GROUP 16915 * (Snowflake / DB2 / other direct-attachment vendors remain 16916 * rejected pending probe).</li> 16917 * <li>{@code case_t} — simple or searched CASE.</li> 16918 * <li>Pure binary ({@link TExpression#isPureBinaryForDoParse}) — 16919 * arithmetic, concat, comparison.</li> 16920 * <li>{@code parenthesis_t} — descend.</li> 16921 * <li>{@code typecast_t} — PostgreSQL / Snowflake / Redshift 16922 * {@code expr::TYPE} (slice 37; cross-vendor probe slice 38). 16923 * Admit unconditionally; the slice-13 invariant rejecters 16924 * ({@link #containsAnySubqueryExpression} / 16925 * {@link #containsWindowFunction}) fire BEFORE this admit check 16926 * inside {@link #isAdmittedPredicateProjection}, so 16927 * {@code (SELECT 1)::INT} and {@code (ROW_NUMBER() OVER ())::INT} 16928 * are still rejected. The default visitor descent walks 16929 * {@code typecast_t.getLeftOperand()} so 16930 * {@code OutputColumn.sources} populates with the underlying 16931 * column refs (probe-verified for PG — 16932 * {@code /tmp/probe37/Probe37.java}; slice 38 extended the probe 16933 * to Snowflake and Redshift — 16934 * {@code /tmp/probe38/Probe38.java}, {@code CheckCurrent.java} — 16935 * and confirmed byte-identical AST + dlineage XML to PG for both 16936 * {@code x.id::VARCHAR [AS lst]} and {@code LOWER(x.id)::VARCHAR} 16937 * composed forms with zero divergence; slice 39 extended the probe 16938 * to Greenplum, Vertica, GaussDB, Netezza — 16939 * {@code /tmp/probe39/Probe39.java}, {@code Probe39b.java} — and 16940 * confirmed AST + dlineage XML byte-identical to the PG / Snowflake / 16941 * Redshift contract for both aliased and unaliased forms with zero 16942 * divergence; slice 40 extended the probe to BigQuery, Trino, Presto, 16943 * EDB, DuckDB, Databricks — 16944 * {@code /tmp/probe40/Probe40.java}, {@code Probe40b.java}, 16945 * {@code Probe40c.java}, {@code Probe40d.java}, {@code Probe40e.java} 16946 * — and confirmed AST + dlineage XML byte-identical to the 16947 * PG / Snowflake / Redshift contract for aliased, unaliased, and 16948 * {@code LOWER(x.id)::VARCHAR} composed forms with zero divergence; 16949 * slice 41 closes out the residual vendor matrix — 16950 * {@code /tmp/probe41/Probe41.java}, {@code Probe41b.java} — 16951 * confirming Informix native {@code typecast_t} (AST + dlineage XML 16952 * byte-identical to the PG / Snowflake / Redshift contract); 16953 * ClickHouse parser auto-lowers {@code expr::TYPE} to 16954 * {@code function_t} so the slice-27 admission applies; 16955 * Sybase / Flink / Dameng parse-fail on {@code ::TYPE} but accept 16956 * {@code CAST(x AS TYPE)} via {@code function_t} (slice-27 16957 * carryover); Exasol / AzureSQL parse {@code expr::TYPE} as 16958 * {@code simple_object_name_t} (vendor-quirk — the {@code ::} is 16959 * interpreted as a qualified-name separator, mirroring T-SQL's 16960 * {@code tablename::method()} schema-qualified syntax) so the 16961 * slice-32 exclusion routes via normal column handling; 16962 * OceanBase / Impala / StarRocks parse-fail boundary locked in). 16963 * Oracle uses {@code CAST(x AS TYPE)} which parses as 16964 * {@code function_t} (already admitted above), so no Oracle-specific 16965 * {@code typecast_t} admission is needed; Hive / SparkSQL parse-fail 16966 * on the {@code ::TYPE} syntax — slice 39 pins this boundary; slice 16967 * 40 extends the parse-fail boundary lock-in to DB2, Teradata, MySQL, 16968 * and HANA so a future grammar lift fires loudly and re-probe is 16969 * required before relying on zero-divergence for those dialects. 16970 * The slice-37 admission remains structural (no vendor gate); future 16971 * vendors that surface {@code typecast_t} will be admitted 16972 * automatically — re-probe before relying on zero-divergence 16973 * guarantees.</li> 16974 * </ul> 16975 * Implicitly rejects {@code list_t}, {@code subquery_t} (caught by the 16976 * caller), and any unknown expression type. 16977 */ 16978 private static boolean isAdmittedSlice27ShapeRoot(TExpression e) { 16979 if (e == null) return false; 16980 EExpressionType t = e.getExpressionType(); 16981 if (t == EExpressionType.function_t) return true; 16982 if (t == EExpressionType.case_t) return true; 16983 if (t == EExpressionType.parenthesis_t) { 16984 return e.getLeftOperand() != null 16985 && isAdmittedSlice27ShapeRoot(e.getLeftOperand()); 16986 } 16987 if (t == EExpressionType.typecast_t) return true; // slice 37 (cross-vendor parity probed in slice 38; widened in slice 39 + slice 40; residual vendors locked in by slice 41 — Informix typecast_t) 16988 if (TExpression.isPureBinaryForDoParse(t)) return true; 16989 return false; 16990 } 16991 16992 /** 16993 * Slice 27: visitor-based deep window-function detector. Mirrors 16994 * {@link #rejectWindowFunctions} (line ~4530) but boolean-returning so 16995 * it can be used as a guard inside 16996 * {@link #isAdmittedPredicateProjection}. 16997 * 16998 * <p>Slice 31: discriminates WITHIN-GROUP-only windowDef shapes 16999 * (Oracle / MSSQL plain {@code WITHIN GROUP (ORDER BY ...)} attachment 17000 * without OVER) from OVER-bearing ones via 17001 * {@link #isWindowDefBearingFunction}. Plain WITHIN GROUP no longer 17002 * counts as a window function for predicate-body inner-projection 17003 * admission. NOTE: only this helper and {@link #isAggregateFunction} 17004 * are lifted — every other slice-13 invariant rejecter 17005 * ({@link #rejectHavingWindowFunction}, 17006 * {@link #rejectOrderByWindowFunction}, 17007 * {@link #rejectWindowFunctionInScope}, 17008 * {@link #rejectWindowFunctions}, 17009 * {@link #rejectEmbeddedWindowFunction}, 17010 * {@link #isTopLevelWindowProjection}, and the OVER ORDER BY 17011 * window check inside {@code buildWindowOrderRefs}) keeps the 17012 * strict {@code wd != null} check unchanged so HAVING / ORDER BY / 17013 * WHERE / GROUP BY / JOIN ON / top-level projection contexts still 17014 * reject WITHIN-GROUP-only attachments — slice 31 boundary. 17015 */ 17016 private static boolean containsWindowFunction(TExpression e) { 17017 if (e == null) return false; 17018 final boolean[] found = {false}; 17019 e.acceptChildren(new TParseTreeVisitor() { 17020 @Override 17021 public void preVisit(TFunctionCall fn) { 17022 if (found[0]) return; 17023 if (isWindowDefBearingFunction(fn)) found[0] = true; 17024 } 17025 }); 17026 if (!found[0] && e.getExpressionType() == EExpressionType.function_t) { 17027 TFunctionCall fn = e.getFunctionCall(); 17028 if (isWindowDefBearingFunction(fn)) found[0] = true; 17029 } 17030 return found[0]; 17031 } 17032 17033 /** 17034 * Slice 31: discriminate WITHIN-GROUP-only {@link TWindowDef} shapes 17035 * from OVER-bearing ones. Returns {@code true} iff: 17036 * <ul> 17037 * <li>{@code wd.getWithinGroup() != null} — the discriminating 17038 * attachment;</li> 17039 * <li>{@code !wd.isIncludingOverClause()} — no OVER syntax of any 17040 * kind (including empty {@code OVER ()});</li> 17041 * <li>{@code wd.getKeepDenseRankClause() == null} — Oracle KEEP 17042 * DENSE_RANK FIRST/LAST is a slice-22 deferred shape and must 17043 * remain windowed.</li> 17044 * </ul> 17045 * 17046 * <p>Probe-validated: {@code TWindowDef.isIncludingOverClause()} is 17047 * {@code false} for plain {@code WITHIN GROUP} and {@code true} for 17048 * any OVER-bearing form (probe Q8 / Q9 / Q11 in 17049 * {@code /tmp/probe31}). 17050 * 17051 * <p>Used by {@link #isWindowDefBearingFunction} (the slice-13 17052 * invariant lift's discriminator). 17053 */ 17054 private static boolean isWithinGroupOnlyWindowDef(TWindowDef wd) { 17055 if (wd == null) return false; 17056 if (wd.isIncludingOverClause()) return false; 17057 if (wd.getWithinGroup() == null) return false; 17058 if (wd.getKeepDenseRankClause() != null) return false; 17059 return true; 17060 } 17061 17062 /** 17063 * Slice 31: a {@link TFunctionCall} is an OVER-bearing window-def 17064 * function iff its {@code windowDef} is non-null AND not 17065 * {@link #isWithinGroupOnlyWindowDef WITHIN-GROUP-only}. Replaces 17066 * the historical {@code fn.getWindowDef() != null} check inside 17067 * {@link #containsWindowFunction} and {@link #isAggregateFunction}; 17068 * every other rejecter retains the strict {@code wd != null} check 17069 * unchanged (slice 31 narrow lift). 17070 */ 17071 private static boolean isWindowDefBearingFunction(TFunctionCall fn) { 17072 if (fn == null) return false; 17073 TWindowDef wd = fn.getWindowDef(); 17074 if (wd == null) return false; 17075 return !isWithinGroupOnlyWindowDef(wd); 17076 } 17077 17078 /** 17079 * Slice 33: a {@link TFunctionCall} is an admitted top-level 17080 * WITHIN-GROUP-only aggregate iff: 17081 * 17082 * <ul> 17083 * <li>{@link #isWithinGroupOnlyWindowDef} returns true on its 17084 * windowDef (Oracle / MSSQL plain {@code WITHIN GROUP}, no 17085 * OVER, no KEEP DENSE_RANK);</li> 17086 * <li>{@code vendor} is Oracle or MSSQL — explicit gate mirroring 17087 * the slice-31 predicate-body gate at line ~3860. PG / 17088 * Snowflake / DB2 / SparkSQL produce direct 17089 * {@code fn.withinGroup} attachment with {@code windowDef=null} 17090 * and don't reach this helper today, but the explicit gate 17091 * keeps the contract narrow against future parser changes;</li> 17092 * <li>The function name is in {@link #AGGREGATE_FUNCTION_NAMES} 17093 * (LISTAGG / STRING_AGG / SUM / MIN / MAX / MODE / etc.).</li> 17094 * </ul> 17095 * 17096 * <p>Used only by {@link #buildOutputColumns} to fall through to the 17097 * normal aggregate path. The slice-13 invariant rejecters 17098 * ({@link #isTopLevelWindowProjection}, 17099 * {@link #rejectWindowFunctions}, 17100 * {@link #rejectEmbeddedWindowFunction}, 17101 * {@link #rejectHavingWindowFunction}, 17102 * {@link #rejectOrderByWindowFunction}, 17103 * {@link #rejectWindowFunctionInScope}) 17104 * keep the strict {@code wd != null} check unchanged; slice 33 17105 * admission is gated by a single boolean local to 17106 * {@code buildOutputColumns}. 17107 * 17108 * <p>Non-whitelisted names (PERCENTILE_CONT / PERCENTILE_DISC / 17109 * RANK / DENSE_RANK / PERCENT_RANK / CUME_DIST / user-defined) 17110 * keep routing to {@link #buildWindowOutputColumn} where the 17111 * {@link #WINDOW_FUNCTION_NAMES} guard rejects them as 17112 * "unsupported window function". 17113 */ 17114 private static boolean isAdmittedTopLevelWithinGroupAggregate( 17115 TFunctionCall fn, EDbVendor vendor) { 17116 if (fn == null) return false; 17117 if (!isWithinGroupOnlyWindowDef(fn.getWindowDef())) return false; 17118 if (vendor != EDbVendor.dbvoracle && vendor != EDbVendor.dbvmssql) { 17119 return false; 17120 } 17121 if (fn.getFunctionName() == null) return false; 17122 String name = fn.getFunctionName().toString(); 17123 if (name == null || name.isEmpty()) return false; 17124 String lower = name.toLowerCase(Locale.ROOT); 17125 if (AGGREGATE_FUNCTION_NAMES.contains(lower)) return true; 17126 // Slice 42: hypothetical-set ordered-set aggregates (RANK / 17127 // DENSE_RANK / PERCENT_RANK / CUME_DIST) admitted on 17128 // Oracle / MSSQL with WITHIN-GROUP-only windowDef shape. The 17129 // surrounding {@code isWithinGroupOnlyWindowDef} guard above 17130 // already enforces the shape; the name-set membership here keeps 17131 // PERCENTILE_CONT / PERCENTILE_DISC / user-defined names rejected. 17132 return HYPOTHETICAL_SET_AGGREGATE_NAMES.contains(lower); 17133 } 17134 17135 /** 17136 * Slice 35 / 36 / 46: top-level direct-attachment WITHIN GROUP 17137 * aggregate. 17138 * 17139 * <p>PG stores {@code LISTAGG(... ) WITHIN GROUP (...)} (slice 35) and 17140 * {@code STRING_AGG(... ) WITHIN GROUP (...)} (slice 36) in 17141 * {@code fn.getWithinGroup()} with {@code windowDef=null}. Both bypass 17142 * the slice-33 Oracle/MSSQL helper above, but the plain aggregate path 17143 * is otherwise already correct: the root is not a window function, 17144 * {@link #isAggregateFunction} sees the whitelisted name, and default 17145 * visitor descent does not walk direct {@code fn.withinGroup}, so 17146 * sources contain the function argument but not the WITHIN GROUP ORDER 17147 * BY ref. This helper exists only to unlock the slice-34 expression-text 17148 * fallback for the unaliased top-level form. 17149 * 17150 * <p>Slice 36 widens the PG name whitelist from {@code {listagg}} to 17151 * {@code {listagg, string_agg}}. Snowflake / DB2 / SparkSQL 17152 * {@code LISTAGG} / {@code STRING_AGG} WG remain rejected because 17153 * their argument-storage shape (e.g. DB2's {@code stringExpr}/ 17154 * {@code separatorExpr}) is not yet probed for visitor descent and 17155 * silent empty {@code OutputColumn.sources} would manufacture 17156 * {@code IR_MISSING_DEPENDENCY} divergence. 17157 * 17158 * <p>Slice 46 widens the vendor gate to additionally admit 17159 * Snowflake — but only for {@code mode}, the only Snowflake 17160 * direct-attachment WITHIN GROUP name whose dlineage XML has been 17161 * probe-confirmed byte-equivalent to PG (the projector's slice-30 17162 * vendor-agnostic {@code AGGREGATE_FUNCTION_NAMES} + 17163 * {@code ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} entries already 17164 * cover {@code mode}, so no projector change is needed). Snowflake 17165 * {@code listagg} / {@code string_agg} / hypothetical-set names 17166 * stay out of slice-46 scope. 17167 * 17168 * <p>Slice 47 widens the PG name whitelist from 17169 * {@code {listagg, string_agg}} to {@code {listagg, string_agg, mode}}, 17170 * the symmetrical lift to slice 46's Snowflake widen. The slice-46 17171 * pre-plan probe ({@code /tmp/Probe46Slice30.java}) already 17172 * confirmed PG aliased {@code mode()} WG was zero-divergence and the 17173 * unaliased form was blocked only by {@code effectiveOutputName}; PG 17174 * top-level {@code mode()} dlineage XML is byte-identical to 17175 * Snowflake's. No projector change is needed (slice 30 already 17176 * registered {@code mode}, vendor-agnostic). 17177 */ 17178 private static boolean isAdmittedTopLevelDirectWithinGroupAggregate( 17179 TFunctionCall fn, EDbVendor vendor) { 17180 if (fn == null) return false; 17181 if (fn.getWithinGroup() == null) return false; 17182 if (fn.getWindowDef() != null) return false; 17183 if (fn.getFunctionName() == null) return false; 17184 String name = fn.getFunctionName().toString(); 17185 if (name == null || name.isEmpty()) return false; 17186 String lower = name.toLowerCase(Locale.ROOT); 17187 if (vendor == EDbVendor.dbvpostgresql) { 17188 // Slice 35/36: PG LISTAGG / STRING_AGG WG. 17189 // Slice 47: PG mode() WG (parallels slice-46 Snowflake mode 17190 // lift; probed mode() carries no positional argument so 17191 // OutputColumn.sources is trivially empty matching 17192 // dlineage's zero-edge canonical model). 17193 return "listagg".equals(lower) 17194 || "string_agg".equals(lower) 17195 || ("mode".equals(lower) && hasNoFunctionArgs(fn)); 17196 } 17197 if (vendor == EDbVendor.dbvsnowflake) { 17198 // Slice 46: Snowflake mode() WG only. The admitted shape is 17199 // constrained to the probed no-arg mode() form: OutputColumn. 17200 // sources is trivially empty (matching dlineage's zero-edge 17201 // canonical model), and dlineage XML was probe-confirmed 17202 // byte-identical to PG's mode() WG XML for both aliased and 17203 // unaliased forms. 17204 return "mode".equals(lower) && hasNoFunctionArgs(fn); 17205 } 17206 return false; 17207 } 17208 /** 17209 * Slice 27: detect {@code FILTER (WHERE ...)} on any function call in 17210 * the expression subtree. Probe Q5 (PostgreSQL) confirmed dlineage's 17211 * {@code fdr clause="on"} omits FILTER-predicate column refs while 17212 * {@link #collectColumnRefs} would include them — canonical-model 17213 * divergence. Reject as slice-27 boundary. 17214 */ 17215 private static boolean containsAggregateWithFilter(TExpression e) { 17216 if (e == null) return false; 17217 final boolean[] found = {false}; 17218 e.acceptChildren(new TParseTreeVisitor() { 17219 @Override 17220 public void preVisit(TFunctionCall fn) { 17221 if (found[0]) return; 17222 if (fn.getFilterClause() != null) found[0] = true; 17223 } 17224 }); 17225 if (!found[0] && e.getExpressionType() == EExpressionType.function_t) { 17226 TFunctionCall fn = e.getFunctionCall(); 17227 if (fn != null && fn.getFilterClause() != null) found[0] = true; 17228 } 17229 return found[0]; 17230 } 17231 17232 /** 17233 * Slice 23: true iff every leaf of {@code e} is a {@code simple_constant_t}. 17234 * Admits {@code 1}, {@code 1+1}, {@code (1)}, {@code 'a' || 'b'} (vendor- 17235 * dependent). Slice 61 additionally admits unary {@code +}/{@code -} 17236 * wrappers over a constant operand so common signed literals like 17237 * {@code -1} and {@code -1.5} count as constants. Rejects column refs, 17238 * function calls (including {@code COALESCE}), CASE, scalar subqueries, 17239 * etc. The predicate body may STILL have inner WHERE / GROUP BY / 17240 * HAVING / ORDER BY referencing inner columns — only the projection 17241 * must be constant. 17242 */ 17243 private static boolean isConstantExpression(TExpression e) { 17244 if (e == null) return false; 17245 EExpressionType t = e.getExpressionType(); 17246 if (t == EExpressionType.simple_constant_t) return true; 17247 if (t == EExpressionType.parenthesis_t) { 17248 return e.getLeftOperand() != null && isConstantExpression(e.getLeftOperand()); 17249 } 17250 // Slice 61: unary +/- over a constant operand. The Oracle parser 17251 // emits `-1` as {@code unary_minus_t} with {@code left=null} and 17252 // {@code right=simple_constant_t(1)}. Pre-slice-61 this fell 17253 // through and was rejected; slice 61 lifts it so signed literals 17254 // like `SELECT -1 FROM t` and `SELECT -1 UNION ALL SELECT -2` 17255 // round-trip through the constant-projection path. 17256 if (t == EExpressionType.unary_minus_t || t == EExpressionType.unary_plus_t) { 17257 TExpression operand = e.getRightOperand() != null 17258 ? e.getRightOperand() 17259 : e.getLeftOperand(); 17260 return operand != null && isConstantExpression(operand); 17261 } 17262 // Pure binary ops (slice-22 isPureBinaryForDoParse helper) — both 17263 // operands must be constant. Concatenation, arithmetic, etc. 17264 if (TExpression.isPureBinaryForDoParse(t)) { 17265 TExpression l = e.getLeftOperand(); 17266 TExpression r = e.getRightOperand(); 17267 return l != null && isConstantExpression(l) 17268 && r != null && isConstantExpression(r); 17269 } 17270 return false; 17271 } 17272 17273 /** 17274 * Reject subqueries in a predicate-subquery body's inner WHERE / JOIN ON / 17275 * GROUP BY / HAVING / ORDER BY (would be predicate subqueries OR scalar 17276 * subqueries). Mirrors the slice-11 17277 * {@link #rejectSubqueriesInScalarBodyClauses} structure but renders 17278 * through the shared predicate-subquery diagnostic context. 17279 */ 17280 private static void rejectSubqueriesInPredicateBodyClauses( 17281 TSelectSqlStatement inner, 17282 PredicateSubqueryContext context) { 17283 TWhereClause where = inner.getWhereClause(); 17284 if (where != null && containsAnySubquery(where)) { 17285 throw new SemanticIRBuildException( 17286 PredicateSubqueryDiagnostics.unsupported( 17287 DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_WHERE, 17288 context, 17289 "inner SELECT has a subquery in its WHERE clause; " 17290 + "not supported yet", 17291 where)); 17292 } 17293 if (inner.joins != null) { 17294 for (TJoin join : inner.joins) { 17295 TJoinItemList items = join.getJoinItems(); 17296 if (items == null) continue; 17297 for (int i = 0; i < items.size(); i++) { 17298 TJoinItem item = items.getJoinItem(i); 17299 TExpression onCond = item == null ? null : item.getOnCondition(); 17300 if (onCond != null && containsAnySubqueryExpression(onCond)) { 17301 throw new SemanticIRBuildException( 17302 PredicateSubqueryDiagnostics.unsupported( 17303 DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_JOIN_ON, 17304 context, 17305 "inner SELECT has a subquery in a JOIN ON " 17306 + "clause; not supported yet", 17307 onCond)); 17308 } 17309 } 17310 } 17311 } 17312 TGroupBy groupBy = inner.getGroupByClause(); 17313 if (groupBy != null) { 17314 TGroupByItemList items = groupBy.getItems(); 17315 if (items != null && containsAnySubquery(items)) { 17316 throw new SemanticIRBuildException( 17317 PredicateSubqueryDiagnostics.unsupported( 17318 DiagnosticCode.JOIN_ON_EXISTS_INNER_SUBQUERY_IN_GROUP_BY, 17319 context, 17320 "inner SELECT has a subquery in a GROUP BY clause; " 17321 + "not supported yet", 17322 groupBy)); 17323 } 17324 } 17325 // HAVING / ORDER BY subqueries are caught by the slice-9 / 10 17326 // deep-scan rejecters that fire during the recursive build. 17327 } 17328 17329 /** 17330 * Slice 25 (impl-review S2-fix): walk {@code onCond} (root + every 17331 * descendant outside {@code extractedRoots}) and return the first 17332 * wrapper expression whose <b>left</b> operand is a 17333 * {@code subquery_t}. Returns null if none. 17334 */ 17335 private static TExpression findSubqueryOnLeftWrapper(TExpression onCond, 17336 final Set<TExpression> extractedRoots) { 17337 if (onCond == null) return null; 17338 if (isSubqueryOnLeftOfWrapper(onCond)) return onCond; 17339 final TExpression[] found = {null}; 17340 onCond.acceptChildren(new TParseTreeVisitor() { 17341 int skipDepth = 0; 17342 17343 @Override 17344 public void preVisit(TExpression e) { 17345 if (found[0] != null) return; 17346 if (extractedRoots.contains(e)) { 17347 skipDepth++; 17348 return; 17349 } 17350 if (skipDepth > 0) return; 17351 if (isSubqueryOnLeftOfWrapper(e)) { 17352 found[0] = e; 17353 } 17354 } 17355 17356 @Override 17357 public void postVisit(TExpression e) { 17358 if (extractedRoots.contains(e) && skipDepth > 0) { 17359 skipDepth--; 17360 } 17361 } 17362 }); 17363 return found[0]; 17364 } 17365 17366 /** 17367 * Slice 25 / Slice 26: true iff {@code e} is a comparison/IN/ 17368 * quantifier wrapper that the slice-25 / slice-26 surface still 17369 * rejects on subquery-positioning grounds. 17370 * 17371 * <p>Slice 26 narrowing: {@code simple_comparison_t} with subquery 17372 * on the LHS and a non-subquery RHS is now ADMITTED (returns 17373 * false here so the post-extraction rejecter doesn't fire). Other 17374 * shapes still fail: 17375 * <ul> 17376 * <li>{@code in_t} with LHS=subquery: dlineage's 17377 * {@code fdr clause="on"} sources omit the outer column for 17378 * IN-LHS, so admitting on the IR side would manufacture 17379 * canonical-model divergence (still rejected).</li> 17380 * <li>{@code group_comparison_t} with LHS=subquery: borderline 17381 * grammar; defensively rejected (slice-26 boundary).</li> 17382 * <li>{@code simple_comparison_t} with subqueries on BOTH 17383 * sides: would require dual extraction; deferred to a future 17384 * slice (slice-26 boundary). Caller emits a tuned message.</li> 17385 * </ul> 17386 */ 17387 private static boolean isSubqueryOnLeftOfWrapper(TExpression e) { 17388 if (e == null) return false; 17389 EExpressionType t = e.getExpressionType(); 17390 TExpression l = e.getLeftOperand(); 17391 TExpression r = e.getRightOperand(); 17392 boolean lhsIsSubq = l != null && l.getExpressionType() == EExpressionType.subquery_t; 17393 boolean rhsIsSubq = r != null && r.getExpressionType() == EExpressionType.subquery_t; 17394 if (t == EExpressionType.in_t) { 17395 return lhsIsSubq; 17396 } 17397 if (t == EExpressionType.group_comparison_t) { 17398 return lhsIsSubq; 17399 } 17400 if (t == EExpressionType.simple_comparison_t) { 17401 // Slice 26: lifted UNLESS both sides are subqueries. 17402 return lhsIsSubq && rhsIsSubq; 17403 } 17404 return false; 17405 } 17406 17407 /** 17408 * Slice 26: true iff {@code e} is a {@code simple_comparison_t} 17409 * whose BOTH operands are {@code subquery_t}. Used by the 17410 * post-extraction rejecter to emit a slice-26-specific tuned 17411 * message distinguishing this case from the slice-25 LHS-subquery 17412 * shapes. 17413 */ 17414 private static boolean isComparisonWithBothSubqueries(TExpression e) { 17415 if (e == null) return false; 17416 if (e.getExpressionType() != EExpressionType.simple_comparison_t) return false; 17417 TExpression l = e.getLeftOperand(); 17418 TExpression r = e.getRightOperand(); 17419 return l != null && l.getExpressionType() == EExpressionType.subquery_t 17420 && r != null && r.getExpressionType() == EExpressionType.subquery_t; 17421 } 17422 17423 /** 17424 * Slice 23: after extraction, any remaining subquery-bearing expression in 17425 * the JOIN-ON tree is an unsupported shape — EXISTS that failed 17426 * extraction (inner-shape rejection), correlated wrappers, subquery on 17427 * left side, etc. Catch them here with a tuned message before 17428 * {@link #collectColumnRefsSkipping} would otherwise descend into them 17429 * and bind their inner refs against the outer scope. 17430 * 17431 * <p>Slice 25 (impl-review S2-fix): subquery-on-LEFT cases get a 17432 * tuned outer-shape reason via {@link #findSubqueryOnLeftWrapper}; 17433 * the shared predicate-subquery context supplies the actual wrapper 17434 * and host clause in the final diagnostic. 17435 * 17436 * <p>Slice 26: a NEW first pass (before the slice-25 LHS-subquery 17437 * pass) detects {@code simple_comparison_t} wrappers with 17438 * subqueries on BOTH sides via 17439 * {@link #findComparisonWithBothSubqueries} and emits a slice-26 17440 * tuned message. Both-sides shape satisfies 17441 * {@link #isSubqueryOnLeftOfWrapper} (which slice 26 narrowed to 17442 * {@code lhsIsSubq && rhsIsSubq} for {@code simple_comparison_t}), 17443 * so ordering matters — without the both-sides first pass, the 17444 * slice-25 LHS-subquery wording would fire first. 17445 */ 17446 private static void rejectAnyRemainingSubqueriesInJoinOn(TExpression onCond, 17447 final Set<TExpression> extractedRoots) { 17448 rejectAnyRemainingSubqueriesFromClause(onCond, extractedRoots, 17449 PredicateClauseContext.JOIN_ON); 17450 } 17451 17452 /** 17453 * Slice 110 — clause-agnostic remaining-subquery rejecter. Mirrors 17454 * the slice-26 logic in {@link #rejectAnyRemainingSubqueriesInJoinOn} 17455 * but uses {@code ctx.*} codes / labels so the same body powers JOIN-ON 17456 * (slice 26) and UPDATE WHERE (slice 110). 17457 */ 17458 private static void rejectAnyRemainingSubqueriesFromClause(TExpression onCond, 17459 final Set<TExpression> extractedRoots, 17460 final PredicateClauseContext ctx) { 17461 if (onCond == null) return; 17462 // Slice 26: tuned message for a comparison with subqueries on 17463 // BOTH sides. Fires at root or any descent. Checked BEFORE the 17464 // slice-25 subquery-on-LEFT pass because both-subqueries 17465 // satisfies isSubqueryOnLeftOfWrapper too — without this 17466 // ordering the slice-25 wording would fire first. 17467 TExpression bothSubqueriesWrapper = findComparisonWithBothSubqueries(onCond, 17468 extractedRoots); 17469 if (bothSubqueriesWrapper != null) { 17470 throw new SemanticIRBuildException( 17471 Diagnostic.error(ctx.scalarComparisonBothSides, 17472 "predicate subquery in " + ctx.clauseLabel + ": scalar comparison with " 17473 + "subqueries on both sides is not supported yet " 17474 + "(slice 26 admits exactly one subquery side, with a " 17475 + "single column reference on the other side; rewrite " 17476 + "as a join across a derived table or a CTE)", null)); 17477 } 17478 // Slice 25 (impl-review S2-fix): tuned message for subquery 17479 // on the LEFT side of a wrapper. Fires at root or any descent. 17480 // Slice 26 narrowed isSubqueryOnLeftOfWrapper: 17481 // simple_comparison_t with LHS=subquery and non-subquery RHS is 17482 // now ADMITTED, so this rejecter only fires for in_t-LHS-subq / 17483 // group_comparison_t-LHS-subq (still rejected as asymmetric / 17484 // borderline shapes). 17485 TExpression leftSubqueryWrapper = findSubqueryOnLeftWrapper(onCond, extractedRoots); 17486 if (leftSubqueryWrapper != null) { 17487 throw new SemanticIRBuildException( 17488 Diagnostic.error(ctx.predicateSubqueryOnLeft, 17489 "predicate subquery in " + ctx.clauseLabel + ": " 17490 + leftSubqueryWrapper.getExpressionType() 17491 + " wrapper has a subquery on the LEFT side " 17492 + "(only RHS-subquery IN / ANY-ALL-SOME and " 17493 + "either-side scalar comparison are admitted; " 17494 + "rewrite to put the subquery on the right side, " 17495 + "or rewrite as a join across a derived table)", null)); 17496 } 17497 // Root check: if the entire condition IS an EXISTS root and it WASN'T 17498 // extracted (meaning extraction threw an exception, which should not 17499 // reach here, OR some other root subquery shape), reject. The root 17500 // walker would otherwise miss it. 17501 TExpression rootSubject = isExistsRoot(onCond) ? unwrapExistsRoot(onCond) : onCond; 17502 if (rootSubject != null 17503 && (rootSubject.getExpressionType() == EExpressionType.subquery_t 17504 || rootSubject.getSubQuery() != null 17505 || rootSubject.getExpressionType() == EExpressionType.exists_t) 17506 && !extractedRoots.contains(rootSubject)) { 17507 throw new SemanticIRBuildException( 17508 Diagnostic.error(ctx.genericSubqueryNotSupported, 17509 "subquery in " + ctx.clauseLabel + " predicate is not supported yet " 17510 + "(slice 26 accepts only uncorrelated EXISTS / " 17511 + "IN-SELECT / scalar-comparison / ANY-ALL-SOME with " 17512 + "single column-ref or constant-only inner projection " 17513 + "and a single column ref on the non-subquery side)", null)); 17514 } 17515 final boolean[] found = {false}; 17516 onCond.acceptChildren(new TParseTreeVisitor() { 17517 int skipDepth = 0; 17518 17519 @Override 17520 public void preVisit(TExpression e) { 17521 if (found[0]) return; 17522 if (extractedRoots.contains(e)) { 17523 skipDepth++; 17524 return; 17525 } 17526 if (skipDepth > 0) return; 17527 if (e.getExpressionType() == EExpressionType.subquery_t 17528 || e.getSubQuery() != null 17529 || e.getExpressionType() == EExpressionType.exists_t) { 17530 found[0] = true; 17531 } 17532 } 17533 17534 @Override 17535 public void postVisit(TExpression e) { 17536 if (extractedRoots.contains(e) && skipDepth > 0) { 17537 skipDepth--; 17538 } 17539 } 17540 }); 17541 if (found[0]) { 17542 throw new SemanticIRBuildException( 17543 Diagnostic.error(ctx.genericSubqueryNotSupported, 17544 "subquery in " + ctx.clauseLabel + " predicate is not supported yet " 17545 + "(slice 26 accepts only uncorrelated EXISTS / " 17546 + "IN-SELECT / scalar-comparison / ANY-ALL-SOME with " 17547 + "single column-ref or constant-only inner projection " 17548 + "and a single column ref on the non-subquery side)", null)); 17549 } 17550 } 17551 17552 /** 17553 * Slice 26: walk {@code onCond} (root + every descendant outside 17554 * {@code extractedRoots}) and return the first 17555 * {@code simple_comparison_t} expression whose BOTH operands are 17556 * {@code subquery_t}. Returns null if none. 17557 */ 17558 private static TExpression findComparisonWithBothSubqueries(TExpression onCond, 17559 final Set<TExpression> extractedRoots) { 17560 if (onCond == null) return null; 17561 if (isComparisonWithBothSubqueries(onCond)) return onCond; 17562 final TExpression[] found = {null}; 17563 onCond.acceptChildren(new TParseTreeVisitor() { 17564 int skipDepth = 0; 17565 17566 @Override 17567 public void preVisit(TExpression e) { 17568 if (found[0] != null) return; 17569 if (extractedRoots.contains(e)) { 17570 skipDepth++; 17571 return; 17572 } 17573 if (skipDepth > 0) return; 17574 if (isComparisonWithBothSubqueries(e)) { 17575 found[0] = e; 17576 } 17577 } 17578 17579 @Override 17580 public void postVisit(TExpression e) { 17581 if (extractedRoots.contains(e) && skipDepth > 0) { 17582 skipDepth--; 17583 } 17584 } 17585 }); 17586 return found[0]; 17587 } 17588 17589 /** 17590 * Slice 23: variant of {@link #rejectWindowFunctionInScope} that skips 17591 * subtrees in {@code skipRoots}. The outer-SELECT JOIN-ON path passes the 17592 * extracted EXISTS roots so a window function inside an extracted body 17593 * is NOT incorrectly rejected as a window in the outer JOIN-ON. (The 17594 * inner statement's own buildSelectStatement does its own 17595 * rejectWindowFunctionInScope sweeps on WHERE / GROUP BY / HAVING / 17596 * ORDER BY, so legitimate inner-window violations still surface.) 17597 */ 17598 private static void rejectWindowFunctionInScopeSkipping( 17599 gudusoft.gsqlparser.nodes.TParseTreeNode root, 17600 String clauseLabel, 17601 final Set<TExpression> skipRoots) { 17602 if (root == null) return; 17603 // Root fast path: if the root itself IS a skipped subtree, nothing to 17604 // check. (acceptChildren wouldn't see the root anyway.) 17605 if (root instanceof TExpression && skipRoots.contains(root)) { 17606 return; 17607 } 17608 final boolean[] found = {false}; 17609 root.acceptChildren(new TParseTreeVisitor() { 17610 int skipDepth = 0; 17611 17612 @Override 17613 public void preVisit(TExpression e) { 17614 if (skipRoots.contains(e)) { 17615 skipDepth++; 17616 } 17617 } 17618 17619 @Override 17620 public void postVisit(TExpression e) { 17621 if (skipRoots.contains(e) && skipDepth > 0) { 17622 skipDepth--; 17623 } 17624 } 17625 17626 @Override 17627 public void preVisit(TFunctionCall fn) { 17628 if (found[0] || skipDepth > 0) return; 17629 if (fn.getWindowDef() != null) found[0] = true; 17630 } 17631 }); 17632 if (found[0]) { 17633 throw new SemanticIRBuildException( 17634 Diagnostic.error(DiagnosticCode.CLAUSE_WINDOW_FUNCTION_LEAK, 17635 clauseLabel + " contains a window function (OVER (...)); " 17636 + "window functions are not allowed in " + clauseLabel 17637 + " per standard SQL", root)); 17638 } 17639 } 17640 17641 /** 17642 * Slice 23: variant of {@link #collectColumnRefs} that skips subtrees in 17643 * {@code skipRoots}. Used by the outer-SELECT JOIN-ON path so the 17644 * extracted EXISTS bodies' inner refs do not leak into outer 17645 * {@code joinColumnRefs}. 17646 */ 17647 private static List<ColumnRef> collectColumnRefsSkipping( 17648 gudusoft.gsqlparser.nodes.TParseTreeNode root, 17649 final NameBindingProvider provider, 17650 final Set<TExpression> skipRoots) { 17651 // Slice 31 refactor: delegate to the extended variant with no 17652 // TWithinGroup skips. Behavior preserved exactly for the 17653 // existing slice-28 caller (outer JOIN-ON path at line ~3021) 17654 // and for the new slice-31 caller via 17655 // {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses}. 17656 return collectColumnRefsSkippingExtended(root, provider, 17657 skipRoots, Collections.<TWithinGroup>emptySet()); 17658 } 17659 17660 /** 17661 * Slice 28: collect every non-null {@link TFunctionCall#getFilterClause()} 17662 * subtree reachable from {@code root}. The returned set is identity-keyed 17663 * (uses {@link IdentityHashMap}) — required because the parser may yield 17664 * two structurally-equal FILTER WHERE expressions with different 17665 * identities; a value-keyed set would coalesce them and the 17666 * downstream {@link #collectColumnRefsSkipping} call would skip only one. 17667 * 17668 * <p>Contract: {@code root} is one of {@link TResultColumn} or 17669 * {@link TExpression}. Both call sites 17670 * ({@link #collectColumnRefsExcludingFilterClauses} for projection 17671 * source collection; the slice-28 correlation walk inside 17672 * {@link #extractOnePredicateSubqueryBody}) pass values of those 17673 * two types. Other {@code TParseTreeNode} subclasses are accepted 17674 * defensively (the visitor scan still works) but the top-level 17675 * direct-check fast paths only cover {@code TResultColumn} and 17676 * {@code TExpression}; this is intentional — adding a 17677 * {@code TFunctionCall} fast path would be reachable only if a 17678 * future call site were added with a {@code TFunctionCall} root. 17679 * 17680 * <p>Visitor-driven; descends into all expression subtrees the 17681 * standard {@code TFunctionCall.acceptChildren} path visits — function 17682 * args, {@code OVER} (analyticFunction / windowDef), FILTER, CASE arms, 17683 * parenthesised sub-expressions, AND the 17684 * {@code windowDef.withinGroup.orderBy} path on Oracle / MSSQL / 17685 * SparkSQL parsers. Note: the PostgreSQL parser stores WITHIN GROUP 17686 * on the direct {@code fn.withinGroup} field, which 17687 * {@code TFunctionCall.acceptChildren} does NOT visit, so PG WITHIN 17688 * GROUP ORDER BY refs are invisible to this collector. Slice 29 17689 * relies on that asymmetry to admit PG WITHIN GROUP aggregates in 17690 * predicate-subquery inner projections without a source-skip. 17691 * Used by: 17692 * <ul> 17693 * <li>{@link #collectColumnRefsExcludingFilterClauses} (Pass 1) — the 17694 * global source-skip in {@link #buildOutputColumns}.</li> 17695 * <li>{@link #extractOnePredicateSubqueryBody}'s slice-28 correlation 17696 * walk — projection-only correlation check for FILTER predicate 17697 * refs.</li> 17698 * </ul> 17699 */ 17700 private static Set<TExpression> collectFilterClauses( 17701 gudusoft.gsqlparser.nodes.TParseTreeNode root) { 17702 final Set<TExpression> out = 17703 Collections.newSetFromMap(new IdentityHashMap<TExpression, Boolean>()); 17704 if (root == null) return out; 17705 // Visitor descends into all expression subtrees; preVisit on 17706 // TFunctionCall records the filter clause if present. The 17707 // visitor's preVisit(TFunctionCall) does NOT fire for a top-level 17708 // function_t expression's root TFunctionCall (matches the slice-13 / 17709 // slice-27 visitor descent behavior); the defensive direct checks 17710 // below cover the top-level case for the two supported root types. 17711 root.acceptChildren(new TParseTreeVisitor() { 17712 @Override 17713 public void preVisit(TFunctionCall fn) { 17714 TExpression f = fn.getFilterClause(); 17715 if (f != null) out.add(f); 17716 } 17717 }); 17718 if (root instanceof TExpression) { 17719 TExpression e = (TExpression) root; 17720 if (e.getExpressionType() == EExpressionType.function_t) { 17721 TFunctionCall fn = e.getFunctionCall(); 17722 if (fn != null && fn.getFilterClause() != null) { 17723 out.add(fn.getFilterClause()); 17724 } 17725 } 17726 } else if (root instanceof TResultColumn) { 17727 TResultColumn rc = (TResultColumn) root; 17728 TExpression e = rc.getExpr(); 17729 if (e != null && e.getExpressionType() == EExpressionType.function_t) { 17730 TFunctionCall fn = e.getFunctionCall(); 17731 if (fn != null && fn.getFilterClause() != null) { 17732 out.add(fn.getFilterClause()); 17733 } 17734 } 17735 } 17736 return out; 17737 } 17738 17739 /** 17740 * Slice 30: collect every qualifier alias (the {@code x} of an 17741 * {@code x.region} TObjectName column reference) reachable from 17742 * {@code root}, without going through the resolver. Used by the 17743 * slice-30 WITHIN GROUP ORDER BY correlation walk in 17744 * {@link #extractOnePredicateSubqueryBody}. 17745 * 17746 * <p>Why bypass the resolver: PostgreSQL's parser stores WITHIN GROUP 17747 * on the direct {@code fn.withinGroup} field. {@code TFunctionCall.acceptChildren} 17748 * does NOT descend into that field, AND Resolver2 follows the same 17749 * traversal, so its {@code ResolutionResult} is null on TObjectName 17750 * nodes inside {@code fn.withinGroup.orderBy}. Calling 17751 * {@link #collectColumnRefs} on the WG ORDER BY would route through 17752 * {@code provider.bindColumn} → {@code NOT_FOUND}, and the 17753 * {@code non-exact column bindings} check would throw on legitimate 17754 * non-correlated refs. The qualifier-only collector here reads the 17755 * alias straight off the TObjectName via {@link TObjectName#getTableString()}, 17756 * matching the slice-23 correlation invariant: qualified refs only. 17757 * Unqualified refs are out of scope (same schema-less limitation as 17758 * the rest of slice-23). 17759 * 17760 * <p>Returned in iteration order (so error messages identify the 17761 * first offender) using a list rather than a set. 17762 */ 17763 private static List<String> collectQualifierAliases( 17764 gudusoft.gsqlparser.nodes.TParseTreeNode root) { 17765 final List<String> out = new ArrayList<>(); 17766 if (root == null) return out; 17767 root.acceptChildren(new TParseTreeVisitor() { 17768 @Override 17769 public void preVisit(TObjectName node) { 17770 if (node.getDbObjectType() != EDbObjectType.column) return; 17771 String t = node.getTableString(); 17772 if (t != null && !t.isEmpty()) out.add(t); 17773 } 17774 }); 17775 return out; 17776 } 17777 17778 /** 17779 * Slice 30 / Slice 31: collect every WITHIN-GROUP {@code ORDER BY} 17780 * clause anywhere in the subtree, identity-keyed so two 17781 * structurally-equal order-by clauses don't collapse. 17782 * 17783 * <p>Two attachment styles are covered: 17784 * <ul> 17785 * <li><b>Slice 30 — direct attachment</b>: PostgreSQL / 17786 * Snowflake / DB2 / SparkSQL parsers store WITHIN GROUP on 17787 * {@code fn.getWithinGroup()}. The default 17788 * {@code TFunctionCall.acceptChildren} does NOT descend into 17789 * that field, so the slice-23 {@link #collectAllInnerRefs} 17790 * walk is blind to outer-alias references inside the ORDER BY. 17791 * The slice-30 correlation walk needs explicit access, hence 17792 * this helper.</li> 17793 * <li><b>Slice 31 — windowDef attachment</b>: Oracle / MSSQL 17794 * parsers store WITHIN GROUP on 17795 * {@code fn.getWindowDef().getWithinGroup()} when the 17796 * windowDef is {@link #isWithinGroupOnlyWindowDef 17797 * WITHIN-GROUP-only}. The default {@code acceptChildren} 17798 * DOES descend through {@code windowDef.acceptChildren} 17799 * which calls {@code withinGroup.acceptChildren} which calls 17800 * {@code orderBy.acceptChildren}, so column refs would 17801 * already appear in {@link #collectAllInnerRefs}-driven 17802 * walks. <b>However</b>, the slice-31 source-skip in 17803 * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} 17804 * removes those refs from {@link OutputColumn#getSources()}; 17805 * the slice-23 correlation walk only sees {@code OutputColumn.sources} 17806 * for the projection bucket, so a correlated 17807 * {@code LISTAGG(x.id) WITHIN GROUP (ORDER BY e.region)} on 17808 * Oracle would slip past the slice-23 loop after the source- 17809 * skip. This dual-attachment helper closes that asymmetry. 17810 * (Inner WHERE / JOIN / HAVING / ORDER BY clauses are 17811 * independently rejected by the slice-13 strict 17812 * {@code rejectWindowFunctionInScope} family — Oracle 17813 * LISTAGG WG inside a clause never reaches this helper.)</li> 17814 * </ul> 17815 * 17816 * <p>The visitor's {@code preVisit(TFunctionCall)} does NOT fire for 17817 * a top-level {@code function_t} expression's root TFunctionCall; 17818 * defensive direct checks below cover the top-level case for both 17819 * {@link TExpression} and {@link TResultColumn} roots, mirroring 17820 * {@link #collectFilterClauses}. 17821 * 17822 * <p>Used by {@link #extractOnePredicateSubqueryBody}'s 17823 * projection-only correlation walk (line ~3690) for both 17824 * direct-attachment (slice 30) and windowDef-attachment (slice 31) 17825 * outer-alias references inside WITHIN GROUP ORDER BY. 17826 */ 17827 private static Set<TOrderBy> collectDirectWithinGroupOrderBys( 17828 gudusoft.gsqlparser.nodes.TParseTreeNode root) { 17829 final Set<TOrderBy> out = 17830 Collections.newSetFromMap(new IdentityHashMap<TOrderBy, Boolean>()); 17831 if (root == null) return out; 17832 root.acceptChildren(new TParseTreeVisitor() { 17833 @Override 17834 public void preVisit(TFunctionCall fn) { 17835 TOrderBy direct = fn.getWithinGroup() == null 17836 ? null : fn.getWithinGroup().getOrderBy(); 17837 if (direct != null) out.add(direct); 17838 TWindowDef wd = fn.getWindowDef(); 17839 if (isWithinGroupOnlyWindowDef(wd)) { 17840 TOrderBy wdOb = wd.getWithinGroup().getOrderBy(); 17841 if (wdOb != null) out.add(wdOb); 17842 } 17843 } 17844 }); 17845 if (root instanceof TExpression) { 17846 TExpression e = (TExpression) root; 17847 if (e.getExpressionType() == EExpressionType.function_t) { 17848 addWithinGroupOrderByIfPresent(e.getFunctionCall(), out); 17849 } 17850 } else if (root instanceof TResultColumn) { 17851 TResultColumn rc = (TResultColumn) root; 17852 TExpression e = rc.getExpr(); 17853 if (e != null && e.getExpressionType() == EExpressionType.function_t) { 17854 addWithinGroupOrderByIfPresent(e.getFunctionCall(), out); 17855 } 17856 } 17857 return out; 17858 } 17859 17860 /** 17861 * Slice 30 / 31: helper for top-level direct check inside 17862 * {@link #collectDirectWithinGroupOrderBys}. Adds the WITHIN GROUP 17863 * ORDER BY clause to {@code out} for whichever attachment style 17864 * the function carries. 17865 */ 17866 private static void addWithinGroupOrderByIfPresent(TFunctionCall fn, 17867 Set<TOrderBy> out) { 17868 if (fn == null) return; 17869 if (fn.getWithinGroup() != null && fn.getWithinGroup().getOrderBy() != null) { 17870 out.add(fn.getWithinGroup().getOrderBy()); 17871 } 17872 TWindowDef wd = fn.getWindowDef(); 17873 if (isWithinGroupOnlyWindowDef(wd) && wd.getWithinGroup().getOrderBy() != null) { 17874 out.add(wd.getWithinGroup().getOrderBy()); 17875 } 17876 } 17877 17878 /** 17879 * Slice 28: variant of {@link #collectColumnRefs} that excludes column 17880 * refs inside {@code FILTER (WHERE ...)} clauses on any function call 17881 * in the subtree. Used by {@link #buildOutputColumns} for ALL output 17882 * source collection so the IR's per-projection {@code OutputColumn.sources} 17883 * matches dlineage's lineage-relationship view (which omits FILTER 17884 * predicate column refs entirely; see slice-28 probes Q1–Q4). 17885 * 17886 * <p>For projections that contain no FILTER aggregates (the common case), 17887 * Pass 1 yields zero skip-roots and Pass 2 reduces to the plain 17888 * {@link #collectColumnRefs}. The asymmetry between projection sources 17889 * (FILTER-skipped) and clause refs ({@code filterColumnRefs}, 17890 * {@code joinColumnRefs}, {@code groupByColumnRefs}, 17891 * {@code havingColumnRefs}, {@code orderByColumnRefs} — NOT 17892 * FILTER-skipped) is intentional: it keeps the existing 17893 * {@link #collectAllInnerRefs}-driven correlation check at line ~3603 17894 * sufficient for FILTER refs landing in non-projection clauses, while 17895 * the slice-28 correlation walk in {@link #extractOnePredicateSubqueryBody} 17896 * covers projection-FILTER refs. 17897 */ 17898 private static List<ColumnRef> collectColumnRefsExcludingFilterClauses( 17899 gudusoft.gsqlparser.nodes.TParseTreeNode root, 17900 NameBindingProvider provider) { 17901 Set<TExpression> filterClauses = collectFilterClauses(root); 17902 if (filterClauses.isEmpty()) { 17903 return collectColumnRefs(root, provider); 17904 } 17905 return collectColumnRefsSkipping(root, provider, filterClauses); 17906 } 17907 17908 /** 17909 * Slice 31: identity-keyed set of every {@link TWithinGroup} reachable 17910 * from {@code root} via {@code fn.getWindowDef().getWithinGroup()} — 17911 * the Oracle / MSSQL attachment style for plain {@code WITHIN GROUP 17912 * (ORDER BY ...)} aggregates. Used as additional skip-roots in 17913 * {@link #collectColumnRefsExcludingFilterAndWithinGroupClauses} so 17914 * the column refs inside the WITHIN GROUP ORDER BY do NOT enter 17915 * {@link OutputColumn#getSources()} on Oracle / MSSQL — matching 17916 * dlineage's omission of those refs from {@code fdr clause="on"} 17917 * sources (probe Q1 / Q3 / Q4 / Q5 in {@code /tmp/probe31}). 17918 * 17919 * <p>Discriminator: {@link #isWithinGroupOnlyWindowDef}. OVER-bearing 17920 * windowDefs (real window functions) are NOT collected here — the 17921 * slice-13 invariant rejecters keep them rejected before this 17922 * collector ever fires for projection sources, so they cannot 17923 * reach the source-skip in practice. Defensive: even if they did, 17924 * the discriminator excludes them so PARTITION BY / OVER ORDER BY 17925 * column refs (slice-13 / slice-19 alias-bound contracts) keep 17926 * their existing semantics. 17927 * 17928 * <p>The PostgreSQL direct {@code fn.getWithinGroup()} attachment 17929 * is NOT collected here because PG's 17930 * {@code TFunctionCall.acceptChildren} does not descend into the 17931 * direct field — slice 29 relied on that asymmetry to admit PG 17932 * WITHIN GROUP aggregates without any source-skip; slice 31 17933 * preserves that asymmetry on PG. 17934 */ 17935 private static Set<TWithinGroup> collectWithinGroupClausesFromWindowDef( 17936 gudusoft.gsqlparser.nodes.TParseTreeNode root) { 17937 final Set<TWithinGroup> out = 17938 Collections.newSetFromMap(new IdentityHashMap<TWithinGroup, Boolean>()); 17939 if (root == null) return out; 17940 root.acceptChildren(new TParseTreeVisitor() { 17941 @Override 17942 public void preVisit(TFunctionCall fn) { 17943 TWindowDef wd = fn.getWindowDef(); 17944 if (isWithinGroupOnlyWindowDef(wd)) { 17945 out.add(wd.getWithinGroup()); 17946 } 17947 } 17948 }); 17949 if (root instanceof TExpression) { 17950 TExpression e = (TExpression) root; 17951 if (e.getExpressionType() == EExpressionType.function_t) { 17952 addWithinGroupFromWindowDefIfPresent(e.getFunctionCall(), out); 17953 } 17954 } else if (root instanceof TResultColumn) { 17955 TResultColumn rc = (TResultColumn) root; 17956 TExpression e = rc.getExpr(); 17957 if (e != null && e.getExpressionType() == EExpressionType.function_t) { 17958 addWithinGroupFromWindowDefIfPresent(e.getFunctionCall(), out); 17959 } 17960 } 17961 return out; 17962 } 17963 17964 /** 17965 * Slice 31: helper for top-level direct check inside 17966 * {@link #collectWithinGroupClausesFromWindowDef}. Mirrors the 17967 * slice-30 {@link #addWithinGroupOrderByIfPresent} helper but adds 17968 * the {@link TWithinGroup} node itself to {@code out} (the entire 17969 * WITHIN GROUP subtree is the skip-root, not just its ORDER BY). 17970 */ 17971 private static void addWithinGroupFromWindowDefIfPresent(TFunctionCall fn, 17972 Set<TWithinGroup> out) { 17973 if (fn == null) return; 17974 TWindowDef wd = fn.getWindowDef(); 17975 if (isWithinGroupOnlyWindowDef(wd)) { 17976 out.add(wd.getWithinGroup()); 17977 } 17978 } 17979 17980 /** 17981 * Slice 31: extends slice-28's filter-skipping projection-source 17982 * collector with an additional skip for Oracle / MSSQL 17983 * {@code fn.windowDef.withinGroup} subtrees. Reduces to slice-28 17984 * behavior on PostgreSQL (where windowDef is null) and to plain 17985 * {@link #collectColumnRefs} when neither FILTER nor WITHIN GROUP 17986 * is present. 17987 * 17988 * <p>Used by {@link #buildOutputColumns} for ALL projection source 17989 * collection (predicate-body short-circuit at line ~4952 and 17990 * normal projection loop at line ~5069) so the IR's 17991 * {@link OutputColumn#getSources()} matches dlineage's 17992 * lineage-relationship view across PG / Oracle / MSSQL. The 17993 * non-projection clause-bucket collectors 17994 * ({@code filterColumnRefs}, {@code joinColumnRefs}, 17995 * {@code groupByColumnRefs}, {@code havingColumnRefs}, 17996 * {@code orderByColumnRefs}) intentionally keep using plain 17997 * {@link #collectColumnRefs} — the slice-13 strict 17998 * {@code rejectWindowFunctionInScope} family rejects any 17999 * {@code wd != null} function in those clauses BEFORE collection 18000 * descends into them, so WITHIN GROUP refs cannot leak into 18001 * clause buckets in practice. 18002 */ 18003 private static List<ColumnRef> collectColumnRefsExcludingFilterAndWithinGroupClauses( 18004 gudusoft.gsqlparser.nodes.TParseTreeNode root, 18005 NameBindingProvider provider) { 18006 Set<TExpression> filterClauses = collectFilterClauses(root); 18007 Set<TWithinGroup> withinGroupClauses = collectWithinGroupClausesFromWindowDef(root); 18008 if (filterClauses.isEmpty() && withinGroupClauses.isEmpty()) { 18009 return collectColumnRefs(root, provider); 18010 } 18011 return collectColumnRefsSkippingExtended(root, provider, 18012 filterClauses, withinGroupClauses); 18013 } 18014 18015 /** 18016 * Slice 31: variant of {@link #collectColumnRefsSkipping} that 18017 * additionally skips column refs inside {@link TWithinGroup} 18018 * subtrees in {@code wgSkipRoots} (Oracle / MSSQL 18019 * {@code fn.windowDef.withinGroup} attachment). The existing 18020 * {@code exprSkipRoots} carries the slice-28 FILTER subtrees. 18021 * Returns column refs in iteration order. 18022 * 18023 * <p>Refactor note: {@link #collectColumnRefsSkipping} now delegates 18024 * to this method with an empty {@code wgSkipRoots} set so its 18025 * behavior is preserved exactly for legacy callers (the outer 18026 * JOIN-ON path at line ~3021). 18027 */ 18028 private static List<ColumnRef> collectColumnRefsSkippingExtended( 18029 gudusoft.gsqlparser.nodes.TParseTreeNode root, 18030 final NameBindingProvider provider, 18031 final Set<TExpression> exprSkipRoots, 18032 final Set<TWithinGroup> wgSkipRoots) { 18033 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 18034 final List<String> rejects = new ArrayList<>(); 18035 // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at 18036 // the wrong side). Such a miss is a genuine error and must stay fatal; 18037 // only all-unqualified rejects are eligible for the join-graph degrade. 18038 final boolean[] sawQualifiedReject = {false}; 18039 // Root fast path: if root IS a skipped TExpression subtree, return empty. 18040 if (root instanceof TExpression && exprSkipRoots.contains(root)) { 18041 return new ArrayList<>(refs); 18042 } 18043 root.acceptChildren(new TParseTreeVisitor() { 18044 int skipDepth = 0; 18045 int nestedSelectDepth = 0; 18046 18047 @Override 18048 public void preVisit(TExpression e) { 18049 if (exprSkipRoots.contains(e)) skipDepth++; 18050 } 18051 18052 @Override 18053 public void postVisit(TExpression e) { 18054 if (exprSkipRoots.contains(e) && skipDepth > 0) skipDepth--; 18055 } 18056 18057 @Override 18058 public void preVisit(TWithinGroup wg) { 18059 if (wgSkipRoots.contains(wg)) skipDepth++; 18060 } 18061 18062 @Override 18063 public void postVisit(TWithinGroup wg) { 18064 if (wgSkipRoots.contains(wg) && skipDepth > 0) skipDepth--; 18065 } 18066 18067 @Override 18068 public void preVisit(TSelectSqlStatement nested) { 18069 nestedSelectDepth++; 18070 } 18071 18072 @Override 18073 public void postVisit(TSelectSqlStatement nested) { 18074 nestedSelectDepth--; 18075 } 18076 18077 @Override 18078 public void preVisit(TObjectName node) { 18079 if (skipDepth > 0) return; 18080 if (nestedSelectDepth > 0) return; 18081 appendMergedOrBoundColumnRef(node, provider, refs, rejects, 18082 sawQualifiedReject); 18083 } 18084 }); 18085 if (!rejects.isEmpty()) { 18086 rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]); 18087 } 18088 return new ArrayList<>(refs); 18089 } 18090 18091 /** 18092 * Reject join shapes that would silently drop predicate semantics: 18093 * semi/anti, vendor-specific kinds; predicate-bearing joins with 18094 * no ON and no USING clause; CROSS / NATURAL JOIN with ON or 18095 * USING. Slice 63 admits {@code CROSS JOIN} via 18096 * {@link #ALLOWED_ON_LESS_JOIN_TYPES}. Slice 64 admits 18097 * {@code JOIN ... USING (...)} on predicate join types; the 18098 * per-key {@code joinColumnRefs} emission is handled in 18099 * {@link #buildRelations}. Slice 66 admits {@code NATURAL JOIN} 18100 * via {@link #NATURAL_JOIN_TYPES} when catalog metadata is 18101 * available on both sides; the catalog-required reject fires 18102 * inside {@link #buildRelations}, not here. 18103 */ 18104 private static void rejectUnsupportedJoinShape(TJoinItem item) { 18105 EJoinType jt = item.getJoinType(); 18106 boolean isPredicate = jt != null && ALLOWED_PREDICATE_JOIN_TYPES.contains(jt); 18107 boolean isOnLess = jt != null && ALLOWED_ON_LESS_JOIN_TYPES.contains(jt); 18108 boolean isNatural = isNaturalJoinType(jt); 18109 boolean isLateral = isLateralJoinType(jt); 18110 if (!isPredicate && !isOnLess && !isNatural && !isLateral) { 18111 throw new SemanticIRBuildException( 18112 Diagnostic.error(DiagnosticCode.UNSUPPORTED_JOIN_TYPE, 18113 "join type " + jt + " is not supported yet; " 18114 + "only INNER/LEFT/RIGHT/FULL [OUTER] JOIN ... ON, " 18115 + "JOIN ... USING (...), CROSS JOIN, CROSS/OUTER APPLY, and " 18116 + "NATURAL [INNER/LEFT/RIGHT/FULL [OUTER]] JOIN are accepted", item)); 18117 } 18118 boolean hasUsing = item.getUsingColumns() != null 18119 && item.getUsingColumns().size() > 0; 18120 boolean hasOn = item.getOnCondition() != null; 18121 if (isLateral) { 18122 // CROSS/OUTER APPLY is an ON-less lateral join; the correlation 18123 // lives inside the right operand, never on an ON/USING clause. 18124 // The parser does not attach ON/USING to APPLY items, so these 18125 // are defensive guards (reuse the CROSS_WITH_* codes — no new 18126 // diagnostic code is introduced for APPLY). 18127 if (hasOn) { 18128 throw new SemanticIRBuildException( 18129 Diagnostic.error(DiagnosticCode.CROSS_WITH_ON, 18130 "CROSS/OUTER APPLY must not carry an ON condition; the " 18131 + "correlation belongs inside the right operand", item)); 18132 } 18133 if (hasUsing) { 18134 throw new SemanticIRBuildException( 18135 Diagnostic.error(DiagnosticCode.CROSS_WITH_USING, 18136 "CROSS/OUTER APPLY must not carry a USING clause; the " 18137 + "correlation belongs inside the right operand", item)); 18138 } 18139 return; 18140 } 18141 if (isNatural) { 18142 if (hasOn) { 18143 throw new SemanticIRBuildException( 18144 Diagnostic.error(DiagnosticCode.NATURAL_WITH_ON, 18145 "NATURAL JOIN must not carry an ON condition; rewrite " 18146 + "as JOIN ... ON, or drop the NATURAL keyword", item)); 18147 } 18148 if (hasUsing) { 18149 throw new SemanticIRBuildException( 18150 Diagnostic.error(DiagnosticCode.NATURAL_WITH_USING, 18151 "NATURAL JOIN must not carry a USING clause; choose " 18152 + "either NATURAL or USING, not both", item)); 18153 } 18154 return; 18155 } 18156 if (isOnLess) { 18157 if (hasOn) { 18158 throw new SemanticIRBuildException( 18159 Diagnostic.error(DiagnosticCode.CROSS_WITH_ON, 18160 "CROSS JOIN must not carry an ON condition; rewrite " 18161 + "as INNER JOIN ... ON, or drop the ON clause", item)); 18162 } 18163 if (hasUsing) { 18164 throw new SemanticIRBuildException( 18165 Diagnostic.error(DiagnosticCode.CROSS_WITH_USING, 18166 "CROSS JOIN must not carry a USING clause; rewrite " 18167 + "as INNER JOIN ... USING (...) or drop USING", item)); 18168 } 18169 return; 18170 } 18171 // Predicate-bearing path. 18172 if (hasUsing && hasOn) { 18173 throw new SemanticIRBuildException( 18174 Diagnostic.error(DiagnosticCode.JOIN_WITH_BOTH_ON_AND_USING, 18175 "JOIN cannot carry both ON and USING; choose one", item)); 18176 } 18177 if (!hasUsing && !hasOn) { 18178 throw new SemanticIRBuildException( 18179 Diagnostic.error(DiagnosticCode.JOIN_MISSING_ON_OR_USING, 18180 "JOIN with no ON or USING condition is not supported yet " 18181 + "(implicit joins must be explicit and supported)", item)); 18182 } 18183 } 18184 18185 /** 18186 * Slice 64 — populate {@code joinColumnRefs} for a USING-shaped 18187 * join item. Emits refs in <b>left-then-right</b> order per key. 18188 * 18189 * <p>Column-source resolution looks at two sources, in order: 18190 * <ol> 18191 * <li>The catalog (via 18192 * {@link NameBindingProvider#getRelationColumnNames(TTable)}) 18193 * for base tables;</li> 18194 * <li>The slice-60 in-scope-relation-columns map (via 18195 * {@link NameBindingProvider#getInScopeRelationColumns()}) 18196 * for CTE and FROM-subquery relations, keyed by effective 18197 * alias.</li> 18198 * </ol> 18199 * 18200 * <p>Left side uses these two sources to narrow to the prior 18201 * relations that actually declare the USING key, walking 18202 * {@code topJoin.getTable()} then 18203 * {@code items[0..itemIndex-1].getTable()} in FROM order. 18204 * 18205 * <p>Right side is always {@code item.getTable()}. When either 18206 * source declares the right relation's columns and the USING key 18207 * is absent there, the build is failed-fast with a 18208 * non-exact-binding-style reject — matching what the resolver 18209 * does for plain {@code SELECT k} where {@code k} doesn't exist. 18210 * 18211 * <p>When neither source has any column info for the prior 18212 * relations (no catalog and no in-scope map), fall back to 18213 * emitting one ref for the immediately-prior relation so the 18214 * slice-64 admission still works without a catalog. Same 18215 * fallback applies to the right side (emit unconditionally). 18216 * 18217 * <p>This matches resolver2's all-chain-tables linkage 18218 * ({@code ScopeBuilder.preVisit(TJoinItem)}) for the cases where 18219 * catalog/in-scope info is missing, without adopting its 18220 * over-approximation when info IS available. 18221 */ 18222 private static void populateUsingJoinRefs(TJoin topJoin, 18223 TJoinItemList items, 18224 int itemIndex, 18225 TTable rightTable, 18226 TObjectNameList usingCols, 18227 NameBindingProvider provider, 18228 List<ColumnRef> joinRefsOut) { 18229 // Slice 66: collect the SQL-written USING key spellings and 18230 // delegate to the shared {@link #emitMergedJoinRefs} helper 18231 // which serves both USING (this path) and NATURAL. 18232 List<String> keyNames = new ArrayList<>(usingCols.size()); 18233 for (int k = 0; k < usingCols.size(); k++) { 18234 TObjectName usingKey = usingCols.getObjectName(k); 18235 if (usingKey == null) continue; 18236 String keyName = usingKey.getColumnNameOnly(); 18237 if (keyName == null || keyName.isEmpty()) continue; 18238 keyNames.add(keyName); 18239 } 18240 emitMergedJoinRefs(JoinKind.USING, keyNames, topJoin, items, 18241 itemIndex, rightTable, provider, joinRefsOut); 18242 } 18243 18244 /** 18245 * Slice 66 — discriminator for {@link #emitMergedJoinRefs}. USING 18246 * comes from a syntactic clause and enforces left-and-right 18247 * "key-must-exist" rejects; NATURAL comes from catalog inference 18248 * and never has a missing key by construction. 18249 */ 18250 private enum JoinKind { USING, NATURAL } 18251 18252 /** 18253 * Slice 66 — shared emit-refs helper used by USING and NATURAL. 18254 * Emits per-key {@code joinColumnRefs} in <b>left-then-right</b> 18255 * order, walking every prior FROM relation for the left side. The 18256 * {@code kind} discriminator controls: 18257 * 18258 * <ul> 18259 * <li><b>CTE-explicit-column-list deferral</b>: applies to BOTH 18260 * (the diagnostic wording mentions JOIN kind);</li> 18261 * <li><b>Right-side "missing key" reject</b>: USING-only — 18262 * NATURAL keys come from catalog intersection so the key 18263 * must be present on the right by construction;</li> 18264 * <li><b>Left-side "missing key" reject</b>: USING-only — same 18265 * rationale.</li> 18266 * </ul> 18267 * 18268 * <p>Spelling: the caller supplies the emitted spelling (USING 18269 * passes the SQL-written spelling; NATURAL passes the catalog- 18270 * declared spelling of the first contributor — see 18271 * {@link #naturalSharedKeys}). 18272 */ 18273 private static void emitMergedJoinRefs(JoinKind kind, 18274 List<String> keyNames, 18275 TJoin topJoin, 18276 TJoinItemList items, 18277 int itemIndex, 18278 TTable rightTable, 18279 NameBindingProvider provider, 18280 List<ColumnRef> joinRefsOut) { 18281 String rightAlias = effectiveAliasOf(rightTable); 18282 List<TTable> priorRelations = new ArrayList<>(); 18283 if (topJoin.getTable() != null) { 18284 priorRelations.add(topJoin.getTable()); 18285 } 18286 for (int j = 0; j < itemIndex; j++) { 18287 TJoinItem prevItem = items.getJoinItem(j); 18288 if (prevItem != null && prevItem.getTable() != null) { 18289 priorRelations.add(prevItem.getTable()); 18290 } 18291 } 18292 // Slice 60 / 64 / 66 originally rejected USING / NATURAL joins 18293 // against a CTE with an explicit column list because the CTE 18294 // body's StatementGraph published inner-projection names rather 18295 // than the renamed list. Slice 103 lifts that rejection by 18296 // wiring the slice-102 rename helper into the SELECT-side CTE 18297 // walker; the published column list now matches the explicit 18298 // list, so lookupRelationColumnNames returns the renamed names 18299 // from the in-scope map and the merged-key emit below works. 18300 // `MERGED_JOIN_AGAINST_CTE_WITH_EXPLICIT_COLUMN_LIST` stays 18301 // declared-but-unreached (slice 71/72/82/86/95/96/97/98/99/100/ 18302 // 101/102 precedent). 18303 for (String keyName : keyNames) { 18304 if (keyName == null || keyName.isEmpty()) continue; 18305 18306 // Left side FIRST (matches ON-clause natural reading order). 18307 // For USING: priorRelations with metadata-unknown or 18308 // declared-key emit a ref; missing-key skips. If no ref 18309 // emitted and all priors had known info → USING-only 18310 // reject (NATURAL never reaches this because the catalog 18311 // intersection guarantees at least one contributor). 18312 boolean emittedAnyLeft = false; 18313 boolean allPriorsHadColumnInfo = true; 18314 for (TTable prior : priorRelations) { 18315 List<String> cols = lookupRelationColumnNames(prior, provider); 18316 if (cols == null) { 18317 allPriorsHadColumnInfo = false; 18318 joinRefsOut.add(new ColumnRef( 18319 effectiveAliasOf(prior), keyName)); 18320 emittedAnyLeft = true; 18321 continue; 18322 } 18323 for (String c : cols) { 18324 if (c != null && c.equalsIgnoreCase(keyName)) { 18325 joinRefsOut.add(new ColumnRef( 18326 effectiveAliasOf(prior), keyName)); 18327 emittedAnyLeft = true; 18328 break; 18329 } 18330 } 18331 } 18332 if (kind == JoinKind.USING 18333 && !emittedAnyLeft && allPriorsHadColumnInfo 18334 && !priorRelations.isEmpty()) { 18335 throw new SemanticIRBuildException( 18336 Diagnostic.error(DiagnosticCode.USING_KEY_NOT_DECLARED, 18337 "USING key '" + keyName + "' is not declared on " 18338 + "any left-side relation; check that the " 18339 + "key exists on at least one of the " 18340 + "joined-in relations", rightTable)); 18341 } 18342 18343 // Right side. USING: must exist OR catalog unknown. 18344 // NATURAL: by construction the key is in right's catalog. 18345 // For the unknown-catalog case we still emit (over-approximate). 18346 List<String> rightCols = lookupRelationColumnNames(rightTable, provider); 18347 if (rightCols == null) { 18348 joinRefsOut.add(new ColumnRef(rightAlias, keyName)); 18349 } else { 18350 boolean rightHasKey = false; 18351 for (String c : rightCols) { 18352 if (c != null && c.equalsIgnoreCase(keyName)) { 18353 rightHasKey = true; 18354 break; 18355 } 18356 } 18357 if (!rightHasKey) { 18358 if (kind == JoinKind.USING) { 18359 throw new SemanticIRBuildException( 18360 Diagnostic.error(DiagnosticCode.USING_KEY_NOT_DECLARED, 18361 "USING key '" + keyName + "' is not declared on " 18362 + "right-side relation '" + rightAlias 18363 + "'; USING requires the key to exist on " 18364 + "both sides", rightTable)); 18365 } 18366 // NATURAL: silently skip — should be unreachable 18367 // because keys come from the intersection. 18368 continue; 18369 } 18370 joinRefsOut.add(new ColumnRef(rightAlias, keyName)); 18371 } 18372 } 18373 } 18374 18375 /** 18376 * Slice 64 — true iff the given table reference is a CTE with an 18377 * explicit column list (e.g. {@code WITH x(a, b) AS ...}). Slice 18378 * 64 originally used this to defer USING joins against such CTEs; 18379 * slice 103 lifted that deferral by wiring the slice-102 rename 18380 * helper into the SELECT-side CTE walker. The helper is retained 18381 * for {@link #buildUsingScope}'s ambiguity check (defense in depth) 18382 * and may be reused by future call sites that need to discriminate 18383 * the shape. 18384 */ 18385 private static boolean hasExplicitCteColumnList(TTable table) { 18386 if (table == null) return false; 18387 TCTE cte = table.getCTE(); 18388 return cte != null && cte.getColumnList() != null 18389 && cte.getColumnList().size() > 0; 18390 } 18391 18392 /** 18393 * Slice 65 — read the renamed column names from a CTE's explicit 18394 * column list ({@code WITH x(a, b) AS ...}). Used by 18395 * {@link #buildUsingScope}'s ambiguity check as a defense-in-depth 18396 * complement to {@link #lookupRelationColumnNames}. Slice 65 18397 * originally needed this because the CTE body's StatementGraph 18398 * published inner-projection names; slice 103 lifted that gap by 18399 * applying the slice-102 rename helper on the SELECT side, so the 18400 * in-scope-map path now returns the renamed list too. 18401 */ 18402 private static java.util.List<String> explicitCteColumnNames(TTable table) { 18403 if (table == null) return null; 18404 TCTE cte = table.getCTE(); 18405 if (cte == null) return null; 18406 if (cte.getColumnList() == null || cte.getColumnList().size() == 0) { 18407 return null; 18408 } 18409 java.util.List<String> names = new java.util.ArrayList<>(cte.getColumnList().size()); 18410 for (int i = 0; i < cte.getColumnList().size(); i++) { 18411 TObjectName col = cte.getColumnList().getObjectName(i); 18412 if (col == null) continue; 18413 String n = col.getColumnNameOnly(); 18414 if (n == null || n.isEmpty()) continue; 18415 names.add(n); 18416 } 18417 return names.isEmpty() ? null : names; 18418 } 18419 18420 /** 18421 * Slice 64 — look up column names for a FROM-clause relation 18422 * combining the slice-58 base-table catalog and the slice-60 18423 * in-scope CTE/subquery map. Returns {@code null} when neither 18424 * source has column info for the table. 18425 * 18426 * <p>The in-scope map is consulted <b>first</b>: when a CTE or 18427 * FROM-subquery has the same name as a base table in the catalog, 18428 * the scoped definition shadows the catalog (codex diff-review 18429 * round-2 P2 #1 — without this precedence, USING against the CTE 18430 * would see the catalog table's columns and reject a valid join). 18431 * 18432 * <p>Slice 103 — CTEs with an explicit column list are no longer 18433 * rejected upstream. The SELECT-side CTE walker now invokes the 18434 * slice-102 rename helper, so {@code ctePublishedColumns} carries 18435 * the renamed names; {@code addRelationToInScopeMap} reads from 18436 * that map, and this lookup returns the renamed list. (Slice 64's 18437 * older comment said the rename was deferred — that deferral was 18438 * lifted by slice 103.) 18439 */ 18440 private static List<String> lookupRelationColumnNames(TTable table, 18441 NameBindingProvider provider) { 18442 String key = effectiveAliasLowerCaseOrNull(table); 18443 if (key != null) { 18444 java.util.Map<String, List<String>> inScope = provider.getInScopeRelationColumns(); 18445 if (inScope != null) { 18446 List<String> scoped = inScope.get(key); 18447 if (scoped != null) return scoped; 18448 } 18449 } 18450 return provider.getRelationColumnNames(table); 18451 } 18452 18453 /** 18454 * True when EVERY FROM relation of {@code select} has a known column list 18455 * (catalog metadata via {@link NameBindingProvider#getRelationColumnNames} 18456 * or in-scope derived columns via {@code getInScopeRelationColumns}). Used to 18457 * gate the join-graph {@code COLUMN_BINDING_NON_EXACT} degrade: the degrade 18458 * is justified only when the FROM is catalog-less for at least one endpoint, 18459 * because then an unqualified column that fails to bind <em>might</em> live 18460 * on the unknown side and we cannot prove otherwise. When every endpoint's 18461 * columns are known, the resolver can adjudicate — an unqualified column 18462 * matching no side is a genuine error and must stay fatal. 18463 * 18464 * <p>Returns {@code false} (i.e. not fully known → degrade-eligible) for an 18465 * empty/absent FROM list; callers gate on {@code relations.size() >= 2} 18466 * first, so that branch is not reached in practice. 18467 */ 18468 private static boolean fromRelationColumnsFullyKnown(TSelectSqlStatement select, 18469 NameBindingProvider provider) { 18470 if (select == null) { 18471 return false; 18472 } 18473 gudusoft.gsqlparser.nodes.TTableList tables = select.getTables(); 18474 if (tables == null || tables.size() == 0) { 18475 return false; 18476 } 18477 for (int i = 0; i < tables.size(); i++) { 18478 TTable t = tables.getTable(i); 18479 if (t == null) { 18480 continue; 18481 } 18482 List<String> cols = lookupRelationColumnNames(t, provider); 18483 if (cols == null || cols.isEmpty()) { 18484 return false; 18485 } 18486 } 18487 return true; 18488 } 18489 18490 /** 18491 * Slice 66 — accumulated row type of the LEFT side of a top-level 18492 * {@code TJoin}. Maintained per top-level TJoin so that mixed 18493 * ON/CROSS/USING/NATURAL chains can be reasoned about against the 18494 * full visible row type (NATURAL JOIN's right operand sees every 18495 * column visible in the accumulated left, not just the immediate 18496 * prior table). 18497 * 18498 * <p>{@link #complete} flips to {@code false} when any contributor 18499 * along the chain has no resolvable catalog. A {@code false} 18500 * {@code complete} blocks subsequent NATURAL JoinItems from 18501 * inferring their shared-key list — they reject with a tuned 18502 * catalog-required diagnostic naming whichever side(s) lack 18503 * catalog metadata. 18504 */ 18505 private static final class LeftOutputState { 18506 final java.util.LinkedHashMap<String, List<TTable>> columns = new java.util.LinkedHashMap<>(); 18507 boolean complete = true; 18508 final List<String> missingAliases = new ArrayList<>(); 18509 18510 void markMissing(TTable t) { 18511 complete = false; 18512 String alias = effectiveAliasOf(t); 18513 if (alias != null && !alias.isEmpty()) { 18514 if (!missingAliases.contains(alias)) { 18515 missingAliases.add(alias); 18516 } 18517 } 18518 } 18519 } 18520 18521 /** 18522 * Slice 66 — result of {@link #naturalSharedKeys}. Either a SUCCESS 18523 * (with the inferred key list in left-output insertion order) or 18524 * one of three failure kinds: 18525 * 18526 * <ul> 18527 * <li>{@code INCOMPLETE_LEFT}: at least one prior contributor on 18528 * the accumulated left side had null/empty catalog;</li> 18529 * <li>{@code MISSING_RIGHT}: right table has null/empty catalog;</li> 18530 * <li>{@code BOTH_MISSING}: both above conditions hold.</li> 18531 * </ul> 18532 * 18533 * <p>Failures carry diagnostic aliases so the caller can produce 18534 * a side-specific reject message. 18535 */ 18536 private static final class NaturalKeyResult { 18537 enum Kind { SUCCESS, INCOMPLETE_LEFT, MISSING_RIGHT, BOTH_MISSING } 18538 final Kind kind; 18539 final List<String> keys; 18540 final List<String> leftMissingAliases; 18541 final String rightAlias; 18542 18543 private NaturalKeyResult(Kind kind, List<String> keys, 18544 List<String> leftMissingAliases, 18545 String rightAlias) { 18546 this.kind = kind; 18547 this.keys = keys; 18548 this.leftMissingAliases = leftMissingAliases; 18549 this.rightAlias = rightAlias; 18550 } 18551 static NaturalKeyResult success(List<String> keys) { 18552 return new NaturalKeyResult(Kind.SUCCESS, keys, null, null); 18553 } 18554 static NaturalKeyResult incompleteLeft(List<String> missing) { 18555 return new NaturalKeyResult(Kind.INCOMPLETE_LEFT, null, missing, null); 18556 } 18557 static NaturalKeyResult missingRight(String alias) { 18558 return new NaturalKeyResult(Kind.MISSING_RIGHT, null, null, alias); 18559 } 18560 static NaturalKeyResult bothMissing(List<String> missing, String alias) { 18561 return new NaturalKeyResult(Kind.BOTH_MISSING, null, missing, alias); 18562 } 18563 } 18564 18565 /** 18566 * Slice 66 — seed the {@link LeftOutputState} with the top-left 18567 * table of a top-level TJoin. Used at the start of each TJoin walk. 18568 */ 18569 private static void seedLeftOutput(LeftOutputState state, TTable t, 18570 NameBindingProvider provider) { 18571 if (t == null) return; 18572 List<String> cols = lookupRelationColumnNames(t, provider); 18573 if (cols == null || cols.isEmpty()) { 18574 state.markMissing(t); 18575 return; 18576 } 18577 for (String c : cols) { 18578 if (c == null || c.isEmpty()) continue; 18579 String colLC = c.toLowerCase(Locale.ROOT); 18580 List<TTable> contributors = state.columns.get(colLC); 18581 if (contributors == null) { 18582 contributors = new ArrayList<>(); 18583 state.columns.put(colLC, contributors); 18584 } 18585 contributors.add(t); 18586 } 18587 } 18588 18589 /** 18590 * Slice 66 — append the right table of an ON-shaped or CROSS 18591 * JoinItem into the running {@link LeftOutputState}. Each catalog 18592 * column is added as a new entry (or extends the contributor list 18593 * for an existing same-named entry). Mirrors 18594 * {@link #seedLeftOutput} but additive. 18595 */ 18596 /** 18597 * Merge the row type accumulated in {@code src} (a nested joined-table 18598 * operand's own {@link LeftOutputState}) into {@code dst} (the enclosing 18599 * join's running state). Used after recursing into a parenthesized 18600 * {@code <joined_table>} right operand so that a NATURAL join FOLLOWING the 18601 * nested operand at the enclosing level sees the sub-tree's columns, while 18602 * the sub-tree's own inner NATURAL inference ran against a fresh state. 18603 */ 18604 private static void mergeLeftOutputState(LeftOutputState dst, LeftOutputState src) { 18605 if (src == null) return; 18606 for (Map.Entry<String, List<TTable>> e : src.columns.entrySet()) { 18607 List<TTable> contributors = dst.columns.get(e.getKey()); 18608 if (contributors == null) { 18609 contributors = new ArrayList<>(); 18610 dst.columns.put(e.getKey(), contributors); 18611 } 18612 contributors.addAll(e.getValue()); 18613 } 18614 if (!src.complete) { 18615 dst.complete = false; 18616 for (String a : src.missingAliases) { 18617 if (!dst.missingAliases.contains(a)) { 18618 dst.missingAliases.add(a); 18619 } 18620 } 18621 } 18622 } 18623 18624 private static void appendRightToLeftOutput(LeftOutputState state, TTable right, 18625 NameBindingProvider provider) { 18626 if (right == null) return; 18627 List<String> cols = lookupRelationColumnNames(right, provider); 18628 if (cols == null || cols.isEmpty()) { 18629 state.markMissing(right); 18630 return; 18631 } 18632 for (String c : cols) { 18633 if (c == null || c.isEmpty()) continue; 18634 String colLC = c.toLowerCase(Locale.ROOT); 18635 List<TTable> contributors = state.columns.get(colLC); 18636 if (contributors == null) { 18637 contributors = new ArrayList<>(); 18638 state.columns.put(colLC, contributors); 18639 } 18640 contributors.add(right); 18641 } 18642 } 18643 18644 /** 18645 * Slice 66 — merge the right table of a USING-shaped or 18646 * NATURAL-shaped JoinItem. Columns in {@code mergedKeys} are 18647 * appended to the existing same-named contributor list at their 18648 * original output position (no new slot); other columns are 18649 * appended as new entries (or contributed to an existing same-named 18650 * entry — slice-59 plain-vs-plain duplicate admit). 18651 */ 18652 private static void mergeRightIntoLeftOutput(LeftOutputState state, TTable right, 18653 NameBindingProvider provider, 18654 List<String> mergedKeys) { 18655 if (right == null) return; 18656 java.util.Set<String> mergedKeysLC = new HashSet<>(); 18657 if (mergedKeys != null) { 18658 for (String k : mergedKeys) { 18659 if (k != null && !k.isEmpty()) { 18660 mergedKeysLC.add(k.toLowerCase(Locale.ROOT)); 18661 } 18662 } 18663 } 18664 List<String> cols = lookupRelationColumnNames(right, provider); 18665 if (cols == null || cols.isEmpty()) { 18666 state.markMissing(right); 18667 return; 18668 } 18669 for (String c : cols) { 18670 if (c == null || c.isEmpty()) continue; 18671 String colLC = c.toLowerCase(Locale.ROOT); 18672 // mergedKeysLC.contains(colLC) — append to existing entry; 18673 // !mergedKeysLC.contains(colLC) && state.columns.containsKey(colLC) 18674 // — append to existing entry (plain-vs-plain duplicate); 18675 // !state.columns.containsKey(colLC) — new entry. 18676 List<TTable> contributors = state.columns.get(colLC); 18677 if (contributors == null) { 18678 contributors = new ArrayList<>(); 18679 state.columns.put(colLC, contributors); 18680 } 18681 contributors.add(right); 18682 } 18683 } 18684 18685 /** 18686 * Slice 66 — infer the NATURAL JOIN shared-column list for the 18687 * current JoinItem. Returns one of four results per §6.1 of the 18688 * slice-66 plan. The shared list uses catalog-declared spelling 18689 * from the FIRST contributor that publishes each key (NATURAL has 18690 * no SQL-written key token, so the catalog form is the only 18691 * source of truth). 18692 */ 18693 private static NaturalKeyResult naturalSharedKeys(LeftOutputState leftState, 18694 TTable right, 18695 NameBindingProvider provider) { 18696 List<String> rightCols = lookupRelationColumnNames(right, provider); 18697 boolean rightMissing = (rightCols == null || rightCols.isEmpty()); 18698 if (!leftState.complete && rightMissing) { 18699 return NaturalKeyResult.bothMissing(leftState.missingAliases, 18700 effectiveAliasOf(right)); 18701 } 18702 if (!leftState.complete) { 18703 return NaturalKeyResult.incompleteLeft(leftState.missingAliases); 18704 } 18705 if (rightMissing) { 18706 return NaturalKeyResult.missingRight(effectiveAliasOf(right)); 18707 } 18708 java.util.Set<String> rightLC = new HashSet<>(); 18709 for (String c : rightCols) { 18710 if (c != null && !c.isEmpty()) { 18711 rightLC.add(c.toLowerCase(Locale.ROOT)); 18712 } 18713 } 18714 List<String> shared = new ArrayList<>(); 18715 for (java.util.Map.Entry<String, List<TTable>> e 18716 : leftState.columns.entrySet()) { 18717 String keyLC = e.getKey(); 18718 if (rightLC.contains(keyLC)) { 18719 shared.add(firstCatalogSpelling(e.getValue(), keyLC, provider)); 18720 } 18721 } 18722 return NaturalKeyResult.success(shared); 18723 } 18724 18725 /** 18726 * Slice 66 — return the catalog-declared spelling of {@code keyLC} 18727 * from the first contributor in insertion order that publishes the 18728 * key with a non-null spelling. Defensive fallback to {@code keyLC} 18729 * if no contributor exposes the spelling (unreachable in practice 18730 * because contributors are catalogued by construction). 18731 */ 18732 private static String firstCatalogSpelling(List<TTable> contributors, 18733 String keyLC, 18734 NameBindingProvider provider) { 18735 if (contributors != null) { 18736 for (TTable t : contributors) { 18737 List<String> cols = lookupRelationColumnNames(t, provider); 18738 if (cols == null) continue; 18739 for (String c : cols) { 18740 if (c != null && c.equalsIgnoreCase(keyLC)) { 18741 return c; 18742 } 18743 } 18744 } 18745 } 18746 return keyLC; 18747 } 18748 18749 /** 18750 * Slice 66 — diagnostic helper. Joins a list of aliases for the 18751 * NATURAL-required catalog reject message. 18752 */ 18753 private static String formatAliasList(List<String> aliases) { 18754 if (aliases == null || aliases.isEmpty()) return "<none>"; 18755 StringBuilder sb = new StringBuilder(); 18756 for (int i = 0; i < aliases.size(); i++) { 18757 if (i > 0) sb.append(", "); 18758 sb.append("'").append(aliases.get(i)).append("'"); 18759 } 18760 return sb.toString(); 18761 } 18762 18763 /** 18764 * Slice 66 — turn a {@link NaturalKeyResult} failure into a 18765 * structured diagnostic for the gated reject inside 18766 * {@link #buildRelations}. 18767 */ 18768 private static String formatNaturalCatalogReject(NaturalKeyResult r) { 18769 switch (r.kind) { 18770 case INCOMPLETE_LEFT: 18771 return "NATURAL JOIN requires catalog metadata for both sides; " 18772 + "left-side row type is incomplete due to uncatalogued " 18773 + "relation(s) " + formatAliasList(r.leftMissingAliases) 18774 + "; supply a TSQLEnv (or in-scope CTE / FROM-subquery " 18775 + "body) for the missing relation(s), or rewrite as " 18776 + "JOIN ... ON"; 18777 case MISSING_RIGHT: 18778 return "NATURAL JOIN requires catalog metadata for both sides; " 18779 + "right-side relation '" + r.rightAlias 18780 + "' has no resolvable column list; supply a TSQLEnv " 18781 + "(or in-scope CTE / FROM-subquery body) for this " 18782 + "relation, or rewrite as JOIN ... ON"; 18783 case BOTH_MISSING: 18784 return "NATURAL JOIN requires catalog metadata for both sides; " 18785 + "left-side row type is incomplete due to uncatalogued " 18786 + "relation(s) " + formatAliasList(r.leftMissingAliases) 18787 + " and right-side relation '" + r.rightAlias 18788 + "' also has no resolvable column list; supply a " 18789 + "TSQLEnv for the missing relation(s), or rewrite " 18790 + "as JOIN ... ON"; 18791 default: 18792 return "NATURAL JOIN: unexpected result kind " + r.kind; 18793 } 18794 } 18795 18796 /** 18797 * Slice 65 — fail fast when a JOIN ON clause references a USING 18798 * merged key by its bare (unqualified) name. JOIN ON requires 18799 * per-position scope (only relations BEFORE that JoinItem are 18800 * visible), which slice 65 does not yet model; the merged-key 18801 * collector applied by other clauses would over-include later 18802 * relations. Reject the shape so the slice-66+ slice can lift 18803 * with proper per-position scope. 18804 * 18805 * <p>This is the narrowed replacement for slice-64's 18806 * {@code rejectUnqualifiedUsingKeyReferences}, which scanned the 18807 * entire SELECT body. Slice 65 admits unqualified USING-key refs 18808 * in every other clause via the merged-key collector. 18809 * 18810 * <p>Qualified references (e.g. {@code a.k}, {@code b.k}) and 18811 * column references whose names don't match a USING key are 18812 * unaffected. 18813 */ 18814 private static void rejectUnqualifiedMergedKeyInJoinOn(TSelectSqlStatement select, 18815 NameBindingProvider provider) { 18816 if (select.joins == null) return; 18817 // Walk each TOP-LEVEL TJoin independently — each comma-FROM 18818 // group has its own scope for JOIN ON purposes (codex slice-65 18819 // diff-review round-4 P2 #1). Within one TJoin, walk JoinItems 18820 // in FROM order and track which merged keys (USING-declared OR 18821 // NATURAL-inferred) have been established. An ON clause is only 18822 // checked against keys that are ALREADY merged at that position; 18823 // a bare `k` in an ON before any USING(k) / NATURAL is just 18824 // resolver2's unqualified-binding case. 18825 // 18826 // Slice 66: NATURAL JoinItems contribute their catalog-inferred 18827 // key list to declaredKeysSoFar. When NATURAL would fail the 18828 // catalog requirement (INCOMPLETE_LEFT / MISSING_RIGHT / 18829 // BOTH_MISSING), the preflight silently skips recording this 18830 // JoinItem's keys — the gated reject in buildRelations will 18831 // fire with a catalog-required diagnostic and the user sees 18832 // that error first. 18833 // 18834 // Identity skip set: USING-clause own TObjectNames are 18835 // declarations not references; never matched against the 18836 // declaredKeysSoFar set since the preflight only walks ON 18837 // conditions. Kept as a defensive no-op. 18838 final java.util.Set<TObjectName> skip = 18839 java.util.Collections.newSetFromMap( 18840 new java.util.IdentityHashMap<TObjectName, Boolean>()); 18841 for (int j = 0; j < select.joins.size(); j++) { 18842 TJoin top = select.joins.getJoin(j); 18843 if (top == null) continue; 18844 TJoinItemList items = top.getJoinItems(); 18845 if (items == null) continue; 18846 // Reset per top-level TJoin so independent comma-FROM 18847 // groups don't poison each other's ON clauses. 18848 final java.util.Set<String> declaredKeysSoFar = new java.util.HashSet<>(); 18849 LeftOutputState leftState = new LeftOutputState(); 18850 seedLeftOutput(leftState, top.getTable(), provider); 18851 for (int i = 0; i < items.size(); i++) { 18852 TJoinItem item = items.getJoinItem(i); 18853 if (item == null) continue; 18854 // Check ON FIRST (uses scope BEFORE this JoinItem), then 18855 // record this JoinItem's USING/NATURAL declarations so 18856 // future siblings see them. 18857 TExpression onCond = item.getOnCondition(); 18858 if (onCond != null && !declaredKeysSoFar.isEmpty()) { 18859 final java.util.Set<String> alreadyDeclared = 18860 new java.util.HashSet<>(declaredKeysSoFar); 18861 onCond.acceptChildren(new TParseTreeVisitor() { 18862 int nestedSelectDepth = 0; 18863 18864 @Override 18865 public void preVisit(TSelectSqlStatement nested) { 18866 nestedSelectDepth++; 18867 } 18868 18869 @Override 18870 public void postVisit(TSelectSqlStatement nested) { 18871 nestedSelectDepth--; 18872 } 18873 18874 @Override 18875 public void preVisit(TObjectName node) { 18876 if (nestedSelectDepth > 0) return; 18877 if (skip.contains(node)) return; 18878 if (node.getDbObjectType() != EDbObjectType.column) return; 18879 String name = node.getColumnNameOnly(); 18880 if (name == null || name.isEmpty() || "*".equals(name)) return; 18881 if (!alreadyDeclared.contains(name.toLowerCase(Locale.ROOT))) return; 18882 String qualifier = node.getTableString(); 18883 if (qualifier == null || qualifier.isEmpty()) { 18884 throw new SemanticIRBuildException( 18885 Diagnostic.error(DiagnosticCode.UNQUALIFIED_MERGED_KEY_IN_JOIN_ON, 18886 "unqualified reference to merged key '" 18887 + name + "' inside a JOIN ON condition " 18888 + "is deferred to a future slice " 18889 + "(per-position scope semantics needed); " 18890 + "qualify with a table alias " 18891 + "(e.g. a." + name + ") to disambiguate", null)); 18892 } 18893 } 18894 }); 18895 } 18896 // Record this JoinItem's contribution to declaredKeysSoFar 18897 // and update leftState for NATURAL's accumulated-left 18898 // semantics. 18899 TTable rightTable = item.getTable(); 18900 TObjectNameList usingCols = item.getUsingColumns(); 18901 if (usingCols != null && usingCols.size() > 0) { 18902 List<String> usingKeyNames = new ArrayList<>(usingCols.size()); 18903 for (int k = 0; k < usingCols.size(); k++) { 18904 TObjectName n = usingCols.getObjectName(k); 18905 if (n == null) continue; 18906 skip.add(n); 18907 String name = n.getColumnNameOnly(); 18908 if (name != null && !name.isEmpty()) { 18909 declaredKeysSoFar.add(name.toLowerCase(Locale.ROOT)); 18910 usingKeyNames.add(name); 18911 } 18912 } 18913 if (rightTable != null) { 18914 mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames); 18915 } 18916 } else if (isNaturalJoinType(item.getJoinType()) && rightTable != null) { 18917 NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider); 18918 if (r.kind == NaturalKeyResult.Kind.SUCCESS) { 18919 for (String s : r.keys) { 18920 if (s != null && !s.isEmpty()) { 18921 declaredKeysSoFar.add(s.toLowerCase(Locale.ROOT)); 18922 } 18923 } 18924 mergeRightIntoLeftOutput(leftState, rightTable, provider, r.keys); 18925 } else { 18926 // Catalog-required reject fires upstream in 18927 // buildRelations. Defensively append for state 18928 // consistency. 18929 appendRightToLeftOutput(leftState, rightTable, provider); 18930 } 18931 } else if (rightTable != null) { 18932 appendRightToLeftOutput(leftState, rightTable, provider); 18933 } 18934 } 18935 } 18936 } 18937 18938 /** 18939 * Slice 65 — compute the {@link UsingScope} for the current SELECT 18940 * body from its FROM-clause USING joins. Walks every {@link TJoin} 18941 * in {@code select.joins} and for each USING(k) JoinItem, builds 18942 * the per-key equivalence class via DSU-like union over prior 18943 * relations + the right-side relation. Then materializes each 18944 * class by a separate FROM-order pass with identity dedup so 18945 * chained USING joins (`a JOIN b USING(k) JOIN c USING(k)`) 18946 * produce {@code [a, b, c]}, never duplicates. 18947 * 18948 * <p>For each class, builds a {@link UsingScope.MergedKeyEntry} 18949 * with FROM-ordered merged source refs (one per relation that 18950 * publishes the key per catalog / in-scope map; unknown-metadata 18951 * priors emit refs unconditionally, matching slice-64's over- 18952 * approximation policy in {@link #populateUsingJoinRefs}). 18953 * 18954 * <p>Ambiguity is precomputed: 18955 * <ul> 18956 * <li>{@code entries.size() > 1}: two disconnected USING classes 18957 * share the same key name.</li> 18958 * <li>{@code entries.size() == 1} AND a FROM relation outside 18959 * the class has catalog metadata that declares the key: 18960 * out-of-class same-named column.</li> 18961 * </ul> 18962 * 18963 * <p>Returns {@link UsingScope#EMPTY} when no USING clauses are 18964 * present in {@code select.joins}. 18965 */ 18966 private static UsingScope buildUsingScope(TSelectSqlStatement select, 18967 NameBindingProvider provider) { 18968 if (select.joins == null) return UsingScope.EMPTY; 18969 // Slice 86 — delegate to the shared TJoinList-taking helper so 18970 // joined UPDATE (slice 86 buildUpdateUsingScope) can reuse the 18971 // identical scope-build pipeline. 18972 return buildUsingScopeFromJoinList(select.joins, provider); 18973 } 18974 18975 /** 18976 * Slice 86 — compute the {@link UsingScope} for a joined UPDATE's 18977 * FROM clause via {@code update.getJoins()}. Mirrors slice-65 18978 * {@link #buildUsingScope}: USING / NATURAL JoinItems contribute 18979 * merged-key equivalence classes; unqualified merged-key references 18980 * in SET RHS / WHERE / RETURNING resolve to the merged source list. 18981 * 18982 * <p>Returns {@link UsingScope#EMPTY} when no USING/NATURAL JoinItems 18983 * appear in the FROM clause. 18984 */ 18985 private static UsingScope buildUpdateUsingScope(TUpdateSqlStatement update, 18986 NameBindingProvider provider) { 18987 if (update == null) return UsingScope.EMPTY; 18988 return buildUsingScopeFromJoinList(update.getJoins(), provider); 18989 } 18990 18991 /** 18992 * Slice 86 — shared {@link UsingScope} computation extracted from 18993 * slice-65 {@link #buildUsingScope}. Takes the {@link TJoinList} 18994 * directly so it can be invoked from both SELECT 18995 * ({@link #buildUsingScope}) and joined UPDATE 18996 * ({@link #buildUpdateUsingScope}). 18997 * 18998 * <p>Behavior identical to slice 65/66: per-key DSU union over prior 18999 * relations + right-side relation per top-level {@link TJoin}; 19000 * disconnected comma-FROM groups keep their own per-key components; 19001 * NATURAL JoinItems infer shared keys against accumulated left row 19002 * type via {@link LeftOutputState}; ambiguity detection walks all 19003 * FROM relations for out-of-class same-named columns. 19004 */ 19005 private static UsingScope buildUsingScopeFromJoinList(TJoinList joins, 19006 NameBindingProvider provider) { 19007 if (joins == null) return UsingScope.EMPTY; 19008 // Pass 1: per-key DSU. For each USING(k) or NATURAL JoinItem, 19009 // union the prior relations PUBLISHING the key (catalog-narrowed 19010 // per codex slice-66 round-1 P1 #1) with the right-side relation, 19011 // scoped to the enclosing top-level TJoin (chained merges within 19012 // one TJoin transitively connect through DSU). Disconnected 19013 // top-level TJoins (comma-FROM) keep their own per-key components. 19014 // Slice 66 maintains a LeftOutputState alongside the loop so 19015 // NATURAL JoinItems can infer their shared-key list against the 19016 // accumulated left row type. 19017 java.util.Map<String, java.util.List<java.util.List<TTable>>> perKeyComponents = 19018 new java.util.LinkedHashMap<>(); 19019 // Track the SQL-written spelling of each merged key (the first 19020 // occurrence in FROM order). For USING keys this is the 19021 // SQL-written USING-clause case (slice-64 contract); for 19022 // NATURAL keys this is the catalog-declared spelling from the 19023 // first contributor. 19024 java.util.Map<String, String> originalSpellingByKey = new java.util.HashMap<>(); 19025 // Catalog-less NATURAL JOIN degrade: ordered (FROM-order) list of 19026 // left-side relation aliases for NATURAL joins whose shared-key set 19027 // could not be computed. Lets unqualified references to a merged 19028 // NATURAL key resolve deterministically to the left side instead of 19029 // failing as COLUMN_BINDING_NON_EXACT (GSP R6 degrade-not-fail). 19030 java.util.List<String> naturalDegradeAliases = new java.util.ArrayList<>(); 19031 // The degrade only applies when the FROM is PURELY NATURAL-connected — 19032 // a single top-level TJoin whose every join item is NATURAL. A mixed 19033 // FROM (plain ON/CROSS/USING item, or a comma-separated second TJoin) 19034 // introduces an independent relation, so an unqualified reference there 19035 // is genuinely ambiguous and MUST still reject (doc §5.3: "do not apply 19036 // this special-case rule to non-NATURAL joins"). Cleared below if any 19037 // non-NATURAL linkage is seen. 19038 boolean pureNatural = (joins.size() == 1); 19039 for (int jx = 0; jx < joins.size(); jx++) { 19040 TJoin top = joins.getJoin(jx); 19041 if (top == null) continue; 19042 TJoinItemList items = top.getJoinItems(); 19043 if (items == null) continue; 19044 TTable topTable = top.getTable(); 19045 // Slice 66: per-TJoin LeftOutputState for NATURAL inference. 19046 LeftOutputState leftState = new LeftOutputState(); 19047 seedLeftOutput(leftState, topTable, provider); 19048 // Per-key in-progress chain for THIS top-level TJoin. 19049 java.util.Map<String, java.util.List<TTable>> inProgressByKey = 19050 new java.util.HashMap<>(); 19051 for (int i = 0; i < items.size(); i++) { 19052 TJoinItem item = items.getJoinItem(i); 19053 if (item == null) continue; 19054 TTable rightTable = item.getTable(); 19055 if (rightTable == null) continue; 19056 19057 // Determine merged keys for this JoinItem and the 19058 // emitted spelling per key. Three cases: 19059 // USING: keys = syntactic usingCols; spelling = USING-clause text. 19060 // NATURAL: keys = catalog intersection (when SUCCESS); 19061 // spelling = catalog spelling. 19062 // ON/CROSS/other: skip — append to leftState only. 19063 List<String> keyNames; 19064 java.util.Map<String, String> spellingByKeyLC = new java.util.HashMap<>(); 19065 TObjectNameList usingCols = item.getUsingColumns(); 19066 if (usingCols != null && usingCols.size() > 0) { 19067 // USING introduces an explicit (non-NATURAL) merge — the 19068 // FROM is no longer purely NATURAL, so disable the degrade. 19069 pureNatural = false; 19070 keyNames = new java.util.ArrayList<>(usingCols.size()); 19071 for (int k = 0; k < usingCols.size(); k++) { 19072 TObjectName keyNode = usingCols.getObjectName(k); 19073 if (keyNode == null) continue; 19074 String keyName = keyNode.getColumnNameOnly(); 19075 if (keyName == null || keyName.isEmpty()) continue; 19076 keyNames.add(keyName); 19077 spellingByKeyLC.put(keyName.toLowerCase(Locale.ROOT), keyName); 19078 } 19079 } else if (isNaturalJoinType(item.getJoinType())) { 19080 NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider); 19081 if (r.kind != NaturalKeyResult.Kind.SUCCESS) { 19082 // Catalog-required reject already fired (or will 19083 // fire) inside buildRelations. Defensively skip 19084 // this JoinItem in the scope build; it does NOT 19085 // contribute to the merged-key scope. Record the 19086 // left-side relation so unqualified references to a 19087 // merged NATURAL key degrade rather than reject. 19088 String leftAlias = effectiveAliasOf(topTable); 19089 if (leftAlias != null && !leftAlias.isEmpty() 19090 && !naturalDegradeAliases.contains(leftAlias)) { 19091 naturalDegradeAliases.add(leftAlias); 19092 } 19093 appendRightToLeftOutput(leftState, rightTable, provider); 19094 continue; 19095 } 19096 keyNames = r.keys; 19097 for (String s : keyNames) { 19098 if (s != null && !s.isEmpty()) { 19099 spellingByKeyLC.put(s.toLowerCase(Locale.ROOT), s); 19100 } 19101 } 19102 } else { 19103 // ON / CROSS / other — no merged-key contribution. This 19104 // join item introduces an independent relation, so an 19105 // unqualified reference is genuinely ambiguous: disable the 19106 // NATURAL degrade for the whole FROM. 19107 pureNatural = false; 19108 appendRightToLeftOutput(leftState, rightTable, provider); 19109 continue; 19110 } 19111 19112 // Prior relations for this JoinItem in FROM order: 19113 // topTable + items[0..i-1].getTable(). 19114 java.util.List<TTable> priorRelations = new java.util.ArrayList<>(); 19115 if (topTable != null) priorRelations.add(topTable); 19116 for (int p = 0; p < i; p++) { 19117 TJoinItem prev = items.getJoinItem(p); 19118 if (prev != null && prev.getTable() != null) { 19119 priorRelations.add(prev.getTable()); 19120 } 19121 } 19122 for (String keyName : keyNames) { 19123 if (keyName == null || keyName.isEmpty()) continue; 19124 String keyLC = keyName.toLowerCase(Locale.ROOT); 19125 // Record the first emitted spelling we see for this 19126 // key. USING uses SQL-written spelling; NATURAL uses 19127 // catalog-declared spelling. 19128 if (!originalSpellingByKey.containsKey(keyLC)) { 19129 originalSpellingByKey.put(keyLC, spellingByKeyLC.get(keyLC)); 19130 } 19131 java.util.List<TTable> chain = inProgressByKey.get(keyLC); 19132 if (chain == null) { 19133 chain = new java.util.ArrayList<>(); 19134 inProgressByKey.put(keyLC, chain); 19135 } 19136 // Slice 66 catalog-narrowed union (codex round-1 P1 #1): 19137 // for each prior relation, include in this key's 19138 // equivalence class only if (a) catalog is unknown 19139 // (over-approximate; slice-64 fallback), or (b) 19140 // catalog declares the key. Skip if catalog is 19141 // known and the key is proven absent. 19142 for (TTable prior : priorRelations) { 19143 if (containsByIdentity(chain, prior)) continue; 19144 List<String> priorCols = lookupRelationColumnNames(prior, provider); 19145 if (priorCols == null) { 19146 chain.add(prior); 19147 continue; 19148 } 19149 boolean priorPublishes = false; 19150 for (String pc : priorCols) { 19151 if (pc != null && pc.equalsIgnoreCase(keyLC)) { 19152 priorPublishes = true; 19153 break; 19154 } 19155 } 19156 if (priorPublishes) { 19157 chain.add(prior); 19158 } 19159 } 19160 if (!containsByIdentity(chain, rightTable)) { 19161 chain.add(rightTable); 19162 } 19163 } 19164 // After the merged-key bookkeeping, merge right into 19165 // the leftState so subsequent NATURAL JoinItems see 19166 // the accumulated row type. 19167 mergeRightIntoLeftOutput(leftState, rightTable, provider, keyNames); 19168 } 19169 // Flush this TJoin's in-progress chains as one component 19170 // per key. 19171 for (java.util.Map.Entry<String, java.util.List<TTable>> e : 19172 inProgressByKey.entrySet()) { 19173 java.util.List<java.util.List<TTable>> bucket = perKeyComponents.get(e.getKey()); 19174 if (bucket == null) { 19175 bucket = new java.util.ArrayList<>(); 19176 perKeyComponents.put(e.getKey(), bucket); 19177 } 19178 bucket.add(e.getValue()); 19179 } 19180 } 19181 // Mixed FROM (any non-NATURAL linkage seen): drop the degrade fallback 19182 // so genuinely-ambiguous unqualified references still reject. 19183 if (!pureNatural) { 19184 naturalDegradeAliases.clear(); 19185 } 19186 if (perKeyComponents.isEmpty()) { 19187 // No merged-key scope, but a catalog-less NATURAL JOIN may still 19188 // need the degrade fallback for unqualified merged-key refs. 19189 return naturalDegradeAliases.isEmpty() 19190 ? UsingScope.EMPTY 19191 : new UsingScope( 19192 java.util.Collections.<String, java.util.List<UsingScope.MergedKeyEntry>>emptyMap(), 19193 java.util.Collections.<String, String>emptyMap(), 19194 naturalDegradeAliases); 19195 } 19196 // Pass 2: materialize EquivalenceClass + MergedKeyEntry per 19197 // component. FROM-order is already preserved by Pass 1's 19198 // accumulation order (priorRelations + rightTable). 19199 java.util.Map<String, java.util.List<UsingScope.MergedKeyEntry>> entriesByName = 19200 new java.util.LinkedHashMap<>(); 19201 for (java.util.Map.Entry<String, java.util.List<java.util.List<TTable>>> e : 19202 perKeyComponents.entrySet()) { 19203 String keyLC = e.getKey(); 19204 // ColumnRef-emit spelling: SQL-written USING-clause spelling 19205 // (matches slice-64 populateUsingJoinRefs). Falls back to 19206 // keyLC if no spelling was recorded (defensive). 19207 String emitKeyName = originalSpellingByKey.containsKey(keyLC) 19208 ? originalSpellingByKey.get(keyLC) 19209 : keyLC; 19210 java.util.List<UsingScope.MergedKeyEntry> entries = new java.util.ArrayList<>(); 19211 for (java.util.List<TTable> componentMembers : e.getValue()) { 19212 if (componentMembers.isEmpty()) continue; 19213 UsingScope.EquivalenceClass cls = new UsingScope.EquivalenceClass( 19214 keyLC, componentMembers); 19215 java.util.List<ColumnRef> sources = new java.util.ArrayList<>(); 19216 java.util.Set<String> seenAliases = new java.util.HashSet<>(); 19217 for (TTable t : componentMembers) { 19218 String effAlias = effectiveAliasOf(t); 19219 if (effAlias == null || effAlias.isEmpty()) continue; 19220 String aliasKey = effAlias.toLowerCase(Locale.ROOT); 19221 if (seenAliases.contains(aliasKey)) continue; 19222 java.util.List<String> cols = lookupRelationColumnNames(t, provider); 19223 if (cols == null) { 19224 // Metadata-unknown: emit ref (over-approximate). 19225 sources.add(new ColumnRef(effAlias, emitKeyName)); 19226 seenAliases.add(aliasKey); 19227 continue; 19228 } 19229 for (String c : cols) { 19230 if (c != null && c.equalsIgnoreCase(keyLC)) { 19231 sources.add(new ColumnRef(effAlias, emitKeyName)); 19232 seenAliases.add(aliasKey); 19233 break; 19234 } 19235 } 19236 } 19237 if (!sources.isEmpty()) { 19238 entries.add(new UsingScope.MergedKeyEntry(cls, sources)); 19239 } 19240 } 19241 if (!entries.isEmpty()) { 19242 entriesByName.put(keyLC, entries); 19243 } 19244 } 19245 if (entriesByName.isEmpty()) { 19246 return naturalDegradeAliases.isEmpty() 19247 ? UsingScope.EMPTY 19248 : new UsingScope( 19249 java.util.Collections.<String, java.util.List<UsingScope.MergedKeyEntry>>emptyMap(), 19250 java.util.Collections.<String, String>emptyMap(), 19251 naturalDegradeAliases); 19252 } 19253 // Pass 3: precompute ambiguity per key. 19254 java.util.Map<String, String> ambiguityByName = new java.util.HashMap<>(); 19255 java.util.List<TTable> allFromRelations = walkAllFromRelationsFromJoinList(joins); 19256 for (java.util.Map.Entry<String, java.util.List<UsingScope.MergedKeyEntry>> e : 19257 entriesByName.entrySet()) { 19258 String keyLC = e.getKey(); 19259 java.util.List<UsingScope.MergedKeyEntry> entries = e.getValue(); 19260 if (entries.size() > 1) { 19261 ambiguityByName.put(keyLC, 19262 "multiple disconnected USING(" + keyLC + ") equivalence " 19263 + "classes appear in this FROM (their merged " 19264 + "columns share the same key name)"); 19265 continue; 19266 } 19267 // Single class. Walk all FROM relations; if any out-of-class 19268 // relation is catalog-known to publish the key, mark ambiguous. 19269 UsingScope.EquivalenceClass cls = entries.get(0).getEquivClass(); 19270 java.util.IdentityHashMap<TTable, Boolean> inClass = new java.util.IdentityHashMap<>(); 19271 for (TTable m : cls.getMembers()) inClass.put(m, Boolean.TRUE); 19272 for (TTable r : allFromRelations) { 19273 if (inClass.containsKey(r)) continue; 19274 // Slice 65 diff-review round-3 P2 #2 (slice 103 update): 19275 // post-slice-103 both branches return the same data — 19276 // `lookupRelationColumnNames` consults the in-scope map 19277 // populated from `ctePublishedColumns`, which now holds 19278 // the renamed names. The discriminator is retained as a 19279 // defense-in-depth path for any call site that bypasses 19280 // the SELECT-side CTE walker but still wants to detect 19281 // renamed-key collisions; the slice-103 path falls into 19282 // the `lookupRelationColumnNames` branch and gets the 19283 // same renamed list. 19284 java.util.List<String> cols; 19285 if (hasExplicitCteColumnList(r)) { 19286 cols = explicitCteColumnNames(r); 19287 } else { 19288 cols = lookupRelationColumnNames(r, provider); 19289 } 19290 if (cols == null) continue; // unknown → trust writer 19291 for (String c : cols) { 19292 if (c != null && c.equalsIgnoreCase(keyLC)) { 19293 String outAlias = effectiveAliasOf(r); 19294 ambiguityByName.put(keyLC, 19295 "the USING(" + keyLC + ") merged column collides " 19296 + "with column '" + keyLC + "' on relation '" 19297 + (outAlias != null ? outAlias : "<unnamed>") 19298 + "' which is not part of the USING equivalence class"); 19299 break; 19300 } 19301 } 19302 if (ambiguityByName.containsKey(keyLC)) break; 19303 } 19304 } 19305 return new UsingScope(entriesByName, ambiguityByName, naturalDegradeAliases); 19306 } 19307 19308 private static boolean containsByIdentity(java.util.List<TTable> list, TTable t) { 19309 for (TTable x : list) { 19310 if (x == t) return true; 19311 } 19312 return false; 19313 } 19314 19315 /** 19316 * Slice 65 — every FROM-clause relation reachable directly from 19317 * {@code select.joins} (every {@code top.getTable()} + every 19318 * {@code joinItem.getTable()}). Used by {@link #buildUsingScope} 19319 * to detect out-of-equivalence-class same-named columns. 19320 */ 19321 private static java.util.List<TTable> walkAllFromRelations(TSelectSqlStatement select) { 19322 if (select == null) return new java.util.ArrayList<>(); 19323 return walkAllFromRelationsFromJoinList(select.joins); 19324 } 19325 19326 /** 19327 * Slice 86 — shared {@link TJoinList}-taking walker for ambiguity 19328 * detection inside {@link #buildUsingScopeFromJoinList}. Used by both 19329 * SELECT ({@link #walkAllFromRelations}) and joined UPDATE 19330 * ({@link #buildUpdateUsingScope}). 19331 */ 19332 private static java.util.List<TTable> walkAllFromRelationsFromJoinList(TJoinList joins) { 19333 java.util.List<TTable> out = new java.util.ArrayList<>(); 19334 if (joins == null) return out; 19335 for (int j = 0; j < joins.size(); j++) { 19336 TJoin top = joins.getJoin(j); 19337 if (top == null) continue; 19338 if (top.getTable() != null) out.add(top.getTable()); 19339 TJoinItemList items = top.getJoinItems(); 19340 if (items == null) continue; 19341 for (int i = 0; i < items.size(); i++) { 19342 TJoinItem item = items.getJoinItem(i); 19343 if (item != null && item.getTable() != null) { 19344 out.add(item.getTable()); 19345 } 19346 } 19347 } 19348 return out; 19349 } 19350 19351 /** 19352 * Slice 64 — true iff any TJoinItem in {@code select.joins} 19353 * carries a non-empty USING list. Used by {@link #tryExpandStar} 19354 * to defer bare {@code *} over USING JOIN to S65 (merged-key 19355 * output naming). 19356 */ 19357 private static boolean hasUsingInFromClause(TSelectSqlStatement select) { 19358 if (select.joins == null) return false; 19359 for (int j = 0; j < select.joins.size(); j++) { 19360 TJoin top = select.joins.getJoin(j); 19361 if (top == null) continue; 19362 TJoinItemList items = top.getJoinItems(); 19363 if (items == null) continue; 19364 for (int i = 0; i < items.size(); i++) { 19365 TJoinItem item = items.getJoinItem(i); 19366 if (item != null 19367 && item.getUsingColumns() != null 19368 && item.getUsingColumns().size() > 0) { 19369 return true; 19370 } 19371 } 19372 } 19373 return false; 19374 } 19375 19376 /** 19377 * Slice 66 — true iff any JoinItem is NATURAL AND its inferred 19378 * shared-column list is non-empty (catalog-resolved on both sides 19379 * and the intersection contains at least one column). 19380 * 19381 * <p>Routes bare {@code *} expansion through 19382 * {@link #expandBareStarOverUsing} when NATURAL contributes merged 19383 * keys. NATURAL with empty intersection or with INCOMPLETE_LEFT / 19384 * MISSING_RIGHT / BOTH_MISSING returns false for THIS JoinItem 19385 * (codex slice-66 round-4 P2 #3) — the bare-* path then falls 19386 * through to per-relation expansion, which is correct for an 19387 * empty-intersection NATURAL (Cartesian, no dedup needed). The 19388 * catalog-required reject for NATURAL fires upstream inside 19389 * {@link #buildRelations} before bare-* runs. 19390 * 19391 * <p>The walk maintains its own per-top-level-TJoin 19392 * {@link LeftOutputState} (so NATURAL inference against accumulated 19393 * left works the same way as in {@link #buildUsingScope} and 19394 * {@link #buildRelations}). 19395 */ 19396 private static boolean hasNaturalJoinMergedKeysInFromClause( 19397 TSelectSqlStatement select, NameBindingProvider provider) { 19398 if (select.joins == null) return false; 19399 for (int j = 0; j < select.joins.size(); j++) { 19400 TJoin top = select.joins.getJoin(j); 19401 if (top == null) continue; 19402 TJoinItemList items = top.getJoinItems(); 19403 if (items == null) continue; 19404 LeftOutputState leftState = new LeftOutputState(); 19405 seedLeftOutput(leftState, top.getTable(), provider); 19406 for (int i = 0; i < items.size(); i++) { 19407 TJoinItem item = items.getJoinItem(i); 19408 if (item == null) continue; 19409 TTable rightTable = item.getTable(); 19410 if (rightTable == null) continue; 19411 if (isNaturalJoinType(item.getJoinType())) { 19412 NaturalKeyResult r = naturalSharedKeys(leftState, rightTable, provider); 19413 if (r.kind == NaturalKeyResult.Kind.SUCCESS 19414 && r.keys != null && !r.keys.isEmpty()) { 19415 return true; 19416 } 19417 appendRightToLeftOutput(leftState, rightTable, provider); 19418 continue; 19419 } 19420 TObjectNameList usingCols = item.getUsingColumns(); 19421 if (usingCols != null && usingCols.size() > 0) { 19422 List<String> usingKeyNames = new ArrayList<>(usingCols.size()); 19423 for (int k = 0; k < usingCols.size(); k++) { 19424 TObjectName usingKey = usingCols.getObjectName(k); 19425 if (usingKey == null) continue; 19426 String keyName = usingKey.getColumnNameOnly(); 19427 if (keyName != null && !keyName.isEmpty()) { 19428 usingKeyNames.add(keyName); 19429 } 19430 } 19431 mergeRightIntoLeftOutput(leftState, rightTable, provider, usingKeyNames); 19432 } else { 19433 appendRightToLeftOutput(leftState, rightTable, provider); 19434 } 19435 } 19436 } 19437 return false; 19438 } 19439 19440 /** 19441 * Two relations sharing the same effective alias would make 19442 * {@link ColumnRef#getRelationAlias()} ambiguous in the IR. Resolver2 19443 * may already flag column references in this case, but the IR-level 19444 * invariant still needs to hold. 19445 */ 19446 /** 19447 * Reject two FROM relations whose aliases are the SAME NAME under the 19448 * vendor's identifier rules — they would make every {@link ColumnRef} 19449 * qualified by that alias ambiguous. 19450 * 19451 * <p>Slice 182 (GitHub #707, codex round-1 P2): this compared raw strings 19452 * with {@code HashSet<String>.add}, so it only caught a byte-identical 19453 * repeat. Oracle {@code FROM employees A1, departments a1} is ONE alias 19454 * under Oracle's case-folding rules but two distinct strings, and it built 19455 * a StatementGraph with two same-named relations. The outer / CTE-body / 19456 * FROM-subquery-body paths were shielded by 19457 * {@link #preflightDirectFromList}, which folds with 19458 * {@code toLowerCase(Locale.ROOT)}; the synthetic body contexts never call 19459 * that preflight, so the miss was reachable there — already via 19460 * {@code JOIN … ON}, and (before this fix) newly via comma FROM as well. 19461 * 19462 * <p>Keyed by {@link SQLUtil#canonKey} per the repo's one-canonical-equality 19463 * rule, which is also stricter than the preflight's naive fold in the right 19464 * direction: a delimited {@code "a1"} stays distinct from an unquoted 19465 * {@code a1} on a case-folding vendor, because quote state is part of a 19466 * name's identity. Keyed as {@code dotTable} because a relation alias lives 19467 * in the table namespace — the same object type every resolver2 / dlineage 19468 * alias comparison already uses. 19469 * 19470 * <p>SECOND CONDITION, APPLIED ONLY TO A COMMA FROM LIST — the 19471 * comma-promotion collision guard (codex round-2 P2, narrowed after codex 19472 * round-3 P2). Two aliases that canonical equality calls DISTINCT can still 19473 * collapse in the comma-join promotion, which keys aliases by 19474 * {@code toLowerCase(Locale.ROOT)} in {@code collectConjunctRelationAliases} 19475 * and {@code endpointAliasesLowerCase}. Oracle 19476 * {@code FROM employees "A1", departments "a1"} is two legitimately distinct 19477 * names that fold to one key, and the measured result was a SILENT WRONG 19478 * ANSWER: the graph came back {@code IMPLICIT_CROSS} with no conditions, 19479 * because the WHERE predicate was never seen as spanning two sides and so 19480 * was never promoted, while the {@code JOIN … ON} spelling of the same 19481 * query correctly returned {@code INNER}. 19482 * 19483 * <p>Gated on {@code fromUsesCommaList} because the promotion is the ONLY 19484 * consumer that turns this collision into a wrong answer on a path this 19485 * slice opened. Round 3 supplied the counterexample that forced the gate: 19486 * {@code EXISTS (SELECT 1 FROM employees "A1" JOIN departments "a1" ON 1=1)} 19487 * was analyzed CORRECTLY before this slice (two relations, INNER/EXPLICIT) — 19488 * {@code emitLineageForStatement}'s alias map does overwrite an entry, but a 19489 * source-free output never consults it — so an ungated reject deleted a 19490 * correct result, which this repo forbids outright. 19491 * 19492 * <p>The gate makes the guard non-regressing BY CONSTRUCTION: a comma FROM 19493 * inside a body context did not analyze at all before this slice, and on the 19494 * outer / CTE-body / FROM-subquery-body paths the same pair is already 19495 * refused by {@code preflightOneTable}'s own lowercase fold. So no shape 19496 * that previously produced a correct graph stops producing one. 19497 * 19498 * <p>FOLLOW-UP, out of scope for #707: key the promotion (and the lineage 19499 * alias map, whose last-write-wins overwrite is a pre-existing latent bug 19500 * reachable only when a colliding alias feeds a sourced output) by canonical 19501 * identity, then drop this second condition and the fold in 19502 * {@code preflightOneTable}. Piecemeal is unsafe — loosening a guard before 19503 * its consumers are collision-safe turns a visible rejection into a silent 19504 * overwrite. 19505 */ 19506 private static void rejectDuplicateAliases(List<RelationSource> relations, 19507 EDbVendor vendor, 19508 boolean fromUsesCommaList) { 19509 Set<Object> seenCanonical = new HashSet<>(); 19510 Set<String> seenPromotionKey = new HashSet<>(); 19511 for (RelationSource r : relations) { 19512 String alias = r.getAlias(); 19513 Object canonical = (alias == null) 19514 ? null 19515 : SQLUtil.canonKey(vendor, ESQLDataObjectType.dotTable, alias); 19516 if (!seenCanonical.add(canonical)) { 19517 throw new SemanticIRBuildException( 19518 Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS, 19519 "duplicate relation alias '" + alias 19520 + "' is not supported (would make ColumnRef ambiguous)", null)); 19521 } 19522 // Distinct names that still collide in the comma-join promotion's 19523 // lowercased alias sets — see the javadoc. Rejected rather than 19524 // analyzed, because analyzing publishes IMPLICIT_CROSS for what is 19525 // really an INNER join, with nothing to flag it. Only the comma 19526 // form reaches that promotion, so only the comma form is gated. 19527 if (!fromUsesCommaList) continue; 19528 String promotionKey = (alias == null) 19529 ? null 19530 : alias.toLowerCase(Locale.ROOT); 19531 if (!seenPromotionKey.add(promotionKey)) { 19532 throw new SemanticIRBuildException( 19533 Diagnostic.error(DiagnosticCode.DUPLICATE_RELATION_ALIAS, 19534 "relation aliases '" + alias + "' and an earlier alias differ only " 19535 + "in letter case; they are distinct names for this vendor, " 19536 + "but a comma-separated FROM list with such a pair cannot " 19537 + "yet be analyzed (the join-condition promotion would " 19538 + "collide them and report a cross join). Rename one of " 19539 + "them, or use an explicit JOIN ... ON", null)); 19540 } 19541 } 19542 } 19543 19544 private static RelationSource buildRelation(TTable table, NameBindingProvider provider, 19545 boolean allowFromSubqueries) { 19546 return buildRelation(table, provider, allowFromSubqueries, 19547 RelationSource.NO_INSTANCE_ID); 19548 } 19549 19550 private static RelationSource buildRelation(TTable table, NameBindingProvider provider, 19551 boolean allowFromSubqueries, int instanceId) { 19552 // Reject FROM-subqueries when the caller did not extract them as 19553 // separate statements. After slice 18 the still-uncovered scopes 19554 // are scalar bodies (slice-11 boundary), set-op branches (slice-16 19555 // boundary), and set-op CTE bodies (build()'s set-op CTE dispatch 19556 // passes allowFromSubqueries=false to each branch). 19557 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.subquery 19558 && !allowFromSubqueries) { 19559 // Slice 74: use effectiveAliasOf so anonymous subqueries 19560 // surface their synth name in the diagnostic instead of the 19561 // empty-string the prior `getAliasName() == null` ternary 19562 // produced. 19563 String bodyAlias = effectiveAliasOf(table); 19564 throw new SemanticIRBuildException( 19565 Diagnostic.error(DiagnosticCode.FROM_SUBQUERY_IN_BODY_CONTEXT_NOT_SUPPORTED, 19566 "FROM-clause subquery '" + (bodyAlias == null || bodyAlias.isEmpty() ? "<anonymous>" : bodyAlias) 19567 + "' inside a scalar body, set-op branch, or set-op CTE body is not supported yet", table)); 19568 } 19569 RelationBinding binding = provider.bindRelation(table); 19570 if (binding == null) { 19571 throw new SemanticIRBuildException( 19572 Diagnostic.error(DiagnosticCode.TABLE_BINDING_UNRESOLVED, 19573 "could not bind table " + safeName(table) + " (only base tables and in-scope CTEs are supported)", table)); 19574 } 19575 // Effective alias: prefer the SQL-written alias, then the slice-74 19576 // synthetic alias for anonymous FROM-subqueries, then the table 19577 // name (mirrors effectiveAliasOf so RelationSource.alias and 19578 // ColumnRef.relationAlias stay aligned). 19579 String alias = effectiveAliasOf(table); 19580 // Slice 164 (S3): attach the FROM-clause table-reference span 19581 // (includes alias, e.g. "t1 a"). Null-safe via SourceSpan.of. 19582 // Slice 179 (R4): carry the stable FROM-order instanceId. 19583 return new RelationSource(alias, binding, SourceSpan.of(table), instanceId); 19584 } 19585 19586 private static String safeName(TTable t) { 19587 try { 19588 return t.getName(); 19589 } catch (RuntimeException e) { 19590 return "<unnamed>"; 19591 } 19592 } 19593 19594 // ----------------------------------------------------------------- 19595 // Slice 167 (join-analysis S6) — ordered, chain-correct structured 19596 // JoinGraph (GAP 1). Walks the same TJoinList the flat join refs come 19597 // from, but stops flattening: each TJoinItem becomes a JoinEntity, and 19598 // comma-separated top-level TJoins become IMPLICIT_CROSS entities. 19599 // 19600 // Left-deep chaining: the right endpoint is always the newly added 19601 // RELATION; the left endpoint is the first RELATION for join order 0, 19602 // else the JOIN_RESULT accumulated by the prior join. No predicate 19603 // decomposition here (that is slice 168). The flat joinColumnRefs are 19604 // untouched. 19605 // ----------------------------------------------------------------- 19606 /** 19607 * R8 — append predicate-derived semi-join entities to a FROM-clause 19608 * {@link JoinGraph}. Each {@link SemiJoinFact} becomes a 19609 * {@link SemanticJoinType#SEMI} / {@link SemanticJoinType#ANTI_SEMI} 19610 * {@link JoinEntity} whose left endpoint is the outer relation owning 19611 * the correlated outer column (resolved from {@code relations} by 19612 * alias; falls back to the first FROM relation) and whose right 19613 * endpoint is the lifted subquery's {@link JoinEndpointKind#SUBQUERY} 19614 * block. Orders continue after the FROM joins. Returns the input graph 19615 * unchanged when there are no facts. 19616 */ 19617 private static JoinGraph appendSemiJoins(JoinGraph base, 19618 List<SemiJoinFact> semiFacts, 19619 List<RelationSource> relations) { 19620 if (semiFacts == null || semiFacts.isEmpty()) { 19621 return base; 19622 } 19623 List<JoinEntity> joins = new ArrayList<>(base.getJoins()); 19624 int order = joins.size(); 19625 for (SemiJoinFact f : semiFacts) { 19626 JoinEndpoint left = f.outerAnchorUnknown 19627 ? unknownOuterEndpointFor(relations, joins, order) 19628 : outerEndpointFor(f.outerAliases, relations, joins); 19629 if (left == null) { 19630 // No FROM relation to anchor the outer side — skip rather 19631 // than fabricate an endpoint (honest omission). 19632 continue; 19633 } 19634 JoinEndpoint right = JoinEndpoint.subquery(f.innerStatementIndex, f.innerLabel); 19635 joins.add(new JoinEntity(f.polarity, left, right, order++, 19636 JoinSourceSyntax.SEMI, /*natural=*/ false, 19637 /*usingColumns=*/ null, f.conditions, f.span, 19638 f.conditionText, /*lateral=*/ false)); 19639 } 19640 return joins.isEmpty() ? JoinGraph.EMPTY : new JoinGraph(joins); 19641 } 19642 19643 /** 19644 * R8 — left endpoint for a semi-join. When the correlation references a 19645 * single outer relation, that relation is the endpoint. When it 19646 * references several (or none can be matched but FROM joins exist), the 19647 * endpoint is the accumulated outer rowset (a {@link JoinEndpointKind#JOIN_RESULT} 19648 * over the last FROM join, carrying every FROM alias) — codex R8 [P2]: 19649 * a single-relation endpoint would be wrong when the semi-join's 19650 * conditions span multiple outer relations. Falls back to the first 19651 * FROM relation; returns {@code null} only when there is no FROM 19652 * relation to anchor. 19653 */ 19654 /** 19655 * Degrade only (GitHub #708) — left endpoint for a semi-join whose body was 19656 * never analyzed. The correlation target is UNKNOWN, so anchoring to a 19657 * specific relation would publish a wrong edge: for 19658 * {@code FROM A, B ... EXISTS (<unsupported>)} correlating to B, naming A 19659 * is not a missing fact, it is a false one. Anchor to the whole outer 19660 * rowset ({@link JoinEndpointKind#JOIN_RESULT}) instead — true whichever 19661 * relation the body referenced. 19662 * 19663 * <p>A single-relation host is the one case where the relation IS provable 19664 * (there is nothing else it could correlate to), so it keeps the precise 19665 * RELATION endpoint. 19666 */ 19667 private static JoinEndpoint unknownOuterEndpointFor(List<RelationSource> relations, 19668 List<JoinEntity> priorJoins, 19669 int order) { 19670 if (relations == null || relations.isEmpty()) { 19671 return null; 19672 } 19673 if (relations.size() == 1) { 19674 return outerEndpointFor(Collections.<String>emptyList(), relations, priorJoins); 19675 } 19676 List<String> allAliases = new ArrayList<>(); 19677 for (RelationSource rs : relations) { 19678 if (rs.getAlias() != null && !rs.getAlias().isEmpty()) { 19679 allAliases.add(rs.getAlias()); 19680 } 19681 } 19682 // Order of the join that produced the outer rowset. Prefer the last 19683 // real join; fall back to this entity's own order slot when the host 19684 // has several relations but no join entity to point at. 19685 int producing = (priorJoins != null && !priorJoins.isEmpty()) 19686 ? priorJoins.get(priorJoins.size() - 1).getOrder() 19687 : order; 19688 return JoinEndpoint.joinResult(producing, allAliases); 19689 } 19690 19691 private static JoinEndpoint outerEndpointFor(List<String> outerAliases, 19692 List<RelationSource> relations, 19693 List<JoinEntity> priorJoins) { 19694 if (relations == null || relations.isEmpty()) { 19695 return null; 19696 } 19697 // Multiple distinct outer relations → the joined outer rowset. 19698 if (outerAliases != null && countMatchingRelations(outerAliases, relations) > 1 19699 && priorJoins != null && !priorJoins.isEmpty()) { 19700 List<String> allAliases = new ArrayList<>(); 19701 for (RelationSource rs : relations) { 19702 if (rs.getAlias() != null && !rs.getAlias().isEmpty()) { 19703 allAliases.add(rs.getAlias()); 19704 } 19705 } 19706 int lastOrder = priorJoins.get(priorJoins.size() - 1).getOrder(); 19707 return JoinEndpoint.joinResult(lastOrder, allAliases); 19708 } 19709 // Single outer relation: match it by alias, else the first FROM relation. 19710 RelationSource chosen = null; 19711 if (outerAliases != null) { 19712 for (String a : outerAliases) { 19713 for (RelationSource rs : relations) { 19714 if (rs.getAlias() != null && rs.getAlias().equalsIgnoreCase(a)) { 19715 chosen = rs; 19716 break; 19717 } 19718 } 19719 if (chosen != null) break; 19720 } 19721 } 19722 if (chosen == null) { 19723 chosen = relations.get(0); 19724 } 19725 String alias = chosen.getAlias(); 19726 if (alias == null || alias.isEmpty()) { 19727 return null; 19728 } 19729 String qn = chosen.getBinding() == null 19730 ? null : chosen.getBinding().getQualifiedName(); 19731 return JoinEndpoint.relation(alias, qn, chosen.getInstanceId()); 19732 } 19733 19734 /** R8 — count how many FROM relations are named by {@code outerAliases}. */ 19735 private static int countMatchingRelations(List<String> outerAliases, 19736 List<RelationSource> relations) { 19737 int n = 0; 19738 for (RelationSource rs : relations) { 19739 if (rs.getAlias() == null) continue; 19740 for (String a : outerAliases) { 19741 if (rs.getAlias().equalsIgnoreCase(a)) { 19742 n++; 19743 break; 19744 } 19745 } 19746 } 19747 return n; 19748 } 19749 19750 private static JoinGraph buildJoinGraph(TSelectSqlStatement select, 19751 NameBindingProvider provider) { 19752 if (select == null || select.getJoins() == null || select.getJoins().size() == 0) { 19753 return JoinGraph.EMPTY; 19754 } 19755 List<JoinEntity> entities = new ArrayList<JoinEntity>(); 19756 JoinEndpoint currentLeft = null; 19757 List<String> accumulated = new ArrayList<String>(); 19758 int order = 0; 19759 boolean firstRelationSeen = false; 19760 // Slice 179 (R4): same FROM-order ordinal as buildRelations (driver 19761 // first, then each join-item right table) so endpoint instanceIds 19762 // align with RelationSource instanceIds by construction. 19763 int relInstanceId = 0; 19764 19765 for (int j = 0; j < select.getJoins().size(); j++) { 19766 TJoin topJoin = select.getJoins().getJoin(j); 19767 if (topJoin == null) continue; 19768 TTable driver = topJoin.getTable(); 19769 if (driver != null) { 19770 String dAlias = effectiveAliasOf(driver); 19771 String dQn = relationQnOf(driver); 19772 if (!firstRelationSeen) { 19773 currentLeft = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId++); 19774 accumulated.add(currentLeft.getAlias()); 19775 firstRelationSeen = true; 19776 } else { 19777 // Comma-FROM: implicit cross between the accumulated 19778 // left result and this new top-level relation. A 19779 // `FROM m, LATERAL (...)` operand is a lateral cross — mark 19780 // the entity lateral (and surface LATERAL syntax) so a 19781 // consumer does not flag it as an accidental cartesian. 19782 boolean driverLateral = isLateralTable(driver); 19783 JoinEndpoint right = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId++); 19784 entities.add(new JoinEntity(SemanticJoinType.IMPLICIT_CROSS, 19785 currentLeft, right, order, 19786 driverLateral ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.COMMA, 19787 false, null, null, SourceSpan.of(driver), null, driverLateral)); 19788 accumulated.add(right.getAlias()); 19789 currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated)); 19790 order++; 19791 } 19792 } else if (topJoin.getJoin() != null) { 19793 // Parenthesized joined-table as the top-level driver operand, 19794 // e.g. `FROM (a JOIN b ON …) JOIN c ON …`. Recurse to emit the 19795 // sub-tree's entities and obtain its result endpoint, then treat 19796 // it as this position's left input (or comma-cross partner). 19797 int[] o = {order}; 19798 int[] rid = {relInstanceId}; 19799 JoinEndpoint sub = buildJoinGraphForJoin(topJoin.getJoin(), provider, entities, o, rid); 19800 order = o[0]; 19801 relInstanceId = rid[0]; 19802 if (sub == null) return JoinGraph.EMPTY; 19803 if (!firstRelationSeen) { 19804 currentLeft = sub; 19805 accumulated.addAll(aliasesOf(sub)); 19806 firstRelationSeen = true; 19807 } else { 19808 entities.add(new JoinEntity(SemanticJoinType.IMPLICIT_CROSS, 19809 currentLeft, sub, order, JoinSourceSyntax.COMMA, 19810 false, null, null, SourceSpan.of(topJoin), null)); 19811 accumulated.addAll(aliasesOf(sub)); 19812 currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated)); 19813 order++; 19814 } 19815 } 19816 TJoinItemList items = topJoin.getJoinItems(); 19817 if (items == null) continue; 19818 for (int i = 0; i < items.size(); i++) { 19819 TJoinItem item = items.getJoinItem(i); 19820 if (item == null) continue; 19821 TTable right = item.getTable(); 19822 JoinEndpoint rightEp; 19823 if (right != null) { 19824 String rAlias = effectiveAliasOf(right); 19825 String rQn = relationQnOf(right); 19826 rightEp = JoinEndpoint.relation(safeAlias(rAlias, rQn), rQn, relInstanceId++); 19827 } else if (item.getJoin() != null) { 19828 // Parenthesized joined-table as the right operand, 19829 // e.g. `FROM a JOIN (b JOIN c ON …) ON …`. Recurse to emit 19830 // the sub-tree's inner entities and use its result endpoint 19831 // as the right side of this outer join. 19832 int[] o = {order}; 19833 int[] rid = {relInstanceId}; 19834 rightEp = buildJoinGraphForJoin(item.getJoin(), provider, entities, o, rid); 19835 order = o[0]; 19836 relInstanceId = rid[0]; 19837 if (rightEp == null) return JoinGraph.EMPTY; 19838 } else { 19839 // Genuinely neither a table nor a nested join — skip safely 19840 // (no endpoint guesswork). 19841 continue; 19842 } 19843 if (currentLeft == null) { 19844 // A join item with no established left input — never guess a 19845 // left endpoint; surface the graph as empty. 19846 return JoinGraph.EMPTY; 19847 } 19848 SemanticJoinType jt = mapSemanticJoinType(item.getJoinType()); 19849 boolean natural = isNaturalJoinType(item.getJoinType()); 19850 // Lateral via the APPLY join type (SQL Server) OR a LATERAL 19851 // right operand (ANSI / PostgreSQL `CROSS JOIN LATERAL (...)`, 19852 // `JOIN LATERAL (...) ON ...`, `LEFT JOIN LATERAL (...) ON ...`). 19853 boolean lateral = isLateralJoinType(item.getJoinType()) 19854 || isLateralTable(item.getTable()); 19855 List<String> using = usingColumnNames(item); 19856 // Slice 168 (S7): decompose this join's ON condition into 19857 // resolved predicate trees and attach them to the entity. 19858 // CROSS/OUTER APPLY carry no ON (correlation is inside the 19859 // right operand), so onPredicates stays null and conditions 19860 // stay empty — the correlation is preserved by the child 19861 // StatementGraph the right derived table produces. 19862 List<Predicate> onPredicates = (item.getOnCondition() != null) 19863 ? PredicateTreeBuilder.build(item.getOnCondition(), provider) 19864 : null; 19865 // Slice 177 (R1): verbatim ON-condition text (null when no ON). 19866 String conditionText = verbatimText(item.getOnCondition()); 19867 // A condition-less lateral join (APPLY, CROSS JOIN LATERAL) is 19868 // self-described by sourceSyntax=LATERAL plus the isLateral flag 19869 // so a consumer never mistakes its empty condition for a buggy 19870 // cartesian INNER. A lateral join that DOES carry an explicit 19871 // condition (`JOIN LATERAL ... ON`, or a USING/NATURAL form) 19872 // keeps EXPLICIT syntax — the user wrote an explicit join 19873 // condition; its laterality rides on the orthogonal isLateral 19874 // flag only. 19875 boolean hasExplicitCondition = item.getOnCondition() != null 19876 || (using != null && !using.isEmpty()) || natural; 19877 JoinSourceSyntax syntax = (lateral && !hasExplicitCondition) 19878 ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.EXPLICIT; 19879 // R7: a USING join's TJoinItem ends at the last USING column 19880 // name, not the closing ')', so SourceSpan.of(item) drops the 19881 // ')'. Extend the span end to one-past the closing ')' for 19882 // USING joins. ON / NATURAL / CROSS items already reach their 19883 // true clause end, so they keep SourceSpan.of(item). 19884 SourceSpan joinSpan = usingJoinSpan(item); 19885 entities.add(new JoinEntity(jt, currentLeft, rightEp, order, 19886 syntax, natural, using, onPredicates, 19887 joinSpan, conditionText, lateral)); 19888 accumulated.addAll(aliasesOf(rightEp)); 19889 currentLeft = JoinEndpoint.joinResult(order, new ArrayList<String>(accumulated)); 19890 order++; 19891 } 19892 } 19893 return new JoinGraph(entities); 19894 } 19895 19896 /** 19897 * Recursive join-graph builder for a parenthesized / nested 19898 * {@code <joined_table>} sub-tree (a {@link TJoin} reached via 19899 * {@link TJoinItem#getJoin()} or {@link TJoin#getJoin()}). Emits the 19900 * sub-tree's {@link JoinEntity}s into {@code entities}, advances the shared 19901 * {@code order} and {@code relInstanceId} counters (one-element arrays so the 19902 * caller sees the updates), and returns the sub-tree's result endpoint to be 19903 * used as a left/right endpoint of the enclosing join. Returns {@code null} 19904 * when an operand is genuinely neither a table nor a nested join, so the 19905 * caller surfaces the graph as empty rather than guessing. 19906 * 19907 * <p>Traversal order (driver first, then items left-to-right, recursing 19908 * inline) is identical to {@link #appendJoinRelations}, so endpoint 19909 * instanceIds align with RelationSource instanceIds by construction. 19910 */ 19911 private static JoinEndpoint buildJoinGraphForJoin(TJoin join, NameBindingProvider provider, 19912 List<JoinEntity> entities, int[] order, int[] relInstanceId) { 19913 if (join == null) return null; 19914 JoinEndpoint currentLeft; 19915 List<String> accumulated = new ArrayList<String>(); 19916 TTable driver = join.getTable(); 19917 if (driver != null) { 19918 String dAlias = effectiveAliasOf(driver); 19919 String dQn = relationQnOf(driver); 19920 currentLeft = JoinEndpoint.relation(safeAlias(dAlias, dQn), dQn, relInstanceId[0]++); 19921 accumulated.add(currentLeft.getAlias()); 19922 } else if (join.getJoin() != null) { 19923 currentLeft = buildJoinGraphForJoin(join.getJoin(), provider, entities, order, relInstanceId); 19924 if (currentLeft == null) return null; 19925 accumulated.addAll(aliasesOf(currentLeft)); 19926 } else { 19927 return null; 19928 } 19929 TJoinItemList items = join.getJoinItems(); 19930 for (int i = 0; items != null && i < items.size(); i++) { 19931 TJoinItem item = items.getJoinItem(i); 19932 if (item == null) continue; 19933 JoinEndpoint rightEp; 19934 TTable right = item.getTable(); 19935 if (right != null) { 19936 String rAlias = effectiveAliasOf(right); 19937 String rQn = relationQnOf(right); 19938 rightEp = JoinEndpoint.relation(safeAlias(rAlias, rQn), rQn, relInstanceId[0]++); 19939 } else if (item.getJoin() != null) { 19940 rightEp = buildJoinGraphForJoin(item.getJoin(), provider, entities, order, relInstanceId); 19941 if (rightEp == null) return null; 19942 } else { 19943 return null; 19944 } 19945 SemanticJoinType jt = mapSemanticJoinType(item.getJoinType()); 19946 boolean natural = isNaturalJoinType(item.getJoinType()); 19947 boolean lateral = isLateralJoinType(item.getJoinType()) 19948 || isLateralTable(item.getTable()); 19949 List<String> using = usingColumnNames(item); 19950 List<Predicate> onPredicates = (item.getOnCondition() != null) 19951 ? PredicateTreeBuilder.build(item.getOnCondition(), provider) 19952 : null; 19953 String conditionText = verbatimText(item.getOnCondition()); 19954 boolean hasExplicitCondition = item.getOnCondition() != null 19955 || (using != null && !using.isEmpty()) || natural; 19956 JoinSourceSyntax syntax = (lateral && !hasExplicitCondition) 19957 ? JoinSourceSyntax.LATERAL : JoinSourceSyntax.EXPLICIT; 19958 SourceSpan joinSpan = usingJoinSpan(item); 19959 entities.add(new JoinEntity(jt, currentLeft, rightEp, order[0], 19960 syntax, natural, using, onPredicates, 19961 joinSpan, conditionText, lateral)); 19962 accumulated.addAll(aliasesOf(rightEp)); 19963 currentLeft = JoinEndpoint.joinResult(order[0], new ArrayList<String>(accumulated)); 19964 order[0]++; 19965 } 19966 return currentLeft; 19967 } 19968 19969 /** 19970 * FROM-order aliases contributed by a join endpoint: the full contributing 19971 * list for a join result, or the single alias for a relation / subquery 19972 * endpoint. Used to extend the accumulated alias list when a nested join 19973 * sub-tree is folded into an enclosing join. 19974 */ 19975 private static List<String> aliasesOf(JoinEndpoint ep) { 19976 if (ep == null) return new ArrayList<String>(); 19977 List<String> contrib = ep.getContributingAliases(); 19978 if (contrib != null && !contrib.isEmpty()) { 19979 return new ArrayList<String>(contrib); 19980 } 19981 List<String> single = new ArrayList<String>(); 19982 if (ep.getAlias() != null && !ep.getAlias().isEmpty()) { 19983 single.add(ep.getAlias()); 19984 } 19985 return single; 19986 } 19987 19988 /** 19989 * R7 — compute the {@link SourceSpan} for a join entity, extending a 19990 * {@code USING (...)} join's span to include the closing {@code ')'}. 19991 * 19992 * <p>For a {@code USING} join the {@link TJoinItem}'s end token is the 19993 * last USING column name (e.g. {@code k} in {@code USING (id, k)}), so 19994 * {@link SourceSpan#of(TParseTreeNode)} ends one token early and the 19995 * slice drops the trailing {@code ')'}. Here we advance the span end to 19996 * one-past the closing {@code ')'} that follows the USING column list. 19997 * 19998 * <p>ON / NATURAL / CROSS / APPLY items already reach their true clause 19999 * end (the ON condition expression reaches the clause end; the others 20000 * carry no parenthesised key list), so they fall through to the plain 20001 * {@code SourceSpan.of(item)}. 20002 */ 20003 private static SourceSpan usingJoinSpan(TJoinItem item) { 20004 if (item == null) return null; 20005 TObjectNameList using = item.getUsingColumns(); 20006 TSourceToken start = item.getStartToken(); 20007 if (using == null || using.size() == 0 || start == null) { 20008 return SourceSpan.of(item); 20009 } 20010 TSourceToken end = using.getEndToken(); 20011 if (end == null) { 20012 return SourceSpan.of(item); 20013 } 20014 // Advance to the closing ')' that follows the USING column list. The 20015 // list's end token is the last column name; the next solid token is 20016 // the ')'. Scan a small bounded window so a stray whitespace/comment 20017 // layout never wedges the walk, and never run past it. 20018 if (!")".equals(end.getAstext())) { 20019 TSourceToken t = end.nextSolidToken(); 20020 int guard = 0; 20021 while (t != null && guard++ < 8 && !")".equals(t.getAstext())) { 20022 t = t.nextSolidToken(); 20023 } 20024 if (t != null && ")".equals(t.getAstext())) { 20025 end = t; 20026 } 20027 } 20028 return SourceSpan.of(start, end); 20029 } 20030 20031 /** 20032 * Slice 177 (R1) — reconstruct the verbatim source substring spanned by 20033 * a node, by concatenating the token chain from start to end WITHOUT 20034 * inserting separators (so original whitespace / comments are 20035 * preserved). Returns null when the node or its boundary tokens are 20036 * null. Used to populate {@code JoinEntity.conditionText} so consumers 20037 * don't re-implement span-slicing for the common "show me the ON text" 20038 * case. 20039 */ 20040 private static String verbatimText(TParseTreeNode node) { 20041 if (node == null) return null; 20042 TSourceToken s = node.getStartToken(); 20043 TSourceToken e = node.getEndToken(); 20044 if (s == null || e == null) return null; 20045 StringBuilder sb = new StringBuilder(); 20046 TSourceToken c = s; 20047 int guard = 0; 20048 boolean reachedEnd = false; 20049 while (c != null && guard++ < 2_000_000) { 20050 if (c.astext != null) sb.append(c.astext); 20051 if (c == e) { 20052 reachedEnd = true; 20053 break; 20054 } 20055 c = c.getNextTokenInChain(); 20056 } 20057 // If the end token was not reachable from the start (or the guard 20058 // tripped), the accumulated prefix would be truncated/unrelated SQL. 20059 // Return null (unavailable) rather than silently-wrong verbatim text. 20060 if (!reachedEnd) return null; 20061 return sb.length() == 0 ? null : sb.toString(); 20062 } 20063 20064 /** Endpoint alias: the effective alias, falling back to the qualified name. */ 20065 private static String safeAlias(String alias, String qn) { 20066 if (alias != null && !alias.isEmpty()) return alias; 20067 if (qn != null && !qn.isEmpty()) return qn; 20068 return "<anonymous>"; 20069 } 20070 20071 /** Bare relation name for an endpoint's qualifiedName; null when unavailable. */ 20072 private static String relationQnOf(TTable t) { 20073 if (t == null) return null; 20074 TObjectName n = t.getTableName(); 20075 if (n != null) { 20076 String s = n.toString(); 20077 if (s != null && !s.isEmpty()) return s; 20078 } 20079 return null; 20080 } 20081 20082 /** USING column names (empty for non-USING joins). */ 20083 private static List<String> usingColumnNames(TJoinItem item) { 20084 List<String> out = new ArrayList<String>(); 20085 if (item.getUsingColumns() != null) { 20086 for (int u = 0; u < item.getUsingColumns().size(); u++) { 20087 TObjectName c = item.getUsingColumns().getObjectName(u); 20088 if (c != null) { 20089 String name = c.getColumnNameOnly(); 20090 if (name == null || name.isEmpty()) name = c.toString(); 20091 if (name != null && !name.isEmpty()) out.add(name); 20092 } 20093 } 20094 } 20095 return out; 20096 } 20097 20098 /** 20099 * Map the parser's {@link EJoinType} to the stable 20100 * {@link SemanticJoinType}. NATURAL variants map to the underlying 20101 * join type ({@code natural_left} -> LEFT); the NATURAL semantics 20102 * are carried by {@code JoinEntity.isNatural()}. Bare {@code natural} 20103 * maps to {@link SemanticJoinType#NATURAL}. {@code crossapply} / 20104 * {@code outerapply} degrade to INNER / LEFT with the lateral nature 20105 * carried by {@code JoinEntity.isLateral()}. Shapes the semantic 20106 * builder does not admit (nested / semi / anti / vendor extensions) 20107 * map to {@link SemanticJoinType#UNSUPPORTED} — never guessed. 20108 */ 20109 private static SemanticJoinType mapSemanticJoinType(EJoinType jt) { 20110 if (jt == null) return SemanticJoinType.UNSUPPORTED; 20111 switch (jt) { 20112 case inner: 20113 case join: 20114 case straight: 20115 case natural_inner: 20116 return SemanticJoinType.INNER; 20117 case left: 20118 case leftouter: 20119 case natural_left: 20120 case natural_leftouter: 20121 return SemanticJoinType.LEFT; 20122 case right: 20123 case rightouter: 20124 case natural_right: 20125 case natural_rightouter: 20126 return SemanticJoinType.RIGHT; 20127 case full: 20128 case fullouter: 20129 case natural_full: 20130 case natural_fullouter: 20131 return SemanticJoinType.FULL; 20132 case cross: 20133 return SemanticJoinType.CROSS; 20134 case crossapply: 20135 // SQL Server CROSS APPLY: inner lateral join (drops left 20136 // rows with no right match). Lateral nature carried by 20137 // JoinEntity.isLateral(). 20138 return SemanticJoinType.INNER; 20139 case outerapply: 20140 // SQL Server OUTER APPLY: left lateral join (keeps left 20141 // rows, right side nullable). 20142 return SemanticJoinType.LEFT; 20143 case natural: 20144 return SemanticJoinType.NATURAL; 20145 default: 20146 return SemanticJoinType.UNSUPPORTED; 20147 } 20148 } 20149 20150 // ----------------------------------------------------------------- 20151 // Slice 58 / 59 — catalog-backed SELECT * expansion. 20152 // 20153 // The hook in buildOutputColumns calls tryExpandStar(rc, select, 20154 // provider, isPredicateBody, stmtName) for any result column whose 20155 // columnNameOnly is "*" (and as defense in depth for any 20156 // EExpressionType.list_t expression). tryExpandStar returns a 20157 // StarExpansionResult that is either EXPANDED (with a list of 20158 // OutputColumns) or one of several reasoned rejection kinds. The 20159 // hook then either appends the expanded columns or throws a 20160 // structured SemanticIRBuildException whose message is unique per 20161 // kind so external callers can pattern-match without parsing a 20162 // generic "not supported yet" string. 20163 // 20164 // Scope (slice 58): 20165 // - single base-table FROM (1 join, no join items) 20166 // - bare `*` or qualified `t.*` 20167 // - catalog provided via NameBindingProvider#getRelationColumnNames 20168 // 20169 // Slice 59 lift: 20170 // - multi-relation FROM is now supported when a single top-level 20171 // TJoin carries one or more explicit JOIN clauses (joinItems). 20172 // Each FROM relation must individually satisfy slice-58 rules 20173 // (binding kind TABLE, catalog declares columns). Bare `*` 20174 // concatenates per-relation expansions in FROM order; qualified 20175 // `t.*` selects the one relation whose effective alias matches. 20176 // - qualifier matching is now effective-alias only 20177 // (alias if present, else table name) — case-insensitive. This 20178 // unifies the rule across single- and multi-relation paths; 20179 // `SELECT employees.* FROM employees e` rejects because the 20180 // effective alias is `e`, not `employees`. 20181 // - star expansion is rejected inside synthetic body contexts 20182 // (scalar-subquery / set-op-branch / predicate-subquery) via 20183 // SYNTHETIC_BODY_CONTEXT; the slice-58 path silently allowed 20184 // this for catalog-equipped builds even though a multi-column 20185 // expansion would corrupt scalar-body shape downstream. 20186 // 20187 // Slice 60 lift: 20188 // - CTE star and FROM-subquery star (`a.*` and bare `*` over a CTE 20189 // or FROM-clause subquery alias) are now supported via the 20190 // in-scope-relation-columns map carried on the provider. The 20191 // map is populated at each consuming-SELECT call site in build() 20192 // and extractFromSubqueriesAsStatements before the consumer's 20193 // buildOutputColumns runs; tryExpandStar reads it for CTE / 20194 // SUBQUERY bindings. Explicit CTE column lists 20195 // (`WITH a(x, y) AS ...`) stay rejected because the CTE body's 20196 // StatementGraph publishes inner-projection names, not the 20197 // explicit list, and emitLineageForStatement would point at 20198 // non-existent body outputs. Lifting that path needs either 20199 // body-output renaming or a published-name → body-name lineage 20200 // map; deferred to a future slice. 20201 // 20202 // Slice 62 lift: 20203 // - Comma-FROM (multiple top-level TJoin elements parsed from 20204 // `FROM a, b, c`) is now admitted at the outer / CTE-body / 20205 // FROM-subquery-body call sites. {@link #tryExpandStar} walks 20206 // every top-level TJoin and accumulates relations in FROM 20207 // order; bare `*` concatenates per-relation expansions and 20208 // qualified `t.*` selects the matching effective-alias. 20209 // Synthetic body contexts (scalar / set-op-branch / set-op-CTE 20210 // / predicate) still reject comma-FROM via the gated reject 20211 // in buildRelations and the slice-62 reject inside 20212 // preflightPredicateSubqueryShape. 20213 // 20214 // Out of scope (slice 60+): 20215 // - SELECT * EXCEPT/REPLACE (BigQuery extensions; no slice scheduled) 20216 // - Explicit CTE column list star expansion (slice 61+) 20217 // ----------------------------------------------------------------- 20218 20219 enum StarExpansionKind { 20220 EXPANDED, 20221 PREDICATE_BODY_GUARD, 20222 // Defensive catch-all for malformed FROM lists: missing top-level 20223 // TJoin, null table on a top-level TJoin or a join item, or 20224 // empty {@code select.joins}. Slice 62 made comma-FROM admit 20225 // here (the walk iterates every top-level TJoin), so reaching 20226 // this kind indicates a parse-tree anomaly rather than a comma- 20227 // FROM rejection. 20228 MULTI_RELATION_FROM, 20229 NON_BASE_TABLE_RELATION, 20230 QUALIFIER_NOT_FOUND, 20231 // Slice 59: a qualifier matches 2+ relations (case-insensitive 20232 // effective-alias collision). Real SQL never reaches this case 20233 // unless `rejectDuplicateAliases` permitted a case-only collision 20234 // (it is case-sensitive at SemanticIRBuilder.java:5621). 20235 QUALIFIER_AMBIGUOUS, 20236 NO_CATALOG_OR_UNKNOWN_TABLE, 20237 // Slice 59: star expansion in synthetic body contexts 20238 // (scalar-subquery, set-op-branch, predicate-subquery) is rejected 20239 // because multi-column expansion would violate the body's shape 20240 // contract (e.g. scalar bodies must project exactly one column). 20241 SYNTHETIC_BODY_CONTEXT, 20242 // Slice 60: CTE has an explicit column list 20243 // (`WITH a(x, y) AS ...`). Deferred to a future slice because 20244 // the CTE body's StatementGraph publishes inner-projection 20245 // names, not the explicit list, and lineage emission cannot 20246 // bridge that without either body-output renaming or a 20247 // published-name → body-name map. 20248 EXPLICIT_CTE_COLUMN_LIST_DEFERRED, 20249 // Slice 60: CTE / SUBQUERY binding's published-column map 20250 // lookup returned null or empty. This indicates a builder 20251 // invariant failure (the body should have been built and 20252 // registered before the consumer's buildOutputColumns runs); 20253 // user SQL cannot reach this kind under normal builds — only 20254 // fabricated providers or a missed plumbing path would. The 20255 // diagnostic names the binding kind and qualified name so 20256 // regressions are loud, not silent. 20257 NO_INSCOPE_RELATION_COLUMNS 20258 } 20259 20260 static final class StarExpansionResult { 20261 final StarExpansionKind kind; 20262 final List<OutputColumn> columns; 20263 final String qualifier; 20264 final String detail; 20265 20266 private StarExpansionResult(StarExpansionKind kind, 20267 List<OutputColumn> columns, 20268 String qualifier, 20269 String detail) { 20270 this.kind = kind; 20271 this.columns = columns; 20272 this.qualifier = qualifier; 20273 this.detail = detail; 20274 } 20275 20276 static StarExpansionResult expanded(List<OutputColumn> cols) { 20277 return new StarExpansionResult(StarExpansionKind.EXPANDED, cols, null, null); 20278 } 20279 20280 static StarExpansionResult reject(StarExpansionKind kind) { 20281 return new StarExpansionResult(kind, null, null, null); 20282 } 20283 20284 static StarExpansionResult reject(StarExpansionKind kind, String qualifier, String detail) { 20285 return new StarExpansionResult(kind, null, qualifier, detail); 20286 } 20287 } 20288 20289 /** 20290 * Effective alias for a FROM-clause {@link TTable}: the SQL-written 20291 * alias if present, else the slice-74 synthetic alias for unaliased 20292 * FROM-subquery TTables (position-keyed via 20293 * {@link FromSubqueryNaming#synthAliasFor}), else the table name. 20294 * Mirrors the rule used by {@link #buildRelation} so 20295 * {@link ColumnRef#getRelationAlias()} stays aligned with what the 20296 * lineage emitter expects. 20297 */ 20298 private static String effectiveAliasOf(TTable t) { 20299 if (t == null) return null; 20300 String alias = t.getAliasName(); 20301 if (alias != null && !alias.isEmpty()) return alias; 20302 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 20303 return FromSubqueryNaming.synthAliasFor(t); 20304 } 20305 return t.getName(); 20306 } 20307 20308 /** 20309 * Slice 58 / 59 — attempt to expand a {@code SELECT *} or 20310 * {@code SELECT alias.*} result column using the catalog exposed via 20311 * {@link NameBindingProvider#getRelationColumnNames(TTable)}. 20312 * 20313 * <p>Slice 58 supported only single-base-table FROM. Slice 59 lifts 20314 * the multi-relation case to JOIN forms (single top-level TJoin with 20315 * explicit JOIN clauses). Comma-FROM stays rejected by 20316 * {@code buildRelations}. 20317 * 20318 * <p>Returns {@link StarExpansionKind#EXPANDED} with one 20319 * {@link OutputColumn} per catalog-declared column on success. 20320 * Otherwise returns a reasoned rejection so the caller can throw a 20321 * shape-specific {@link SemanticIRBuildException}. 20322 */ 20323 /** 20324 * True when some FROM operand of {@code select} is a parenthesized / nested 20325 * {@code <joined_table>}: a {@link TJoin} reached via {@link TJoin#getJoin()} 20326 * (nested driver) or {@link TJoinItem#getJoin()} (nested right operand) whose 20327 * {@code getTable()} is null. Used to scope the MULTI_RELATION_FROM 20328 * star-expansion degrade to the genuinely-analyzable nested-join shape — a 20329 * null FROM table WITHOUT a nested join, or a null top-level {@link TJoin}, 20330 * is a broken invariant and stays fatal. 20331 */ 20332 private static boolean hasNestedJoinOperand(TSelectSqlStatement select) { 20333 if (select == null || select.joins == null) { 20334 return false; 20335 } 20336 for (int j = 0; j < select.joins.size(); j++) { 20337 TJoin topJoin = select.joins.getJoin(j); 20338 if (topJoin == null) { 20339 continue; 20340 } 20341 if (topJoin.getTable() == null && topJoin.getJoin() != null) { 20342 return true; 20343 } 20344 TJoinItemList items = topJoin.getJoinItems(); 20345 for (int i = 0; items != null && i < items.size(); i++) { 20346 TJoinItem item = items.getJoinItem(i); 20347 if (item != null && item.getTable() == null && item.getJoin() != null) { 20348 return true; 20349 } 20350 } 20351 } 20352 return false; 20353 } 20354 20355 private static StarExpansionResult tryExpandStar(TResultColumn rc, 20356 TSelectSqlStatement select, 20357 NameBindingProvider provider, 20358 boolean isPredicateBody, 20359 String stmtName) { 20360 if (isPredicateBody) { 20361 // Defensive: the active rejection lives in 20362 // preflightPredicateSubqueryShape (~line 3880) and fires before 20363 // this code runs. Slice-24 EXISTS-with-* tests pin that path. 20364 return StarExpansionResult.reject(StarExpansionKind.PREDICATE_BODY_GUARD); 20365 } 20366 // Slice 59: reject star expansion in synthetic body contexts. 20367 // Scalar-subquery bodies must project exactly one column; 20368 // set-op-branch bodies must keep per-branch column-count parity; 20369 // predicate-subquery bodies are constant or column-ref shapes 20370 // (slice 23/24/27). Multi-column expansion would corrupt all 20371 // three. The preflight at SemanticIRBuilder.java:1007 only 20372 // checks AST result-column count/name, not "*", so without this 20373 // guard a catalog-equipped scalar body `SELECT * FROM small` 20374 // would silently emit multiple OutputColumns. 20375 if (stmtName != null 20376 && (isScalarSyntheticName(stmtName) 20377 || isSetOpBranchSyntheticName(stmtName) 20378 || isPredicateSubquerySyntheticName(stmtName))) { 20379 return StarExpansionResult.reject( 20380 StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null, 20381 "star expansion is not supported inside synthetic body '" 20382 + stmtName + "' (scalar, set-op branch, or predicate body)"); 20383 } 20384 // Extract qualifier (empty string for bare `*`, alias/name for `t.*`). 20385 String qualifier = ""; 20386 TExpression expr = rc.getExpr(); 20387 if (expr != null && expr.getObjectOperand() != null) { 20388 String q = expr.getObjectOperand().getTableString(); 20389 if (q != null && !q.isEmpty()) { 20390 qualifier = q; 20391 } 20392 } 20393 // FROM-clause shape gate. Slice 59 supported a single top-level 20394 // TJoin with zero or more explicit JOIN clauses. Slice 62 lifts 20395 // comma-FROM to multi-TJoin: walk every top-level TJoin in 20396 // {@code select.joins} (a comma-FROM list parses as multiple 20397 // top-level TJoins) and accumulate every relation in FROM order. 20398 if (select.joins == null || select.joins.size() == 0) { 20399 return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM); 20400 } 20401 List<TTable> fromRelations = new ArrayList<>(); 20402 for (int j = 0; j < select.joins.size(); j++) { 20403 TJoin topJoin = select.joins.getJoin(j); 20404 if (topJoin == null) { 20405 return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM); 20406 } 20407 TTable leftTable = topJoin.getTable(); 20408 if (leftTable == null) { 20409 return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM); 20410 } 20411 fromRelations.add(leftTable); 20412 TJoinItemList items = topJoin.getJoinItems(); 20413 if (items == null) continue; 20414 for (int i = 0; i < items.size(); i++) { 20415 TJoinItem item = items.getJoinItem(i); 20416 TTable rightTable = item.getTable(); 20417 if (rightTable == null) { 20418 return StarExpansionResult.reject(StarExpansionKind.MULTI_RELATION_FROM); 20419 } 20420 fromRelations.add(rightTable); 20421 } 20422 } 20423 // Slice 65 / 66: bare `*` over a USING / NATURAL JOIN collapses 20424 // merged keys. For each FROM relation in order, walk catalog/ 20425 // in-scope columns; emit one OutputColumn per merged key (sources 20426 // = merged ref list) and one per non-merged column. Qualified 20427 // `t.*` is unaffected (single-relation path, no merged-key dedup). 20428 if (qualifier.isEmpty() 20429 && (hasUsingInFromClause(select) 20430 || hasNaturalJoinMergedKeysInFromClause(select, provider))) { 20431 return expandBareStarOverUsing(select, provider, fromRelations); 20432 } 20433 // Qualified `t.*`: pick the (unique) FROM relation whose 20434 // effective alias matches the qualifier (case-insensitive). 20435 // Effective alias = `alias != null && !alias.isEmpty() ? alias : 20436 // tableName`, matching buildRelation at line 5649. Slice 58's 20437 // alias-OR-name match (line 5785 before slice 59) is replaced 20438 // here so `SELECT employees.* FROM employees e` rejects 20439 // (qualifier=`employees` ≠ effective alias `e`), consistent 20440 // with standard SQL correlation-name semantics. 20441 if (!qualifier.isEmpty()) { 20442 List<TTable> matches = new ArrayList<>(); 20443 for (TTable t : fromRelations) { 20444 String ea = effectiveAliasOf(t); 20445 if (ea != null && ea.equalsIgnoreCase(qualifier)) { 20446 matches.add(t); 20447 } 20448 } 20449 if (matches.isEmpty()) { 20450 return StarExpansionResult.reject( 20451 StarExpansionKind.QUALIFIER_NOT_FOUND, qualifier, null); 20452 } 20453 if (matches.size() > 1) { 20454 StringBuilder names = new StringBuilder(); 20455 for (int i = 0; i < matches.size(); i++) { 20456 if (i > 0) names.append(", "); 20457 names.append(effectiveAliasOf(matches.get(i))); 20458 } 20459 return StarExpansionResult.reject( 20460 StarExpansionKind.QUALIFIER_AMBIGUOUS, qualifier, 20461 "matches " + matches.size() + " FROM-clause relations: " 20462 + names); 20463 } 20464 return expandSingleRelation(matches.get(0), provider, qualifier); 20465 } 20466 // Bare `*`: expand every FROM relation in order. Fail fast on 20467 // the first relation that does not satisfy the slice-58 rules 20468 // (binding kind TABLE, catalog declares columns); the caller 20469 // sees the per-relation rejection kind and detail. No partial 20470 // outputs are returned. 20471 List<OutputColumn> all = new ArrayList<>(); 20472 for (TTable t : fromRelations) { 20473 StarExpansionResult one = expandSingleRelation(t, provider, ""); 20474 if (one.kind != StarExpansionKind.EXPANDED) { 20475 return one; 20476 } 20477 all.addAll(one.columns); 20478 } 20479 return StarExpansionResult.expanded(all); 20480 } 20481 20482 /** 20483 * Slice 58 / 59 — pure per-relation star expander. Applies the 20484 * base-table-only + catalog rules and builds one 20485 * {@link OutputColumn} per catalog-declared column with a 20486 * {@link ColumnRef} whose {@code relationAlias} is the effective 20487 * alias of {@code target}. Returns {@link StarExpansionKind#EXPANDED} 20488 * on success, otherwise a tuned rejection. 20489 * 20490 * <p>The {@code qualifier} parameter is the SQL-written qualifier 20491 * for qualified `t.*` (empty for bare `*`); it is plumbed back into 20492 * the rejection result so the caller can include it in 20493 * user-visible diagnostics. 20494 */ 20495 private static StarExpansionResult expandSingleRelation(TTable target, 20496 NameBindingProvider provider, 20497 String qualifier) { 20498 // The TTable's tableType cannot distinguish CTE from base table — 20499 // CTE references arrive as ETableSource.objectname. Use the 20500 // provider's bindRelation to get the resolved RelationKind. 20501 RelationBinding binding = provider.bindRelation(target); 20502 if (binding == null) { 20503 String diagAlias = effectiveAliasOf(target); 20504 return StarExpansionResult.reject( 20505 StarExpansionKind.NON_BASE_TABLE_RELATION, qualifier, 20506 "FROM source '" 20507 + (diagAlias != null ? diagAlias : "<unnamed>") 20508 + "' could not be bound (only base tables, in-scope CTEs, and FROM-subqueries are supported)"); 20509 } 20510 RelationKind kind = binding.getKind(); 20511 if (kind == RelationKind.TABLE) { 20512 // Slice 58 catalog-backed path. Returns null when no 20513 // catalog, when the catalog doesn't declare this table, 20514 // or when the table has no columns. 20515 List<String> columnNames = provider.getRelationColumnNames(target); 20516 if (columnNames == null || columnNames.isEmpty()) { 20517 String diagAlias = effectiveAliasOf(target); 20518 return StarExpansionResult.reject( 20519 StarExpansionKind.NO_CATALOG_OR_UNKNOWN_TABLE, qualifier, 20520 diagAlias); 20521 } 20522 return buildExpansionFromColumnNames(target, columnNames); 20523 } 20524 if (kind == RelationKind.CTE) { 20525 // Slice 60 + Slice 103: key by EFFECTIVE ALIAS in the consuming 20526 // SELECT, not by CTE name. This avoids a collision when a 20527 // FROM-subquery alias equals a visible CTE name and the 20528 // CTE is referenced under a different alias (codex 20529 // diff-review): `WITH a AS (...) SELECT c.*, a.* FROM a c 20530 // JOIN (SELECT ...) a ON ...` — both 'a' (CTE) and 'a' 20531 // (subquery alias) live in the FROM clause; effective 20532 // aliases are 'c' and 'a' respectively, so per-relation 20533 // entries cannot overwrite each other. 20534 // 20535 // Slice 103 — explicit CTE column lists (WITH a(x, y) AS ...) 20536 // are no longer rejected here. The slice-102 rename helper now 20537 // runs on the SELECT-side CTE walker too; the in-scope map 20538 // populated by addRelationToInScopeMap reads from 20539 // ctePublishedColumns, which the helper has populated with the 20540 // renamed names. Star expansion just falls through to the 20541 // in-scope lookup below; the renamed list comes back. 20542 String lookupKey = effectiveAliasLowerCaseOrNull(target); 20543 List<String> cteColumns = (lookupKey == null) ? null 20544 : provider.getInScopeRelationColumns().get(lookupKey); 20545 if (cteColumns == null || cteColumns.isEmpty()) { 20546 String diagAlias = effectiveAliasOf(target); 20547 return StarExpansionResult.reject( 20548 StarExpansionKind.NO_INSCOPE_RELATION_COLUMNS, 20549 qualifier, 20550 "CTE '" + diagAlias + "' has no published columns in " 20551 + "the in-scope map (builder invariant: the " 20552 + "CTE body should have been built and " 20553 + "registered before this consumer ran)"); 20554 } 20555 return buildExpansionFromColumnNames(target, cteColumns); 20556 } 20557 if (kind == RelationKind.SUBQUERY) { 20558 // Slice 60: same effective-alias keying as the CTE branch 20559 // above (codex diff-review). For a subquery the effective 20560 // alias IS the alias the SQL writer wrote (preflight 20561 // rejects anonymous subqueries), so this branch is also 20562 // unambiguous under the alias-collision example. 20563 String lookupKey = effectiveAliasLowerCaseOrNull(target); 20564 List<String> subColumns = (lookupKey == null) ? null 20565 : provider.getInScopeRelationColumns().get(lookupKey); 20566 if (subColumns == null || subColumns.isEmpty()) { 20567 String diagAlias = effectiveAliasOf(target); 20568 return StarExpansionResult.reject( 20569 StarExpansionKind.NO_INSCOPE_RELATION_COLUMNS, 20570 qualifier, 20571 "FROM-clause subquery '" 20572 + (diagAlias != null ? diagAlias : "<unnamed>") 20573 + "' has no published columns in the in-scope " 20574 + "map (builder invariant: the subquery body " 20575 + "should have been extracted and registered " 20576 + "before this consumer ran)"); 20577 } 20578 return buildExpansionFromColumnNames(target, subColumns); 20579 } 20580 // OUTER_REFERENCE / UNION / UNKNOWN: keep the slice-58 / 59 20581 // rejection contract. None of these arrive on a slice-60 20582 // FROM-clause relation via the current builder paths 20583 // (OUTER_REFERENCE bindings live only on RelationSource for 20584 // correlated scalar lookup; UNION is a set-op branch concept, 20585 // not a FROM-clause relation). Defensive catch-all. 20586 String diagAlias = effectiveAliasOf(target); 20587 String detail; 20588 switch (kind) { 20589 case OUTER_REFERENCE: 20590 detail = "OUTER_REFERENCE star expansion is not supported (relation '" 20591 + diagAlias + "')"; 20592 break; 20593 case UNION: 20594 case UNKNOWN: 20595 default: 20596 detail = "FROM source '" + diagAlias 20597 + "' must be a base table, CTE, or FROM-subquery (got kind=" 20598 + kind + ")"; 20599 break; 20600 } 20601 return StarExpansionResult.reject( 20602 StarExpansionKind.NON_BASE_TABLE_RELATION, qualifier, detail); 20603 } 20604 20605 /** 20606 * Slice 60 — shared helper that turns a column-name list into the 20607 * star-expansion OutputColumn list with one {@link ColumnRef} per 20608 * column whose {@code relationAlias} is the effective alias of the 20609 * target table (alias if present, else the table name). Used by all 20610 * three slice-58 / 59 / 60 paths. 20611 */ 20612 private static StarExpansionResult buildExpansionFromColumnNames( 20613 TTable target, List<String> columnNames) { 20614 String alias = effectiveAliasOf(target); 20615 List<OutputColumn> outputs = new ArrayList<>(columnNames.size()); 20616 for (String colName : columnNames) { 20617 ColumnRef ref = new ColumnRef(alias, colName); 20618 outputs.add(new OutputColumn( 20619 colName, 20620 /*derived=*/ false, 20621 /*aggregate=*/ false, 20622 Collections.singletonList(ref), 20623 /*windowSpec=*/ null)); 20624 } 20625 return StarExpansionResult.expanded(outputs); 20626 } 20627 20628 /** 20629 * Slice 65 — bare {@code *} over a USING JOIN: deduplicate the 20630 * merged key within each equivalence class. For each FROM relation 20631 * in order: 20632 * <ul> 20633 * <li>look up columns via the existing 20634 * {@link #lookupRelationColumnNames} (catalog + in-scope map); 20635 * reject with {@link StarExpansionKind#NO_CATALOG_OR_UNKNOWN_TABLE} 20636 * when null (we can't dedup without knowing what's there);</li> 20637 * <li>for each column, check 20638 * {@link UsingScope#entryContaining(String, TTable)}; 20639 * if a class contains this relation, emit a single 20640 * merged-source {@link OutputColumn} the first time the class 20641 * is seen and skip duplicates from later class members;</li> 20642 * <li>otherwise emit a plain single-source OutputColumn.</li> 20643 * </ul> 20644 * 20645 * <p>Duplicate-output guard fires ONLY when the conflicting names 20646 * involve a USING-merged entry (merged-vs-plain or two disconnected 20647 * merged classes for the same key). Plain duplicates from 20648 * non-USING multi-relation expansion remain admitted (slice-59 20649 * behavior). 20650 * 20651 * <p>Output column order is <b>left-table order with USING-key 20652 * dedup within each equivalence class</b>: the merged column 20653 * appears at the position of its first member in FROM order. E.g. 20654 * {@code a(id, k), b(k, name)} → {@code [id, k, name]}. 20655 * 20656 * <p>This is INTENTIONALLY DIFFERENT from the ANSI/PostgreSQL 20657 * physical column order (which puts USING columns first, then 20658 * remaining left, then remaining right — would yield 20659 * {@code [k, id, name]}). The Semantic IR is not a query 20660 * executor; the order it surfaces is a lineage-tracking 20661 * presentation choice. Left-table order: 20662 * <ol> 20663 * <li>matches the slice-65 roadmap resume protocol 20664 * ({@code docs/designs/sql-semantic-governance-unified-roadmap.md} 20665 * §13.1) which fixed this order before implementation;</li> 20666 * <li>keeps the merged column physically adjacent to its 20667 * left-side neighbors, matching how lineage tooling 20668 * traditionally renders combined JOIN output;</li> 20669 * <li>does not depend on USING-clause ordering (which is a 20670 * syntactic choice, not a semantic one).</li> 20671 * </ol> 20672 * Codex diff-review round 5 flagged this as P2 (non-ANSI). The 20673 * choice was confirmed in plan-review and is locked by 20674 * {@code bareStarOverUsingLeftPositionPreserved} so any future 20675 * change to ANSI order is a deliberate observable contract 20676 * change, not a silent fix. 20677 */ 20678 private static StarExpansionResult expandBareStarOverUsing( 20679 TSelectSqlStatement select, 20680 NameBindingProvider provider, 20681 List<TTable> fromRelations) { 20682 UsingScope scope = provider.getUsingScope(); 20683 if (scope.isEmpty()) { 20684 // The slice-65 caller checks hasUsingInFromClause before 20685 // routing here, so an empty scope here means buildUsingScope 20686 // computed empty entries (unreachable in practice). Fall 20687 // back to a generic reject so the caller surfaces a 20688 // structured diagnostic. 20689 return StarExpansionResult.reject( 20690 StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null, 20691 "bare * over JOIN ... USING reached the merged-key expander " 20692 + "with an empty UsingScope (builder invariant failure)"); 20693 } 20694 LinkedHashSet<String> emittedNamesLC = new LinkedHashSet<>(); 20695 Set<String> mergedNamesLC = new HashSet<>(); 20696 java.util.IdentityHashMap<UsingScope.EquivalenceClass, Boolean> emittedClasses = 20697 new java.util.IdentityHashMap<>(); 20698 List<OutputColumn> outputs = new ArrayList<>(); 20699 for (TTable t : fromRelations) { 20700 // Slice 103 lifted the explicit-CTE-column-list deferral: 20701 // populateUsingJoinRefs no longer rejects, and 20702 // lookupRelationColumnNames returns the renamed names from 20703 // the in-scope map (populated by the slice-102 rename 20704 // helper that the SELECT-side CTE walker now invokes). 20705 List<String> cols = lookupRelationColumnNames(t, provider); 20706 if (cols == null) { 20707 return StarExpansionResult.reject( 20708 StarExpansionKind.NO_CATALOG_OR_UNKNOWN_TABLE, null, 20709 effectiveAliasOf(t)); 20710 } 20711 for (String c : cols) { 20712 if (c == null) continue; 20713 String keyLC = c.toLowerCase(Locale.ROOT); 20714 UsingScope.MergedKeyEntry entry = scope.entryContaining(keyLC, t); 20715 if (entry != null) { 20716 // USING-merged column. Dedup per class. 20717 if (emittedClasses.containsKey(entry.getEquivClass())) { 20718 continue; 20719 } 20720 emittedClasses.put(entry.getEquivClass(), Boolean.TRUE); 20721 OutputColumn cand = new OutputColumn( 20722 c, /*derived=*/ false, /*aggregate=*/ false, 20723 entry.getSources(), /*windowSpec=*/ null); 20724 StarExpansionResult dup = appendMergedAwareOrReject( 20725 outputs, emittedNamesLC, mergedNamesLC, cand, /*isMerged=*/ true); 20726 if (dup != null) return dup; 20727 } else { 20728 // Plain column. Slice-59 behavior: duplicate plain 20729 // names are admitted. Codex round-5: the merged-aware 20730 // guard fires only when ONE side is merged. 20731 OutputColumn cand = new OutputColumn( 20732 c, /*derived=*/ false, /*aggregate=*/ false, 20733 Collections.singletonList(new ColumnRef(effectiveAliasOf(t), c)), 20734 /*windowSpec=*/ null); 20735 StarExpansionResult dup = appendMergedAwareOrReject( 20736 outputs, emittedNamesLC, mergedNamesLC, cand, /*isMerged=*/ false); 20737 if (dup != null) return dup; 20738 } 20739 } 20740 } 20741 return StarExpansionResult.expanded(outputs); 20742 } 20743 20744 /** 20745 * Slice 65 — duplicate-output helper for 20746 * {@link #expandBareStarOverUsing}. Fires the merged-vs-non-merged 20747 * collision guard. Returns a {@link StarExpansionResult} when the 20748 * caller should reject; returns {@code null} when the candidate is 20749 * appended successfully. 20750 */ 20751 private static StarExpansionResult appendMergedAwareOrReject( 20752 List<OutputColumn> outputs, 20753 LinkedHashSet<String> emittedNamesLC, 20754 Set<String> mergedNamesLC, 20755 OutputColumn cand, 20756 boolean isMerged) { 20757 String nameLC = cand.getName().toLowerCase(Locale.ROOT); 20758 boolean alreadyEmitted = emittedNamesLC.contains(nameLC); 20759 boolean alreadyMerged = mergedNamesLC.contains(nameLC); 20760 // Reject only when at least one side is a USING-merged entry. 20761 // Plain-vs-plain duplicates remain admitted (slice-59 behavior). 20762 if (alreadyEmitted && (isMerged || alreadyMerged)) { 20763 return StarExpansionResult.reject( 20764 StarExpansionKind.SYNTHETIC_BODY_CONTEXT, null, 20765 "bare * over JOIN ... USING produces ambiguous output " 20766 + "column '" + cand.getName() + "': a USING-merged " 20767 + "entry and a same-named column from outside the " 20768 + "USING equivalence class collide (or two disconnected " 20769 + "USING classes share the same key name); qualify " 20770 + "with t.* per relation or rename a column to " 20771 + "disambiguate"); 20772 } 20773 emittedNamesLC.add(nameLC); 20774 if (isMerged) mergedNamesLC.add(nameLC); 20775 outputs.add(cand); 20776 return null; 20777 } 20778 20779 /** 20780 * Slice 60 — read the published column names for an already-built 20781 * statement (CTE body or FROM-subquery body) from its 20782 * {@link StatementGraph#getOutputColumns()}. Used by {@code build()} 20783 * and {@code extractFromSubqueriesAsStatements} to populate the 20784 * in-scope map before each consuming SELECT's 20785 * {@code buildOutputColumns} runs. 20786 */ 20787 private static List<String> outputColumnNames(StatementGraph body) { 20788 List<OutputColumn> cols = body.getOutputColumns(); 20789 List<String> names = new ArrayList<>(cols.size()); 20790 for (OutputColumn c : cols) names.add(c.getName()); 20791 return Collections.unmodifiableList(names); 20792 } 20793 20794 /** 20795 * Slice 60 — effective alias of a TTable lower-cased, or null when 20796 * the table has neither an alias nor a name. The alias-collision 20797 * fix (codex diff-review) replaced CTE-name / subquery-alias 20798 * keying with effective-alias keying; this helper centralises the 20799 * lookup-key computation. 20800 */ 20801 private static String effectiveAliasLowerCaseOrNull(TTable t) { 20802 String alias = effectiveAliasOf(t); 20803 if (alias == null || alias.isEmpty()) return null; 20804 return alias.toLowerCase(Locale.ROOT); 20805 } 20806 20807 /** 20808 * Slice 60 — build a per-consumer effective-alias-keyed map of 20809 * "FROM-clause relation alias → published column names" by walking 20810 * the consumer's direct FROM/JOIN list (single top-level TJoin, 20811 * left table + each joinItem.getTable()). 20812 * 20813 * <p>Each CTE-bound relation contributes its effective alias → 20814 * {@code ctePublishedColumns.get(cteName.toLowerCase())}. Each 20815 * FROM-subquery contributes its alias → {@code 20816 * outputColumnNames(stmts.get(subqueryAliasToIndex.get(alias)))}. 20817 * Base-table relations are skipped because their star expansion 20818 * uses the catalog path (TSQLEnv); adding them here would force 20819 * dialect-specific catalog walks before catalog access is required. 20820 * 20821 * <p>The codex diff-review found that a single name-keyed map 20822 * collides when a FROM-subquery alias equals a visible CTE name 20823 * (`WITH a AS (...) ... FROM a c JOIN (SELECT ...) a ...`). Keying 20824 * by effective alias (which is unique per FROM clause — 20825 * {@link #preflightDirectFromList} rejects duplicates) closes the 20826 * collision class. 20827 * 20828 * @param consumer the SELECT whose FROM list to walk 20829 * @param consumerProvider provider used only for bindRelation 20830 * (CTE vs TABLE discrimination) 20831 * @param ctePublishedColumns CTE-name → columns lookup 20832 * populated as CTE bodies are built 20833 * @param subqueryAliasToIndex this consumer's own subquery alias 20834 * → stmts index lookup 20835 * @param stmts already-built statement list 20836 * @return mutable effective-alias-keyed in-scope map for this 20837 * consumer; callers wrap it via 20838 * {@code provider.withInScopeRelationColumns(map)} 20839 */ 20840 private static Map<String, List<String>> buildEffectiveAliasInScopeMap( 20841 TSelectSqlStatement consumer, 20842 NameBindingProvider consumerProvider, 20843 Map<String, List<String>> ctePublishedColumns, 20844 Map<String, Integer> subqueryAliasToIndex, 20845 List<StatementGraph> stmts) { 20846 Map<String, List<String>> result = new HashMap<>(); 20847 if (consumer.joins == null) return result; 20848 for (TJoin join : consumer.joins) { 20849 addRelationToInScopeMap(join.getTable(), consumerProvider, 20850 ctePublishedColumns, subqueryAliasToIndex, stmts, result); 20851 TJoinItemList items = join.getJoinItems(); 20852 if (items == null) continue; 20853 for (int i = 0; i < items.size(); i++) { 20854 TJoinItem item = items.getJoinItem(i); 20855 if (item == null) continue; 20856 addRelationToInScopeMap(item.getTable(), consumerProvider, 20857 ctePublishedColumns, subqueryAliasToIndex, stmts, result); 20858 } 20859 } 20860 return result; 20861 } 20862 20863 private static void addRelationToInScopeMap( 20864 TTable t, 20865 NameBindingProvider consumerProvider, 20866 Map<String, List<String>> ctePublishedColumns, 20867 Map<String, Integer> subqueryAliasToIndex, 20868 List<StatementGraph> stmts, 20869 Map<String, List<String>> result) { 20870 if (t == null) return; 20871 // Slice 137 — a PIVOT / UNPIVOT source can itself be a FROM-subquery 20872 // (or CTE): `FROM (SELECT ...) [alias] PIVOT(...)`. Unwrap the 20873 // pivoted_table to its source relation so the source's published 20874 // columns are registered under the SAME effective alias that 20875 // lookupRelationColumnNames resolves in expandPivot/UnpivotBareStar. 20876 // The source-subquery alias is already registered in 20877 // subqueryAliasToIndex by the slice-136 processDirectSubqueryTable 20878 // pre-pass (which performs the same unwrap), so the subquery branch 20879 // below finds its statement index. A base-table pivot source falls 20880 // through unchanged: bindRelation returns TABLE → no in-scope entry, 20881 // and the slice-133 catalog path (getRelationColumnNames) handles it. 20882 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.pivoted_table 20883 && t.getPivotedTable() != null 20884 && !t.getPivotedTable().getRelations().isEmpty()) { 20885 TTable src = t.getPivotedTable().getRelations().get(0); 20886 if (src == null) return; 20887 t = src; 20888 } 20889 String key = effectiveAliasLowerCaseOrNull(t); 20890 if (key == null) return; 20891 if (t.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) { 20892 Integer idx = subqueryAliasToIndex.get(key); 20893 if (idx != null) { 20894 result.put(key, outputColumnNames(stmts.get(idx))); 20895 } 20896 return; 20897 } 20898 // objectname (base-table OR CTE reference). Use bindRelation 20899 // to discriminate; base tables don't need an in-scope entry 20900 // (slice 58 catalog path handles them via getRelationColumnNames). 20901 RelationBinding b = consumerProvider.bindRelation(t); 20902 if (b == null) return; 20903 if (b.getKind() == RelationKind.CTE) { 20904 String cteName = t.getName(); 20905 if (cteName == null) return; 20906 List<String> cols = ctePublishedColumns.get(cteName.toLowerCase(Locale.ROOT)); 20907 if (cols != null && !cols.isEmpty()) { 20908 result.put(key, cols); 20909 } 20910 } 20911 // For TABLE, OUTER_REFERENCE, UNION, UNKNOWN bindings the 20912 // in-scope map is intentionally not populated; the 20913 // base-table catalog path or rejection path applies. 20914 } 20915 20916 /** 20917 * Build the {@link OutputColumn} list. Slice 4 lifts the 20918 * simple-object-name / single-source restriction: any expression with at 20919 * least one column reference is accepted, and the column is marked 20920 * {@link OutputColumn#isDerived()} when the expression is anything 20921 * other than a direct column reference. Slice 61 also admits 20922 * canonical constant-only projections (zero column refs) outside 20923 * scalar-subquery bodies, using alias-or-expression text naming. 20924 */ 20925 private static List<OutputColumn> buildOutputColumns(TSelectSqlStatement select, 20926 NameBindingProvider provider, 20927 boolean allowScalarProjectionSubqueries, 20928 boolean allowWindowProjection, 20929 boolean isPredicateBody, 20930 String stmtName) { 20931 TResultColumnList rcl = select.getResultColumnList(); 20932 if (rcl == null || rcl.size() == 0) { 20933 throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.SELECT_NO_PROJECTED_COLUMNS, "SELECT has no projected columns", select)); 20934 } 20935 // Slice 23/24/27: predicate-body short-circuit. The preflight 20936 // (§4.4 / slice-24 §4.1.1 / slice-27 §4.1) already validated that 20937 // the inner SELECT projects exactly one column, of an admitted 20938 // shape — constant (slice 23), simple column ref (slice 24), or 20939 // expression / function call / CASE / aggregate over inner 20940 // columns (slice 27). Discriminate on the shape: 20941 // 20942 // - Constant: bypass the regular result-column loop (which would 20943 // reject empty-source non-aggregate projections via the 20944 // "no column refs" guard at line ~4397) and emit one synthetic 20945 // OutputColumn with empty sources. The synthesised name 20946 // `<predicate_subquery_<i>>_const_0` guarantees no collision 20947 // with real column names. 20948 // 20949 // - Slice-24 column ref (simple_object_name_t with name): fall 20950 // through to the normal loop; effectiveOutputName(rc) returns 20951 // the column name. OutputColumn carries name, derived=false, 20952 // aggregate=false, sources=[ColumnRef(...)]. 20953 // 20954 // - Slice-27 expression / function / CASE / aggregate without 20955 // alias: synthesise the OutputColumn here. The normal loop's 20956 // {@link #effectiveOutputName} would throw on rc with neither 20957 // alias nor column name (a slice-6 invariant for OUTER 20958 // projections); for predicate bodies the OutputColumn name is 20959 // internal scaffolding only — no consumer references it 20960 // externally — so a synthetic name is sound. For aggregate-over- 20961 // constants (COUNT(*), SUM(1)) sources is empty and aggregate=true 20962 // matches the line-4397 guard's intent. The slice-24 projector 20963 // pass walks OutputColumn.sources to base-column terminals and 20964 // emits JOIN canonical edges (zero terminals → zero edges, 20965 // multi-source → multiple edges). 20966 if (isPredicateBody) { 20967 TResultColumn rc0 = rcl.getResultColumn(0); 20968 if (rc0.getExpr() != null && isConstantExpression(rc0.getExpr())) { 20969 String synthName = (stmtName != null ? stmtName : "<predicate_subquery_?>") 20970 + "_const_0"; 20971 return Collections.singletonList(new OutputColumn( 20972 synthName, /*derived=*/ true, /*aggregate=*/ false, 20973 Collections.<ColumnRef>emptyList(), /*windowSpec=*/ null)); 20974 } 20975 // Slice 27 + Slice 32: synthesise the OutputColumn for any 20976 // slice-27/31-admitted predicate-body projection EXCEPT the 20977 // slice-24 simple_object_name_t shape. Slice 27 fired this 20978 // branch only when both alias AND columnNameOnly were absent 20979 // (missingName=true); slice 32 widens it to also fire when 20980 // alias is present, so aliased Oracle / MSSQL plain 20981 // {@code LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY ...) AS lst} 20982 // is admitted (the slice-31 boundary lifted by slice 32). 20983 // 20984 // The simple_object_name_t exclusion is intentional. That 20985 // shape MUST keep falling through to the normal loop, where 20986 // {@link #effectiveOutputName} returns the column name (or 20987 // alias if present), {@code derived=false}, and 20988 // {@code sources=[ColumnRef(...)]} — the slice-24 baseline. 20989 // Per {@code TResultColumn.getColumnNameOnly()}, only 20990 // {@code simple_object_name_t}, {@code typecast_t}, and 20991 // {@code sqlserver_proprietary_column_alias_t} populate 20992 // columnNameOnly; function_t / case_t / pure-binary all 20993 // return empty, so the cascade below is alias > _proj_0 20994 // (no columnNameOnly intermediate). 20995 if (rc0.getExpr() != null 20996 && rc0.getExpr().getExpressionType() != EExpressionType.simple_object_name_t) { 20997 String alias = rc0.getColumnAlias(); 20998 String name; 20999 if (alias != null && !alias.isEmpty()) { 21000 // Slice 32 widening: aliased projection. Use the 21001 // alias as the OutputColumn name. 21002 name = alias; 21003 } else { 21004 // Slice 27 carryover: unaliased non-column-ref 21005 // projection. Synthesise a stable name. The synth 21006 // name is used internally by 21007 // {@link gudusoft.gsqlparser.ir.semantic.diff.SemanticIRProjector} 21008 // (line ~161 — BFS start key keyed by 21009 // {@code stmtOutputKey(idx, out.getName())}) to walk 21010 // predicate-body lineage to base columns; uniqueness 21011 // within the single-column predicate body is 21012 // sufficient for that walk. {@code _proj_0} is also 21013 // exposed by the JSON exporter but is not externally 21014 // referenced by callers — only the inner JOIN 21015 // canonical edges (target.column omitted; role=JOIN) 21016 // are visible to consumers. 21017 name = (stmtName != null ? stmtName : "<predicate_subquery_?>") 21018 + "_proj_0"; 21019 } 21020 boolean aggregate = isAggregateFunction(rc0.getExpr()); 21021 // Slice 43 / 44: PG (slice 43) and Snowflake (slice 44) 21022 // hypothetical-set ordered-set aggregates ({@code rank} / 21023 // {@code dense_rank} / {@code percent_rank} / 21024 // {@code cume_dist}) via direct {@code fn.getWithinGroup()} 21025 // attachment do not satisfy 21026 // {@link #isHypotheticalSetWithinGroupCall} (which requires 21027 // a non-null windowDef) and are not in the regular 21028 // {@link #AGGREGATE_FUNCTION_NAMES} whitelist. Inside the 21029 // predicate-body branch they are admitted as aggregates 21030 // when the slice-43 / 44 vendor-gated shape predicate 21031 // fires — contained here (NOT folded into 21032 // {@code isAggregateFunction}) so the carve-out cannot 21033 // accidentally lift the top-level PG / Snowflake case 21034 // (whose dlineage XML is structurally identical to the 21035 // OVER form — see Slice43Test / Slice44Test javadoc). 21036 if (!aggregate 21037 && rc0.getExpr().getExpressionType() == EExpressionType.function_t 21038 && isDirectAttachmentHypotheticalSetCall( 21039 rc0.getExpr().getFunctionCall(), select.dbvendor)) { 21040 aggregate = true; 21041 } 21042 // Slice 28: FILTER-aware collector excludes column refs inside 21043 // FILTER (WHERE ...) subtrees so OutputColumn.sources matches 21044 // dlineage's lineage-relationship view (FILTER predicate refs 21045 // absent from fdd / fdr). 21046 // Slice 31: also excludes column refs inside Oracle / MSSQL 21047 // {@code fn.windowDef.withinGroup} (the WITHIN GROUP ORDER BY) 21048 // so plain {@code LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY x.region)} 21049 // emits sources=[x.id] only — matching dlineage's omission of the 21050 // WITHIN GROUP ORDER BY ref from {@code fdr clause="on"} sources 21051 // (probe Q1 in {@code /tmp/probe31}). Slice 32 reuses the 21052 // same collector unchanged. 21053 List<ColumnRef> sources = collectColumnRefsExcludingFilterAndWithinGroupClauses(rc0, provider); 21054 if (sources.isEmpty() && !aggregate) { 21055 // Non-aggregate with no inner column refs: should be 21056 // covered by the constant short-circuit above. If we 21057 // reach here, fall through to the normal loop's 21058 // line-4397 guard for a conservative tuned message. 21059 } else { 21060 return Collections.singletonList(new OutputColumn( 21061 name, /*derived=*/ true, aggregate, 21062 sources, /*windowSpec=*/ null)); 21063 } 21064 } 21065 // simple_object_name_t falls through (slice-24 carryover): 21066 // the normal loop produces derived=false / 21067 // sources=[ColumnRef(...)] using effectiveOutputName. 21068 } 21069 // Slice 19 (alias-bound PARTITION BY discriminator): the resolver 21070 // synthesises EXACT_MATCH bindings for PARTITION BY <name> when no 21071 // schema metadata is available (TableNamespace.resolveColumn 21072 // inferred_from_usage fallback), even when <name> is a SELECT-list 21073 // alias on a calculated expression. The discriminator is exposed 21074 // by NameBindingProvider#isCalculatedProjectionAliasFallback and 21075 // consulted in buildWindowPartitionRefs / buildWindowOrderRefs; 21076 // see Slice13Test#partitionByExpressionAliasIsRejectedAsAliasBound 21077 // and the shadowing-with-metadata companion. Slice 19 prefers 21078 // conservative rejection in the no-metadata case; with TSQLEnv 21079 // declaring the shadowed column, ColumnSource#hasDefiniteEvidence 21080 // returns true and the discriminator falls through. 21081 List<OutputColumn> out = new ArrayList<>(rcl.size()); 21082 for (int i = 0; i < rcl.size(); i++) { 21083 TResultColumn rc = rcl.getResultColumn(i); 21084 if (rc.getExpr() == null) { 21085 throw new SemanticIRBuildException(Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NULL_EXPRESSION, "result column " + rc + " has null expression", rc)); 21086 } 21087 EExpressionType type = rc.getExpr().getExpressionType(); 21088 // Slice 58: catalog-backed star expansion for a single base 21089 // table. Star projections were rejected by slices 1-57 with 21090 // "SELECT * / list expansions are deferred"; slice 58 lifts 21091 // the single-base-table case when a catalog is available via 21092 // NameBindingProvider#getRelationColumnNames(TTable). Bare 21093 // `*` and qualified `t.*` both arrive here as 21094 // simple_object_name_t with rc.getColumnNameOnly() == "*" 21095 // (probed; see slice-58 plan); the prior EExpressionType.list_t 21096 // branch is dead defense for stars in practice but stays in 21097 // case a future grammar variant routes them differently. 21098 String colNameOnly = rc.getColumnNameOnly(); 21099 if ("*".equals(colNameOnly) || type == EExpressionType.list_t) { 21100 StarExpansionResult exp = tryExpandStar(rc, select, provider, 21101 isPredicateBody, stmtName); 21102 switch (exp.kind) { 21103 case EXPANDED: 21104 out.addAll(exp.columns); 21105 continue; 21106 case PREDICATE_BODY_GUARD: 21107 // Defensive; preflightPredicateSubqueryShape at line ~3880 21108 // rejects SELECT * in EXISTS earlier with a tuned 21109 // message. This branch only fires if a future call 21110 // site enters buildOutputColumns with isPredicateBody 21111 // and a star still present. 21112 throw new SemanticIRBuildException( 21113 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_PREDICATE_BODY, 21114 "result column " + rc + " is a star expansion (SELECT *) " 21115 + "inside a predicate body; not supported yet", rc)); 21116 case SYNTHETIC_BODY_CONTEXT: 21117 // Slice 59: star expansion is rejected inside a 21118 // synthetic body (scalar-subquery / set-op-branch / 21119 // predicate-subquery). Multi-column expansion would 21120 // violate the body's shape contract; the slice-58 21121 // path could silently produce this for 21122 // catalog-equipped builds. 21123 throw new SemanticIRBuildException( 21124 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_SYNTHETIC_BODY, 21125 "result column " + rc + " is a star expansion (SELECT *); " 21126 + (exp.detail != null ? exp.detail 21127 : "star expansion is not supported inside a synthetic body"), rc)); 21128 case MULTI_RELATION_FROM: { 21129 // GSP R6 degrade (star-expansion-multi-relation-from- 21130 // degrade.md): tryExpandStar returns this kind when a 21131 // FROM operand is a parenthesized / nested <joined_table> 21132 // (TJoin.getTable() / TJoinItem.getTable() is null), so 21133 // the single-relation expander cannot pick one relation. 21134 // The parenthesized-join recursion (buildRelations / 21135 // buildJoinGraph) already builds a complete multi-relation 21136 // join graph for exactly this FROM shape, so failing here 21137 // would discard a fully-analyzable program. DEGRADE just 21138 // like the NO_CATALOG_OR_UNKNOWN_TABLE arm below: record a 21139 // non-fatal warning, emit the star as an UNEXPANDED MARKER 21140 // output column (no sources — never fabricate names), and 21141 // continue. Synthetic bodies are caught earlier as 21142 // SYNTHETIC_BODY_CONTEXT (set-op parity unaffected); 21143 // catalog-present flat expansion still returns EXPANDED. 21144 // 21145 // Scope the degrade to the genuinely-analyzable nested-join 21146 // shape: a null FROM table caused by a parenthesized 21147 // <joined_table> operand, for which buildRelations / 21148 // buildJoinGraph already built the graph. A 21149 // MULTI_RELATION_FROM with NO nested operand (the 21150 // defensive topJoin==null path, or a null FROM table with 21151 // no nested join) is a broken invariant with no graph to 21152 // preserve — keep it fatal rather than fabricating a 21153 // valid-looking program with a sourceless marker. 21154 if (!hasNestedJoinOperand(select)) { 21155 throw new SemanticIRBuildException( 21156 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_MULTI_RELATION_FROM, 21157 "result column " + rc + " is a star expansion (SELECT *); " 21158 + "FROM source could not be determined", rc)); 21159 } 21160 recordBuildWarning(Diagnostic.warn( 21161 DiagnosticCode.STAR_EXPANSION_MULTI_RELATION_FROM, 21162 "result column " + rc + " is a star expansion (SELECT *) " 21163 + "over a parenthesized / nested join — left " 21164 + "unexpanded (catalog-less degrade)", rc)); 21165 String mrStarQualifier = null; 21166 if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) { 21167 mrStarQualifier = rc.getExpr().getObjectOperand().getTableString(); 21168 } 21169 String mrStarName = (mrStarQualifier != null && !mrStarQualifier.isEmpty()) 21170 ? mrStarQualifier + ".*" : "*"; 21171 out.add(new OutputColumn(mrStarName, /*derived=*/ false, 21172 /*aggregate=*/ false, 21173 Collections.<ColumnRef>emptyList(), 21174 /*windowSpec=*/ null)); 21175 continue; 21176 } 21177 case NON_BASE_TABLE_RELATION: 21178 throw new SemanticIRBuildException( 21179 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_NON_BASE_TABLE, 21180 "result column " + rc + " is a star expansion (SELECT *); " 21181 + (exp.detail != null ? exp.detail 21182 : "FROM source must be a base table"), rc)); 21183 case QUALIFIER_NOT_FOUND: 21184 throw new SemanticIRBuildException( 21185 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_QUALIFIER_NOT_FOUND, 21186 "result column " + rc + " (qualified star " 21187 + (exp.qualifier == null ? "?" : exp.qualifier) 21188 + ".*) does not match any FROM-clause relation", rc)); 21189 case QUALIFIER_AMBIGUOUS: 21190 // Slice 59: 2+ FROM relations have the same 21191 // effective alias. Real SQL never reaches this 21192 // unless rejectDuplicateAliases:~5621 (case- 21193 // sensitive) allowed a case-only collision. 21194 throw new SemanticIRBuildException( 21195 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_QUALIFIER_AMBIGUOUS, 21196 "result column " + rc + " (qualified star " 21197 + (exp.qualifier == null ? "?" : exp.qualifier) 21198 + ".*) is ambiguous: " 21199 + (exp.detail != null ? exp.detail 21200 : "multiple FROM-clause relations match"), rc)); 21201 case NO_CATALOG_OR_UNKNOWN_TABLE: { 21202 // GSP R6 degrade (star-expansion-no-catalog-degrade.md): 21203 // a catalog-less SELECT * / t.* cannot be expanded to 21204 // real column names, but the join structure is fixed by 21205 // the FROM / ON clauses and does NOT depend on star 21206 // expansion. Rather than discarding the whole statement 21207 // graph (relations + join graph + scope), DEGRADE: 21208 // record a non-fatal warning and emit the star as an 21209 // UNEXPANDED MARKER output column (no sources — never 21210 // fabricate column names), then continue the build. 21211 // 21212 // Catalog-present expansion is unaffected (it returns 21213 // EXPANDED above). Synthetic bodies (scalar / set-op 21214 // branch / predicate) are caught earlier as 21215 // SYNTHETIC_BODY_CONTEXT and stay fatal, so set-op 21216 // column-count parity is never relaxed by this degrade. 21217 // Mirrors the NATURAL (NATURAL_CATALOG_REQUIRED) and 21218 // USING (COLUMN_BINDING_NON_EXACT) catalog-less degrades. 21219 String relationLabel; 21220 if (exp.qualifier != null && !exp.qualifier.isEmpty()) { 21221 relationLabel = exp.qualifier; 21222 } else if (exp.detail != null && !exp.detail.isEmpty()) { 21223 relationLabel = exp.detail; 21224 } else { 21225 relationLabel = "the FROM relation"; 21226 } 21227 recordBuildWarning(Diagnostic.warn( 21228 DiagnosticCode.STAR_EXPANSION_NO_CATALOG, 21229 "result column " + rc + " is a star expansion (SELECT *); " 21230 + "requires catalog with column declarations for " 21231 + relationLabel 21232 + " — left unexpanded (catalog-less degrade)", rc)); 21233 // Unexpanded-star marker: the star's own text (`*` or 21234 // `t.*`), no sources. Derived=false so the empty-source 21235 // RESULT_COLUMN_NO_COLUMN_REFS guard does not apply 21236 // (that guard targets derived no-ref expressions). 21237 String starQualifier = null; 21238 if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) { 21239 starQualifier = rc.getExpr().getObjectOperand().getTableString(); 21240 } 21241 String starName = (starQualifier != null && !starQualifier.isEmpty()) 21242 ? starQualifier + ".*" : "*"; 21243 out.add(new OutputColumn(starName, /*derived=*/ false, 21244 /*aggregate=*/ false, 21245 Collections.<ColumnRef>emptyList(), 21246 /*windowSpec=*/ null)); 21247 continue; 21248 } 21249 case EXPLICIT_CTE_COLUMN_LIST_DEFERRED: 21250 // Slice 103 lifted the explicit-CTE-column-list 21251 // deferral: the SELECT-side CTE walker now runs 21252 // the slice-102 rename helper, so the in-scope 21253 // map publishes the renamed columns and 21254 // expandSingleRelation returns EXPANDED instead 21255 // of falling into this arm. The case is kept 21256 // declared-but-unreached for API stability and 21257 // exhaustive-switch coverage (slice 71/72/82/86 21258 // /95/96/97/98/99/100/101/102 precedent). If a 21259 // future call path re-introduces the kind, the 21260 // throw still fires with a faithful diagnostic. 21261 throw new SemanticIRBuildException( 21262 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_EXPLICIT_CTE_COLUMN_LIST, 21263 "result column " + rc + " is a star expansion (SELECT *); " 21264 + (exp.detail != null ? exp.detail 21265 : "star expansion through an explicit CTE column list is deferred to a future slice"), rc)); 21266 case NO_INSCOPE_RELATION_COLUMNS: 21267 // Slice 60: builder invariant failure — a CTE 21268 // or FROM-subquery body was not registered in 21269 // the provider's in-scope-relation-columns map 21270 // before this consumer ran. User SQL cannot 21271 // reach this kind under normal build() 21272 // execution; reaching it indicates a missing 21273 // call site is not narrowing the provider 21274 // before invoking buildOutputColumns. 21275 throw new SemanticIRBuildException( 21276 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_NO_INSCOPE_RELATION_COLUMNS, 21277 "result column " + rc + " is a star expansion (SELECT *); " 21278 + (exp.detail != null ? exp.detail 21279 : "in-scope CTE/subquery column map is empty for this relation (builder invariant failure)"), rc)); 21280 // No `default`: switch is intentionally exhaustive 21281 // over StarExpansionKind. The post-switch throw 21282 // below is the actual runtime guard if a future 21283 // enum value is added without updating this 21284 // switch. 21285 } 21286 throw new SemanticIRBuildException( 21287 Diagnostic.error(DiagnosticCode.STAR_EXPANSION_UNHANDLED_KIND, 21288 "result column " + rc + " is a star expansion (SELECT *); " 21289 + "unhandled StarExpansionKind=" + exp.kind, rc)); 21290 } 21291 // Top-level scalar subquery in projection (slice 11). When the 21292 // caller permits it (allowScalarProjectionSubqueries=true), the 21293 // outer caller has already extracted the inner SELECT as its 21294 // own statement via extractScalarSubqueriesAsStatements; here 21295 // we just construct the OutputColumn shell with empty sources 21296 // and let emitLineageForStatement wire the 21297 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge. 21298 if (type == EExpressionType.subquery_t) { 21299 if (!allowScalarProjectionSubqueries) { 21300 throw new SemanticIRBuildException( 21301 Diagnostic.error(DiagnosticCode.NESTED_SCALAR_SUBQUERY_IN_PROJECTION, 21302 "nested scalar subquery in projection (inside another " 21303 + "scalar subquery body or FROM-clause subquery body) " 21304 + "is not supported yet", rc)); 21305 } 21306 String alias = rc.getColumnAlias(); 21307 if (alias == null || alias.isEmpty()) { 21308 throw new SemanticIRBuildException( 21309 Diagnostic.error(DiagnosticCode.SCALAR_SUBQUERY_ALIAS_REQUIRED, 21310 "scalar subquery projection must have an alias", rc)); 21311 } 21312 out.add(new OutputColumn(alias, /*derived=*/ true, 21313 /*aggregate=*/ false, 21314 Collections.<ColumnRef>emptyList(), 21315 /*windowSpec=*/ null)); 21316 continue; 21317 } 21318 // Slice 13: detect top-level window function before deep scans 21319 // so the embedded-window rejecter can identity-skip the 21320 // legitimate top-level window function call. 21321 boolean topLevelWindow = isTopLevelWindowProjection(rc.getExpr()); 21322 // Slice 33: detect Oracle / MSSQL plain WITHIN-GROUP-only 21323 // aggregate at the projection root. When admitted, the root 21324 // function carries fn.windowDef!=null but is the legitimate 21325 // top-level form — the slice-13 invariant rejecters 21326 // (isTopLevelWindowProjection / rejectWindowFunctions / 21327 // rejectEmbeddedWindowFunction) keep their strict wd!=null 21328 // check unchanged; this local boolean is what discriminates 21329 // them. The admission helper combines: 21330 // - isWithinGroupOnlyWindowDef (no OVER, no KEEP DENSE_RANK) 21331 // - explicit EDbVendor gate (Oracle / MSSQL only — mirrors 21332 // the slice-31 predicate-body gate at line ~3860) 21333 // - function name in AGGREGATE_FUNCTION_NAMES whitelist 21334 // PG / Snowflake / DB2 / SparkSQL produce direct fn.withinGroup 21335 // (windowDef=null) and never reach this admission helper; their 21336 // top-level WG already builds today via the normal aggregate 21337 // path (with pre-existing AGGREGATION_MISMATCH divergence on 21338 // the dlineage projector side that slice 33 deliberately does 21339 // not address — see the slice-30 rationale on 21340 // ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES for why a name-only 21341 // projector override is unsafe across the dual-form aggregates 21342 // SUM / MIN / MAX / LISTAGG that have OVER (PARTITION BY) 21343 // forms on Oracle). 21344 TFunctionCall slice33RootFn = rc.getExpr().getExpressionType() == EExpressionType.function_t 21345 ? rc.getExpr().getFunctionCall() 21346 : null; 21347 boolean slice33TopLevelWG = isAdmittedTopLevelWithinGroupAggregate( 21348 slice33RootFn, select.dbvendor); 21349 boolean slice35TopLevelDirectWG = isAdmittedTopLevelDirectWithinGroupAggregate( 21350 slice33RootFn, select.dbvendor); 21351 // Reject scalar subqueries embedded inside larger projection 21352 // expressions (slice 11 + codex round-2 MUST 7). Catches both 21353 // top-level subquery_t hidden under a wrapping expression 21354 // (e.g. UPPER((SELECT ...)) — though the parser sometimes 21355 // strips the wrap) AND predicate subqueries that don't surface 21356 // as subquery_t (EXISTS in projection, IN-projection). 21357 // Slice 9/10 deep-scan pattern. 21358 rejectEmbeddedSubqueryInProjection(rc.getExpr(), rc); 21359 // Slice 13: reject window functions embedded inside larger 21360 // projection expressions (e.g. `ROW_NUMBER() OVER (...) + 1`, 21361 // `UPPER(LAG(...) OVER (...))`). The helper identity-skips 21362 // the legitimate top-level window function call when 21363 // `topLevelWindow=true`. 21364 // 21365 // Slice 33: also identity-skip the top-level WITHIN-GROUP-only 21366 // aggregate root. TFunctionCall.acceptChildren preVisits the 21367 // root function (TFunctionCall.java:1528), so without 21368 // skipTopLevel=true the visitor would catch the slice-33- 21369 // admitted root (fn.windowDef!=null). Embedded WG inside 21370 // UPPER / CASE still rejects because the visitor finds a 21371 // non-root function whose windowDef!=null — the inner 21372 // function is not == identity to the root, so the skip 21373 // doesn't apply. 21374 rejectEmbeddedWindowFunction(rc.getExpr(), rc, topLevelWindow || slice33TopLevelWG); 21375 // Slice 33/35 fast path: WITHIN-GROUP-only aggregate — fall 21376 // through to the normal aggregate path. Oracle / MSSQL use the 21377 // windowDef attachment (slice 33); PostgreSQL direct attachment 21378 // is already on the normal aggregate path but shares the 21379 // unaliased expression-text fallback below (slice 35). 21380 if (slice33TopLevelWG || slice35TopLevelDirectWG) { 21381 // No special branch — fall through to the plain aggregate 21382 // / expression / column path below. 21383 } else if (topLevelWindow) { 21384 if (!allowWindowProjection) { 21385 throw new SemanticIRBuildException( 21386 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_AS_PROJECTION_NOT_SUPPORTED, 21387 "result column " + rc + " is a window function; not supported " 21388 + "inside this body (e.g. scalar-subquery body)", rc)); 21389 } 21390 out.add(buildWindowOutputColumn(rc, select, provider)); 21391 continue; 21392 } 21393 // Plain aggregate / expression / column path. The 21394 // rejectWindowFunctions call below is now defensive — the 21395 // top-level-window fast path above intercepts legitimate 21396 // windows, and rejectEmbeddedWindowFunction caught any 21397 // descendant window functions. 21398 // 21399 // Slice 33: skip rejectWindowFunctions for the slice-33- 21400 // admitted shape. The root function has windowDef!=null but 21401 // is the legitimate top-level form; calling 21402 // rejectWindowFunctions here would reject it via the 21403 // strict-wd!=null check (kept unchanged per slice-31 21404 // invariant). 21405 if (!slice33TopLevelWG && !slice35TopLevelDirectWG) { 21406 rejectWindowFunctions(rc.getExpr(), rc); 21407 } 21408 boolean derived = (type != EExpressionType.simple_object_name_t); 21409 boolean aggregate = isAggregateFunction(rc.getExpr()); 21410 // Slice 28: FILTER-aware collector excludes column refs inside 21411 // FILTER (WHERE ...) subtrees so OutputColumn.sources matches 21412 // dlineage's lineage-relationship view (FILTER predicate refs 21413 // absent from fdd / fdr). 21414 // Slice 31: also excludes column refs inside Oracle / MSSQL 21415 // {@code fn.windowDef.withinGroup} so plain WITHIN GROUP 21416 // aggregates emit sources from function args only. Defense- 21417 // in-depth here: the slice-31 lift only admits Oracle / MSSQL 21418 // plain WITHIN GROUP at the unaliased predicate-body 21419 // short-circuit (line ~5216) — the strict 21420 // {@link #rejectWindowFunctions} call above keeps top-level 21421 // windowDef-bearing projections rejected outside the 21422 // predicate-body context, so this collector reduces to the 21423 // slice-28 FILTER-only variant in practice today. 21424 List<ColumnRef> sources = collectColumnRefsExcludingFilterAndWithinGroupClauses(rc, provider); 21425 if (sources.isEmpty() && !aggregate) { 21426 boolean canonicalConstant = isConstantExpression(rc.getExpr()); 21427 boolean inScalarBody = isScalarSyntheticName(stmtName); 21428 if (canonicalConstant && !inScalarBody) { 21429 // Slice 61: constant-only projection lift. Predicate 21430 // bodies still use the earlier slice-23 short-circuit, 21431 // while scalar-subquery bodies intentionally keep the 21432 // slice-11/20 invariant that scalar body projections 21433 // must have a column source. 21434 String alias = rc.getColumnAlias(); 21435 String name = (alias != null && !alias.isEmpty()) 21436 ? alias 21437 : rc.getExpr().toString(); 21438 out.add(new OutputColumn(name, /*derived=*/ true, 21439 /*aggregate=*/ false, 21440 Collections.<ColumnRef>emptyList(), 21441 /*windowSpec=*/ null)); 21442 continue; 21443 } 21444 // Join-structure degrade (GSP R6): a plain unqualified column 21445 // that could not be bound under a fully-built join graph (its 21446 // COLUMN_BINDING_NON_EXACT was downgraded to a warning by 21447 // rejectNonExactBindings) is emitted with no sources rather 21448 // than aborting the whole analysis. The join structure is 21449 // intact; only this column's source side is unknown without a 21450 // catalog (honest: not fabricated). Both anchors qualify: 21451 // - a JOIN ... USING merged-key scope, OR 21452 // - an explicit ON / CROSS / comma join graph 21453 // (hasJoinStructureAnchor()). 21454 // A qualified miss never reaches here — it already aborted 21455 // fatally in the column collector (rejectNonExactBindings with 21456 // allRejectsUnqualified=false). Derived expressions 21457 // (e.g. UPPER('lit')) keep the !derived guard and stay fatal, 21458 // as do scalar bodies (they must carry a column source). 21459 if (!derived && !inScalarBody 21460 && (provider.getUsingScope().hasMergedKeys() 21461 || provider.hasJoinStructureAnchor())) { 21462 String alias = rc.getColumnAlias(); 21463 String name = (alias != null && !alias.isEmpty()) 21464 ? alias 21465 : rc.getExpr().toString(); 21466 out.add(new OutputColumn(name, /*derived=*/ false, 21467 /*aggregate=*/ false, 21468 Collections.<ColumnRef>emptyList(), 21469 /*windowSpec=*/ null)); 21470 continue; 21471 } 21472 throw new SemanticIRBuildException( 21473 Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NO_COLUMN_REFS, 21474 "result column " + rc + " has no column references " 21475 + "and is not a constant or aggregate expression " 21476 + "(e.g. UPPER('literal') / CAST / current_date - not supported yet)", rc)); 21477 } 21478 // Slice 34: when the slice-33-admitted top-level Oracle / MSSQL 21479 // WITHIN-GROUP-only aggregate has no alias, fall back to the 21480 // parser's expression text. {@code effectiveOutputName} would 21481 // throw "neither alias nor column name" because 21482 // {@code function_t} returns "" from getColumnNameOnly(). 21483 // Probe-verified that {@code rc.getExpr().toString()} byte- 21484 // matches dlineage's <select_list> column name attribute on 21485 // Oracle / MSSQL for this shape, so canonical SELECT-edge 21486 // outputName remains in parity with no projector change. 21487 // Gated tightly on slice33TopLevelWG so unrelated unaliased 21488 // shapes (function calls / CASE / expressions outside the 21489 // slice-33 admit set) keep failing loudly via 21490 // effectiveOutputName until each is probed and admitted 21491 // explicitly. See Slice34Test. 21492 String name; 21493 if (slice33TopLevelWG || slice35TopLevelDirectWG) { 21494 String alias = rc.getColumnAlias(); 21495 name = (alias != null && !alias.isEmpty()) 21496 ? alias 21497 : rc.getExpr().toString(); 21498 } else { 21499 name = effectiveOutputName(rc); 21500 } 21501 out.add(new OutputColumn(name, derived, aggregate, sources, /*windowSpec=*/ null)); 21502 } 21503 return out; 21504 } 21505 21506 /** 21507 * Reject scalar subqueries embedded inside larger projection 21508 * expressions (slice 11). Catches: 21509 * 21510 * <ul> 21511 * <li>{@code SELECT UPPER((SELECT MAX(salary) AS m FROM employees)) 21512 * AS x FROM ...} — scalar nested inside a function call.</li> 21513 * <li>{@code SELECT EXISTS (SELECT 1 FROM employees) AS has_emp 21514 * FROM ...} — EXISTS doesn't surface as 21515 * {@link EExpressionType#subquery_t} but carries 21516 * {@code getSubQuery() != null} (slice-9 round-3 lesson).</li> 21517 * <li>Other in-expression subqueries that 21518 * {@link #collectColumnRefs} would otherwise descend into.</li> 21519 * </ul> 21520 * 21521 * <p>Only top-level {@code subquery_t} projections are extracted as 21522 * separate statements (handled in 21523 * {@link #extractScalarSubqueriesAsStatements}); embedded subqueries 21524 * remain rejected because the IR doesn't yet model the "expression 21525 * over subquery result" shape. 21526 */ 21527 private static void rejectEmbeddedSubqueryInProjection(TExpression expr, TResultColumn rc) { 21528 if (expr == null) return; 21529 final boolean[] found = {false}; 21530 expr.acceptChildren(new TParseTreeVisitor() { 21531 @Override 21532 public void preVisit(TExpression e) { 21533 if (found[0]) return; 21534 if (e.getExpressionType() == EExpressionType.subquery_t 21535 || e.getSubQuery() != null) { 21536 found[0] = true; 21537 } 21538 } 21539 }); 21540 if (!found[0]) { 21541 // Top-level expression itself may carry a subquery (e.g. EXISTS 21542 // at the projection root, where rc.getExpr() is exists_t with 21543 // non-null getSubQuery() but is NOT subquery_t — so the 21544 // top-level subquery_t branch above didn't extract it). 21545 if (expr.getExpressionType() != EExpressionType.subquery_t 21546 && expr.getSubQuery() != null) { 21547 found[0] = true; 21548 } 21549 } 21550 if (found[0]) { 21551 throw new SemanticIRBuildException( 21552 Diagnostic.error(DiagnosticCode.RESULT_COLUMN_SCALAR_SUBQUERY_EMBEDDED, 21553 "result column " + rc + " contains a scalar subquery embedded " 21554 + "in a larger projection expression; not supported yet " 21555 + "(only top-level scalar subquery projections are extracted)", rc)); 21556 } 21557 } 21558 21559 /** 21560 * Detect whether an expression contains an aggregate function call 21561 * anywhere in its subtree. Slice 6 uses a name whitelist via 21562 * {@link #AGGREGATE_FUNCTION_NAMES}. Walking recursively means 21563 * {@code SUM(salary) + 1} and {@code COUNT(*) + 1} are both classified 21564 * as aggregate (and thus permitted with empty sources via 21565 * {@link #buildOutputColumns}). Wrapped in a helper so slice 7+ can 21566 * swap in deeper detection (e.g. vendor-specific function classification 21567 * on TFunctionCall) without touching call sites. 21568 * 21569 * <p>Note on aggregate literals like {@code COUNT(1)} or {@code SUM(1)}: 21570 * the visitor finds no column refs, so {@code sources=[]}. Slice 6 21571 * permits these as aggregates with no lineage edges; consumers must 21572 * read {@link OutputColumn#isAggregate()} to know the value is 21573 * row-collapsing without column lineage. 21574 */ 21575 private static boolean isAggregateFunction(TExpression expr) { 21576 if (expr == null) return false; 21577 // Slice 13: short-circuit for top-level window function. The 21578 // upstream `rejectEmbeddedWindowFunction` has already rejected any 21579 // embedded window functions, but this short-circuit ensures 21580 // `AVG(salary) OVER (...)` is never classified as an aggregate 21581 // even if it somehow slips past the upstream guard. 21582 // 21583 // Slice 31: discriminate WITHIN-GROUP-only windowDef (Oracle / 21584 // MSSQL plain WITHIN GROUP attachment without OVER) so 21585 // `LISTAGG(x.id, ',') WITHIN GROUP (ORDER BY x.region)` stays 21586 // classified as an aggregate. Uses {@link #isWindowDefBearingFunction} 21587 // — only this check and {@link #containsWindowFunction} are 21588 // lifted; every other slice-13 invariant rejecter is unchanged. 21589 if (expr.getExpressionType() == EExpressionType.function_t) { 21590 TFunctionCall rootFn = expr.getFunctionCall(); 21591 // Slice 42: hypothetical-set ordered-set aggregate root 21592 // (Oracle / MSSQL {@code RANK(100) WITHIN GROUP (ORDER BY x)}) 21593 // — short-circuit aggregate=true. The shape predicate 21594 // {@link #isHypotheticalSetWithinGroupCall} requires WITHIN- 21595 // GROUP-only windowDef AND a name in 21596 // {@link #HYPOTHETICAL_SET_AGGREGATE_NAMES}, so PG direct 21597 // attachment ({@code fn.getWindowDef()==null}) and OVER- 21598 // bearing forms cannot fire it. 21599 if (isHypotheticalSetWithinGroupCall(rootFn)) { 21600 return true; 21601 } 21602 if (isWindowDefBearingFunction(rootFn)) { 21603 return false; 21604 } 21605 } 21606 final boolean[] found = {false}; 21607 expr.acceptChildren(new TParseTreeVisitor() { 21608 @Override 21609 public void preVisit(TFunctionCall fn) { 21610 if (found[0]) return; 21611 // Slice 13 codex round-2 SHOULD 3: skip windowed function 21612 // calls inside the visitor too, defensively. Upstream 21613 // rejection should already have fired, but this removes 21614 // overlap risk for `sum/count/avg`. 21615 // 21616 // Slice 31: same WITHIN-GROUP-only carve-out as the root 21617 // short-circuit above so an Oracle / MSSQL plain WITHIN 21618 // GROUP aggregate nested inside CASE/UPPER (slice-27 21619 // admit) is still picked up as aggregate. 21620 // 21621 // Slice 42: hypothetical-set ordered-set aggregate carve- 21622 // out — descendants matching the shape predicate count 21623 // as aggregate (defense-in-depth; the slice-13 embedded- 21624 // window rejecter already fires on inner WG-bearing 21625 // calls, so this branch is mostly unreachable today). 21626 if (isHypotheticalSetWithinGroupCall(fn)) { 21627 found[0] = true; 21628 return; 21629 } 21630 if (isWindowDefBearingFunction(fn)) return; 21631 if (fn.getFunctionName() == null) return; 21632 String name = fn.getFunctionName().toString(); 21633 if (name == null || name.isEmpty()) return; 21634 if (AGGREGATE_FUNCTION_NAMES.contains(name.toLowerCase(Locale.ROOT))) { 21635 found[0] = true; 21636 } 21637 } 21638 }); 21639 // The root expression itself is not visited by acceptChildren — only 21640 // its children. If the root is the function call (the common case 21641 // for `SUM(salary)` with no enclosing arithmetic), check it too. 21642 if (!found[0] && expr.getExpressionType() == EExpressionType.function_t) { 21643 TFunctionCall fn = expr.getFunctionCall(); 21644 if (fn != null && fn.getFunctionName() != null) { 21645 String name = fn.getFunctionName().toString(); 21646 if (name != null && !name.isEmpty() 21647 && AGGREGATE_FUNCTION_NAMES.contains(name.toLowerCase(Locale.ROOT))) { 21648 found[0] = true; 21649 } 21650 } 21651 } 21652 return found[0]; 21653 } 21654 21655 /** 21656 * Reject window-function projections like {@code AVG(salary) OVER (...)}. 21657 * In the GSP AST these still parse as {@code function_t} with a 21658 * non-null {@code TFunctionCall.getWindowDef()}, but their semantics 21659 * are row-preserving (analytic), not row-collapsing (aggregate). Slice 21660 * 6 owns plain GROUP BY aggregation only; window functions deserve 21661 * their own slice. 21662 */ 21663 private static void rejectWindowFunctions(TExpression expr, TResultColumn rc) { 21664 if (expr == null) return; 21665 final boolean[] found = {false}; 21666 expr.acceptChildren(new TParseTreeVisitor() { 21667 @Override 21668 public void preVisit(TFunctionCall fn) { 21669 if (found[0]) return; 21670 if (fn.getWindowDef() != null) found[0] = true; 21671 } 21672 }); 21673 if (!found[0] && expr.getExpressionType() == EExpressionType.function_t) { 21674 TFunctionCall fn = expr.getFunctionCall(); 21675 if (fn != null && fn.getWindowDef() != null) found[0] = true; 21676 } 21677 if (found[0]) { 21678 throw new SemanticIRBuildException( 21679 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_USED_NOT_SUPPORTED, 21680 "result column " + rc + " uses a window function (OVER (...)); not supported yet", rc)); 21681 } 21682 } 21683 21684 /** 21685 * Slice 13: detect whether the projection root is a top-level 21686 * window-function call. Returns {@code true} iff 21687 * {@code expr.getExpressionType() == function_t} AND the function 21688 * call carries a non-null {@code TWindowDef}. The result drives 21689 * three things in {@link #buildOutputColumns}: 21690 * 21691 * <ul> 21692 * <li>The {@code skipTopLevel} arg to 21693 * {@link #rejectEmbeddedWindowFunction} so the legitimate 21694 * top-level window call is identity-skipped during embedded 21695 * detection.</li> 21696 * <li>The fast-path dispatch into 21697 * {@link #buildWindowOutputColumn} when window projections 21698 * are allowed.</li> 21699 * <li>The scalar-body / future-context rejection when window 21700 * projections are forbidden in the surrounding context 21701 * (slice-13 {@code allowWindowProjection=false}).</li> 21702 * </ul> 21703 */ 21704 private static boolean isTopLevelWindowProjection(TExpression expr) { 21705 if (expr == null) return false; 21706 if (expr.getExpressionType() != EExpressionType.function_t) return false; 21707 TFunctionCall fn = expr.getFunctionCall(); 21708 return fn != null && fn.getWindowDef() != null; 21709 } 21710 21711 /** 21712 * Slice 13: reject window functions embedded inside a larger 21713 * projection expression (mirrors slice 11's 21714 * {@link #rejectEmbeddedSubqueryInProjection}). The {@code skipTopLevel} 21715 * flag is set when the caller has identified 21716 * {@code expr} as a legitimate top-level window-function projection 21717 * — without identity-skipping that exact {@code TFunctionCall} the 21718 * visitor would reject every valid top-level window. 21719 * 21720 * <p>Visitor-only (no post-visitor fallback): unlike 21721 * {@code subquery_t} which can be wrapped in expression types that 21722 * do not surface as {@code subquery_t}, a window function is always 21723 * reachable through {@code TExpression.acceptChildren} → 21724 * {@code TFunctionCall.preVisit}. Codex round-3 MUST 1. 21725 */ 21726 private static void rejectEmbeddedWindowFunction(TExpression expr, 21727 TResultColumn rc, 21728 boolean skipTopLevel) { 21729 if (expr == null) return; 21730 final TFunctionCall topLevelFn = 21731 skipTopLevel 21732 && expr.getExpressionType() == EExpressionType.function_t 21733 ? expr.getFunctionCall() 21734 : null; 21735 final boolean[] found = {false}; 21736 expr.acceptChildren(new TParseTreeVisitor() { 21737 @Override 21738 public void preVisit(TFunctionCall fn) { 21739 if (found[0]) return; 21740 if (fn == topLevelFn) return; // identity-skip 21741 if (fn.getWindowDef() != null) found[0] = true; 21742 } 21743 }); 21744 if (found[0]) { 21745 throw new SemanticIRBuildException( 21746 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_EMBEDDED_NOT_SUPPORTED, 21747 "result column " + rc + " contains a window function embedded " 21748 + "in a larger projection expression; not supported yet " 21749 + "(only top-level window-function projections are supported)", rc)); 21750 } 21751 } 21752 21753 /** 21754 * Lower-cased function names accepted as window functions in slice 13. 21755 * Includes every name in {@link #AGGREGATE_FUNCTION_NAMES} (aggregates 21756 * can be windowed: {@code SUM(...) OVER (...)}, {@code AVG(...) OVER (...)}, 21757 * etc.) plus the analytic-only names. New analytic functions must be 21758 * added here explicitly to avoid silent acceptance of an unfamiliar 21759 * window function whose semantics the slice does not yet model. 21760 * 21761 * <p>Slice 30 exception: {@code mode} is added to 21762 * {@link #AGGREGATE_FUNCTION_NAMES} for the WITHIN GROUP path but 21763 * REMOVED from this allowlist via {@code s.remove("mode")} below — 21764 * {@code mode()} has no documented window form in any GSP-supported 21765 * vendor and the explicit removal keeps {@code mode() OVER (...)} 21766 * (which the PostgreSQL parser accepts) rejected by 21767 * {@code buildWindowOutputColumn}. 21768 */ 21769 private static final Set<String> WINDOW_FUNCTION_NAMES; 21770 static { 21771 Set<String> s = new HashSet<>(); 21772 // Aggregate names that can be windowed. 21773 s.addAll(AGGREGATE_FUNCTION_NAMES); 21774 // Slice 30: mode is an ordered-set-only aggregate; remove from the 21775 // window allowlist (it was added to AGGREGATE_FUNCTION_NAMES for the 21776 // WITHIN GROUP path but never appears as a real window function in 21777 // any GSP-supported vendor — see Slice30Test.pgModeOverStillRejected 21778 // AtOuterProjection for the lock-in). 21779 s.remove("mode"); 21780 // Analytic-only window functions. 21781 s.add("row_number"); 21782 s.add("rank"); 21783 s.add("dense_rank"); 21784 s.add("lag"); 21785 s.add("lead"); 21786 s.add("ntile"); 21787 s.add("first_value"); 21788 s.add("last_value"); 21789 s.add("percent_rank"); 21790 s.add("cume_dist"); 21791 s.add("nth_value"); 21792 WINDOW_FUNCTION_NAMES = Collections.unmodifiableSet(s); 21793 } 21794 21795 /** 21796 * Slice 13: build an {@link OutputColumn} for a top-level 21797 * window-function projection. Caller must have already verified 21798 * {@link #isTopLevelWindowProjection(TExpression)}, run the 21799 * {@link #rejectEmbeddedSubqueryInProjection} and 21800 * {@link #rejectEmbeddedWindowFunction} guards, and confirmed the 21801 * surrounding body permits window projections (i.e., the 21802 * {@code !allowWindowProjection} fast-path in 21803 * {@link #buildOutputColumns} did not fire). 21804 * 21805 * <p>The constructed {@link OutputColumn} carries: 21806 * <ul> 21807 * <li>{@code derived = true} (window functions are computed)</li> 21808 * <li>{@code aggregate = false} (window functions are 21809 * row-preserving — see slice-13 §14)</li> 21810 * <li>{@code sources} = column refs from the function args only 21811 * (PARTITION BY / OVER ORDER BY refs are excluded so that 21812 * canonical SELECT lineage matches dlineage's 21813 * function-arg-only SELECT BFS)</li> 21814 * <li>{@code windowSpec = WindowSpec(partitionRefs, orderRefs, frame)} 21815 * (slice 22 — frame may be null when the SQL has no 21816 * {@code ROWS}/{@code RANGE}/{@code GROUPS BETWEEN ...} clause)</li> 21817 * </ul> 21818 */ 21819 private static OutputColumn buildWindowOutputColumn(TResultColumn rc, 21820 TSelectSqlStatement enclosingSelect, 21821 NameBindingProvider provider) { 21822 TFunctionCall fn = rc.getExpr().getFunctionCall(); 21823 TWindowDef wd = fn.getWindowDef(); 21824 21825 // 1. Function-name allowlist (codex round-1 MUST 3). 21826 String fnName = fn.getFunctionName() == null ? null : fn.getFunctionName().toString(); 21827 if (fnName == null || !WINDOW_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) { 21828 throw new SemanticIRBuildException( 21829 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_UNSUPPORTED, 21830 "result column " + rc + " uses unsupported window function '" 21831 + fnName + "'; supported names are " + WINDOW_FUNCTION_NAMES, rc)); 21832 } 21833 21834 // 2. Reject vendor-specific function-level surfaces (codex round-1 MUST 4). 21835 if (fn.getFilterClause() != null) { 21836 throw new SemanticIRBuildException( 21837 Diagnostic.error(DiagnosticCode.WINDOW_FILTER_NOT_SUPPORTED, 21838 "result column " + rc + " uses FILTER (WHERE ...) on a " 21839 + "window function; not supported yet", rc)); 21840 } 21841 if (fn.getWithinGroup() != null) { 21842 throw new SemanticIRBuildException( 21843 Diagnostic.error(DiagnosticCode.WINDOW_WITHIN_GROUP_NOT_SUPPORTED, 21844 "result column " + rc + " uses WITHIN GROUP on a " 21845 + "window function; not supported yet", rc)); 21846 } 21847 if (fn.getOrderByList() != null && fn.getOrderByList().size() > 0) { 21848 throw new SemanticIRBuildException( 21849 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_LEVEL_ORDER_BY_NOT_SUPPORTED, 21850 "result column " + rc + " uses function-level ORDER BY " 21851 + "(LISTAGG-style); not supported yet", rc)); 21852 } 21853 if (fn.getSortClause() != null) { 21854 throw new SemanticIRBuildException( 21855 Diagnostic.error(DiagnosticCode.WINDOW_FUNCTION_LEVEL_SORT_NOT_SUPPORTED, 21856 "result column " + rc + " uses function-level SORT clause; " 21857 + "not supported yet", rc)); 21858 } 21859 21860 // 3. Reject vendor-specific window-def surfaces (codex round-1 MUSTs 5, 7). 21861 if (wd.getName() != null) { 21862 throw new SemanticIRBuildException( 21863 Diagnostic.error(DiagnosticCode.WINDOW_NAMED_WINDOW_DECLARATION_NOT_SUPPORTED, 21864 "result column " + rc + " declares a named window " 21865 + "(WINDOW name AS); not supported yet", rc)); 21866 } 21867 if (wd.getReferenceName() != null) { 21868 throw new SemanticIRBuildException( 21869 Diagnostic.error(DiagnosticCode.WINDOW_NAMED_WINDOW_REFERENCE_NOT_SUPPORTED, 21870 "result column " + rc + " references a named window via " 21871 + "OVER name; not supported yet", rc)); 21872 } 21873 if (wd.getWithinGroup() != null) { 21874 throw new SemanticIRBuildException( 21875 Diagnostic.error(DiagnosticCode.WINDOW_WITHIN_GROUP_INSIDE_PROJECTION_NOT_SUPPORTED, 21876 "result column " + rc + " uses WITHIN GROUP inside the " 21877 + "OVER clause; not supported yet", rc)); 21878 } 21879 if (wd.getKeepDenseRankClause() != null) { 21880 throw new SemanticIRBuildException( 21881 Diagnostic.error(DiagnosticCode.WINDOW_KEEP_DENSE_RANK_NOT_SUPPORTED, 21882 "result column " + rc + " uses KEEP DENSE_RANK FIRST/LAST; " 21883 + "not supported yet", rc)); 21884 } 21885 if (wd.getDistributeBy() != null) { 21886 throw new SemanticIRBuildException( 21887 Diagnostic.error(DiagnosticCode.WINDOW_DISTRIBUTE_BY_NOT_SUPPORTED, 21888 "result column " + rc + " uses Hive DISTRIBUTE BY in window; " 21889 + "not supported yet", rc)); 21890 } 21891 if (wd.getClusterBy() != null) { 21892 throw new SemanticIRBuildException( 21893 Diagnostic.error(DiagnosticCode.WINDOW_CLUSTER_BY_NOT_SUPPORTED, 21894 "result column " + rc + " uses Hive CLUSTER BY in window; " 21895 + "not supported yet", rc)); 21896 } 21897 if (wd.getSortBy() != null) { 21898 throw new SemanticIRBuildException( 21899 Diagnostic.error(DiagnosticCode.WINDOW_SORT_BY_NOT_SUPPORTED, 21900 "result column " + rc + " uses Hive SORT BY in window; " 21901 + "not supported yet", rc)); 21902 } 21903 // Slice 22: frame clauses are now built into WindowSpec.frame; the 21904 // slice-13 wholesale rejection is gone. Frame build happens AFTER 21905 // empty-OVER reject below so a frame-only OVER (...) fails on 21906 // empty-OVER first (the more user-tuned error message). 21907 21908 // 4. Reject empty OVER () (slice-13 boundary; dlineage parity — 21909 // empty OVER () is byte-identical to a plain aggregate in the XML). 21910 TPartitionClause pc = wd.getPartitionClause(); 21911 TOrderBy ob = wd.getOrderBy(); 21912 boolean hasPartitionBy = pc != null 21913 && pc.getExpressionList() != null 21914 && pc.getExpressionList().size() > 0; 21915 boolean hasOverOrderBy = ob != null 21916 && ob.getItems() != null 21917 && ob.getItems().size() > 0; 21918 if (!hasPartitionBy && !hasOverOrderBy) { 21919 throw new SemanticIRBuildException( 21920 Diagnostic.error(DiagnosticCode.WINDOW_EMPTY_OVER_NOT_SUPPORTED, 21921 "result column " + rc + " uses empty OVER (); not supported yet " 21922 + "(dlineage XML cannot discriminate from a plain aggregate)", rc)); 21923 } 21924 21925 // 5. Reject Hive PARTITION BY ... SORT (...). 21926 if (pc != null && pc.getSortedColumns() != null && pc.getSortedColumns().size() > 0) { 21927 throw new SemanticIRBuildException( 21928 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_SORT_NOT_SUPPORTED, 21929 "result column " + rc + " uses Hive PARTITION BY ... SORT (...); " 21930 + "not supported yet", rc)); 21931 } 21932 21933 // 6. Build PARTITION BY refs. 21934 List<ColumnRef> partitionRefs = hasPartitionBy 21935 ? buildWindowPartitionRefs(pc, rc, enclosingSelect, provider) 21936 : new ArrayList<ColumnRef>(); 21937 21938 // 7. Build OVER ORDER BY refs. 21939 List<ColumnRef> orderRefs = hasOverOrderBy 21940 ? buildWindowOrderRefs(ob, rc, enclosingSelect, provider) 21941 : new ArrayList<ColumnRef>(); 21942 21943 // 8. Build frame (slice 22). Null when the SQL has no ROWS/RANGE/ 21944 // GROUPS clause inside OVER (...). 21945 WindowFrame frame = wd.getWindowFrame() == null 21946 ? null 21947 : buildWindowFrame(wd.getWindowFrame(), rc); 21948 21949 // 9. Build sources from args only — PARTITION BY / OVER ORDER BY 21950 // refs must NOT leak into OutputColumn.sources because canonical 21951 // SELECT lineage on the dlineage side only walks fdd edges 21952 // (function args), not fdr edges (PARTITION BY / OVER ORDER BY). 21953 List<ColumnRef> sources = (fn.getArgs() == null || fn.getArgs().size() == 0) 21954 ? new ArrayList<ColumnRef>() 21955 : collectColumnRefs(fn.getArgs(), provider); 21956 21957 // 10. Construct OutputColumn. aggregate=false ALWAYS for window 21958 // functions (row-preserving). The OutputColumn ctor enforces 21959 // the windowSpec!=null AND aggregate=false invariant. 21960 String name = effectiveOutputName(rc); 21961 return new OutputColumn(name, /*derived=*/ true, /*aggregate=*/ false, 21962 sources, new WindowSpec(partitionRefs, orderRefs, frame)); 21963 } 21964 21965 /** 21966 * Slice 22: build a {@link WindowFrame} from a parser 21967 * {@link TWindowFrame}. Frame information is presentation-only 21968 * (dlineage XML harvests no frame data — see 21969 * {@code DataFlowAnalyzer.java:20558-20575}); this helper captures 21970 * the surface shape into the IR for governance consumers without 21971 * touching the canonical lineage model. 21972 * 21973 * <p>Direct field access via {@link TWindowFrame#getStartBoundary()} / 21974 * {@link TWindowFrame#getEndBoundary()}; visitors are NOT used because 21975 * {@code TWindowFrame.acceptChildren()} doesn't recurse into the 21976 * boundaries (codex round-1 SHOULD 3). 21977 * 21978 * <p>Order of guards (codex round-2 SHOULD 2): EXCLUDE first so the 21979 * error message is tuned to the actual surface; then null-guard the 21980 * boundary type (defensive — current parsers always pass it); then 21981 * map the {@code EBoundaryType} via an exhaustive switch 21982 * (slice-14 process lesson #17 — no catch-all); then check the 21983 * {@code boundaryNumber} expression type and reject non-constant 21984 * offsets (codex round-1 SHOULD 1 — PG {@code simple_object_name_t} 21985 * and ANSI {@code parenthesis_t} are reachable). 21986 * 21987 * <p>Null guards on the frame's {@link ELimitRowType} and 21988 * {@code startBoundary} fields are defensive / forward-compat: every 21989 * vendor grammar surveyed (codex round-4 NOTE 1) passes these 21990 * arguments together when constructing a {@code TWindowFrame}, so the 21991 * guards are unexercised by current parsers but protect against 21992 * future parser drift. 21993 */ 21994 private static WindowFrame buildWindowFrame(TWindowFrame wf, TResultColumn rc) { 21995 // Defensive null guards (codex round-2 MUST 2; codex round-4 21996 // SHOULD 1 — labelled DEFENSIVE / FORWARD-COMPAT). 21997 if (wf.getLimitRowType() == null) { 21998 throw new SemanticIRBuildException( 21999 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_LIMIT_ROW_TYPE, 22000 "result column " + rc + " has a frame with null limitRowType " 22001 + "(forward-compat / unexpected parser shape); not supported", rc)); 22002 } 22003 if (wf.getStartBoundary() == null) { 22004 throw new SemanticIRBuildException( 22005 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_START_BOUNDARY, 22006 "result column " + rc + " has a frame with null start boundary " 22007 + "(forward-compat / unexpected parser shape); not supported", rc)); 22008 } 22009 WindowFrame.Unit unit = mapFrameUnit(wf.getLimitRowType()); 22010 FrameBound start = buildFrameBound(wf.getStartBoundary(), rc, /*end=*/ false); 22011 FrameBound end = wf.getEndBoundary() == null 22012 ? null 22013 : buildFrameBound(wf.getEndBoundary(), rc, /*end=*/ true); 22014 return new WindowFrame(unit, start, end); 22015 } 22016 22017 /** 22018 * Slice 22: map the parser's {@link ELimitRowType} to the IR's 22019 * {@link WindowFrame.Unit}. Exhaustive switch (slice-14 process 22020 * lesson #17 — no catch-all); a future enum addition fails closed. 22021 */ 22022 private static WindowFrame.Unit mapFrameUnit(ELimitRowType type) { 22023 switch (type) { 22024 case Rows: 22025 return WindowFrame.Unit.ROWS; 22026 case Range: 22027 return WindowFrame.Unit.RANGE; 22028 case Groups: 22029 return WindowFrame.Unit.GROUPS; 22030 default: 22031 throw new SemanticIRBuildException( 22032 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNSUPPORTED_LIMIT_ROW_TYPE, 22033 "unsupported window frame limitRowType: " + type, null)); 22034 } 22035 } 22036 22037 /** 22038 * Slice 22: build a {@link FrameBound} from a parser 22039 * {@link TWindowFrameBoundary}. The {@code end} parameter is for 22040 * error messages only (start vs end disambiguation). 22041 * 22042 * <p>Per-bound check order: EXCLUDE → boundaryType-null → kind switch 22043 * → boundaryNumber shape (codex round-2 SHOULD 2 + slice-22 invariant). 22044 */ 22045 private static FrameBound buildFrameBound(TWindowFrameBoundary boundary, 22046 TResultColumn rc, 22047 boolean end) { 22048 String which = end ? "end" : "start"; 22049 22050 // (a) EXCLUDE first (codex round-1 MUST 2 + Netezza probe). 22051 // Netezza populates getExclusionClause() on the END boundary for 22052 // EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS; rejecting here 22053 // surfaces the unsupported clause with a tuned message rather 22054 // than letting the offset-shape check fire on an unrelated 22055 // surface. 22056 if (boundary.getExclusionClause() != null) { 22057 throw new SemanticIRBuildException( 22058 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_EXCLUDE_NOT_SUPPORTED, 22059 "result column " + rc + " has a frame " + which 22060 + " boundary with EXCLUDE clause " 22061 + "(EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS); " 22062 + "not supported yet", rc)); 22063 } 22064 22065 // (b) Null-guard the boundary type (defensive). 22066 if (boundary.getBoundaryType() == null) { 22067 throw new SemanticIRBuildException( 22068 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_NULL_BOUNDARY_TYPE, 22069 "result column " + rc + " has a frame " + which 22070 + " boundary with null boundaryType " 22071 + "(forward-compat / unexpected parser shape); not supported", rc)); 22072 } 22073 22074 // (c) Map the kind via exhaustive switch. 22075 FrameBound.Kind kind = mapBoundaryKind(boundary.getBoundaryType()); 22076 22077 // (d) Capture the optional offset literal. Reject non-constant 22078 // offsets (codex round-1 SHOULD 1 + slice-22 PG/ANSI probe — PG 22079 // accepts simple_object_name_t (column ROWS BETWEEN x PRECEDING ...), 22080 // ANSI accepts parenthesis_t ((x+1))). 22081 String offsetLiteral = null; 22082 TExpression offsetExpr = boundary.getBoundaryNumber(); 22083 if (offsetExpr != null) { 22084 // Slice-22 codex impl-review SHOULD 1: when the kind forbids 22085 // an offset (UNBOUNDED_*/CURRENT_ROW), reject with 22086 // SemanticIRBuildException so the failure stays inside the 22087 // builder's error contract — without this guard, a parser 22088 // surfacing a stray boundary number on CURRENT_ROW would 22089 // escape as IllegalArgumentException from 22090 // FrameBound's ctor. 22091 boolean offsetAllowed = (kind == FrameBound.Kind.PRECEDING 22092 || kind == FrameBound.Kind.FOLLOWING); 22093 if (!offsetAllowed) { 22094 throw new SemanticIRBuildException( 22095 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNEXPECTED_OFFSET, 22096 "result column " + rc + " has a frame " + which 22097 + " boundary of kind " + kind 22098 + " carrying an unexpected offset '" 22099 + offsetExpr + "' (forward-compat / " 22100 + "unexpected parser shape); not supported", rc)); 22101 } 22102 EExpressionType offsetType = offsetExpr.getExpressionType(); 22103 if (offsetType != EExpressionType.simple_constant_t) { 22104 throw new SemanticIRBuildException( 22105 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_OFFSET_NON_CONSTANT, 22106 "result column " + rc + " has a frame " + which 22107 + " offset that is not a simple constant " 22108 + "(got " + offsetType + " '" + offsetExpr + "'); " 22109 + "not supported yet", rc)); 22110 } 22111 offsetLiteral = offsetExpr.toString(); 22112 } 22113 return new FrameBound(kind, offsetLiteral); 22114 } 22115 22116 /** 22117 * Slice 22: map the parser's {@link EBoundaryType} to the IR's 22118 * {@link FrameBound.Kind}. Exhaustive switch (slice-14 process 22119 * lesson #17). 22120 */ 22121 private static FrameBound.Kind mapBoundaryKind(EBoundaryType type) { 22122 switch (type) { 22123 case ebtUnboundedPreceding: 22124 return FrameBound.Kind.UNBOUNDED_PRECEDING; 22125 case ebtUnboundedFollowing: 22126 return FrameBound.Kind.UNBOUNDED_FOLLOWING; 22127 case ebtCurrentRow: 22128 return FrameBound.Kind.CURRENT_ROW; 22129 case ebtPreceding: 22130 return FrameBound.Kind.PRECEDING; 22131 case ebtFollowing: 22132 return FrameBound.Kind.FOLLOWING; 22133 default: 22134 throw new SemanticIRBuildException( 22135 Diagnostic.error(DiagnosticCode.WINDOW_FRAME_UNSUPPORTED_BOUNDARY_TYPE, 22136 "unsupported frame boundary type: " + type, null)); 22137 } 22138 } 22139 22140 /** 22141 * Slice 13: build the PARTITION BY ref list. Every item must be a 22142 * physical column reference ({@code simple_object_name_t} resolving 22143 * via the provider to {@code EXACT_MATCH}). Other shapes are 22144 * rejected with a tuned message — slice-9 / slice-13 22145 * rejection-over-silent-loss. 22146 */ 22147 private static List<ColumnRef> buildWindowPartitionRefs(TPartitionClause pc, 22148 TResultColumn rc, 22149 TSelectSqlStatement enclosingSelect, 22150 NameBindingProvider provider) { 22151 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 22152 TExpressionList list = pc.getExpressionList(); 22153 for (int i = 0; i < list.size(); i++) { 22154 TExpression item = list.getExpression(i); 22155 EExpressionType t = item.getExpressionType(); 22156 if (t == EExpressionType.simple_constant_t) { 22157 throw new SemanticIRBuildException( 22158 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_LITERAL, 22159 "result column " + rc + " has PARTITION BY literal '" 22160 + item + "'; not supported yet", rc)); 22161 } 22162 if (t == EExpressionType.subquery_t || item.getSubQuery() != null) { 22163 throw new SemanticIRBuildException( 22164 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_SUBQUERY, 22165 "result column " + rc + " has PARTITION BY containing " 22166 + "a subquery; not supported yet", rc)); 22167 } 22168 if (t == EExpressionType.function_t) { 22169 throw new SemanticIRBuildException( 22170 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_AGGREGATE, 22171 "result column " + rc + " has PARTITION BY containing " 22172 + "a function call '" + item + "'; not supported yet", rc)); 22173 } 22174 if (t != EExpressionType.simple_object_name_t) { 22175 throw new SemanticIRBuildException( 22176 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_UNKNOWN_REFERENCE, 22177 "result column " + rc + " has PARTITION BY using an " 22178 + "unsupported expression shape (" + t + "): " + item, rc)); 22179 } 22180 // Defensive: reject if the parser/resolver has retyped this 22181 // item as a projection alias. Current Oracle parsers leave 22182 // PARTITION BY <alias> as dbType=column even for projection 22183 // aliases; this guard fires for vendors that may behave 22184 // differently. The slice-19 discriminator below catches the 22185 // Oracle case where dbType stays "column" but the binding 22186 // came from the schema-less inferred-from-usage fallback. 22187 TObjectName on = item.getObjectOperand(); 22188 if (on != null && on.getDbObjectType() == EDbObjectType.column_alias) { 22189 throw new SemanticIRBuildException( 22190 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_PROJECTION_ALIAS, 22191 "result column " + rc + " has PARTITION BY referencing " 22192 + "a projection alias '" + item + "'; not supported yet", rc)); 22193 } 22194 // Slice 19: alias-bound discriminator. Reject when the 22195 // resolver's binding lacks definite FROM-scope evidence and 22196 // the name matches a calculated SELECT-list alias of the 22197 // enclosing SELECT. Without schema metadata the resolver 22198 // cannot tell alias from real column; rejection-over-silent- 22199 // guess matches the slice-9/-10/-13 invariant. 22200 if (on != null && provider.isCalculatedProjectionAliasFallback(on, enclosingSelect)) { 22201 throw new SemanticIRBuildException( 22202 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_CALCULATED_ALIAS, 22203 "result column " + rc + " has PARTITION BY referencing a " 22204 + "SELECT-list alias on a calculated expression ('" + item 22205 + "'); not supported yet — requires schema metadata to " 22206 + "discriminate alias from base column", rc)); 22207 } 22208 // Resolve the column ref through the provider. EXACT_MATCH is 22209 // required (slice-1 fail-fast invariant); collectColumnRefs 22210 // does the heavy lifting and rejects anything else. 22211 List<ColumnRef> built = collectColumnRefs(item, provider); 22212 if (built.isEmpty()) { 22213 throw new SemanticIRBuildException( 22214 Diagnostic.error(DiagnosticCode.WINDOW_PARTITION_BY_ITEM_UNUSABLE, 22215 "result column " + rc + " has PARTITION BY item '" 22216 + item + "' with no resolvable column refs", rc)); 22217 } 22218 refs.addAll(built); 22219 } 22220 return new ArrayList<>(refs); 22221 } 22222 22223 /** 22224 * Slice 13: build the OVER ORDER BY ref list. Every sort key must 22225 * be a physical column reference (mirrors slice-9 outer ORDER BY 22226 * rejection set). Ordinals, projection aliases, expressions, 22227 * subqueries, window functions, and SIBLINGS / RESET WHEN are 22228 * rejected with tuned messages. 22229 */ 22230 private static List<ColumnRef> buildWindowOrderRefs(TOrderBy ob, 22231 TResultColumn rc, 22232 TSelectSqlStatement enclosingSelect, 22233 NameBindingProvider provider) { 22234 // Slice-13 codex impl-review MUST 2: defense in depth, mirror outer 22235 // ORDER BY's slice-9 SIBLINGS / RESET WHEN guards. 22236 if (ob.isSiblings()) { 22237 throw new SemanticIRBuildException( 22238 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_SIBLINGS_NOT_SUPPORTED, 22239 "result column " + rc + " has OVER ORDER BY SIBLINGS; not supported yet " 22240 + "(Oracle hierarchical-query syntax in window OVER clause)", rc)); 22241 } 22242 if (ob.getResetWhenCondition() != null) { 22243 throw new SemanticIRBuildException( 22244 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_RESET_WHEN_NOT_SUPPORTED, 22245 "result column " + rc + " has OVER ORDER BY ... RESET WHEN; not supported yet " 22246 + "(Teradata window-style restart)", rc)); 22247 } 22248 LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 22249 TOrderByItemList items = ob.getItems(); 22250 for (int i = 0; i < items.size(); i++) { 22251 TOrderByItem item = items.getOrderByItem(i); 22252 TExpression key = item.getSortKey(); 22253 if (key == null) { 22254 throw new SemanticIRBuildException( 22255 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_NULL_SORT_KEY, 22256 "result column " + rc + " has OVER ORDER BY item with " 22257 + "null sort key", rc)); 22258 } 22259 EExpressionType t = key.getExpressionType(); 22260 if (t == EExpressionType.simple_constant_t) { 22261 // Catches both ordinal sort keys and string-literal sort keys. 22262 throw new SemanticIRBuildException( 22263 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_LITERAL, 22264 "result column " + rc + " has OVER ORDER BY literal/ordinal '" 22265 + key + "'; not supported yet", rc)); 22266 } 22267 if (t == EExpressionType.subquery_t || key.getSubQuery() != null) { 22268 throw new SemanticIRBuildException( 22269 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_SUBQUERY, 22270 "result column " + rc + " has OVER ORDER BY containing a " 22271 + "subquery; not supported yet", rc)); 22272 } 22273 if (t == EExpressionType.function_t) { 22274 TFunctionCall innerFn = key.getFunctionCall(); 22275 if (innerFn != null && innerFn.getWindowDef() != null) { 22276 throw new SemanticIRBuildException( 22277 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_WINDOW_FUNCTION, 22278 "result column " + rc + " has OVER ORDER BY containing a " 22279 + "window function; not supported yet", rc)); 22280 } 22281 throw new SemanticIRBuildException( 22282 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_AGGREGATE, 22283 "result column " + rc + " has OVER ORDER BY containing a " 22284 + "function call '" + key + "'; not supported yet", rc)); 22285 } 22286 if (t != EExpressionType.simple_object_name_t) { 22287 throw new SemanticIRBuildException( 22288 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_UNKNOWN_REFERENCE, 22289 "result column " + rc + " has OVER ORDER BY using an " 22290 + "unsupported expression shape (" + t + "): " + key, rc)); 22291 } 22292 // NOTE: Oracle's parser DOES retype OVER ORDER BY refs to 22293 // column_alias when they match a SELECT alias (mirrors 22294 // slice-9 outer ORDER BY behaviour). The defensive 22295 // column_alias guard from PARTITION BY is intentionally 22296 // omitted here — `collectColumnRefs` already skips 22297 // column_alias-typed nodes, and the empty-refs guard below 22298 // catches the resulting unresolvable item with a clear 22299 // message. Outer ORDER BY aliases use the same path. 22300 // 22301 // Slice 19: defensive symmetry with PARTITION BY. A future 22302 // vendor whose parser does NOT retype OVER ORDER BY refs to 22303 // column_alias would land here as `simple_object_name_t` 22304 // with an inferred-from-usage resolution; the discriminator 22305 // catches that case before collectColumnRefs descends. As of 22306 // slice 19, every supported vendor retypes (probe in 22307 // §14.21), so this branch is unreachable in current tests 22308 // — kept for forward-compat. 22309 TObjectName on = key.getObjectOperand(); 22310 if (on != null && provider.isCalculatedProjectionAliasFallback(on, enclosingSelect)) { 22311 throw new SemanticIRBuildException( 22312 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_CALCULATED_ALIAS, 22313 "result column " + rc + " has OVER ORDER BY referencing a " 22314 + "SELECT-list alias on a calculated expression ('" + key 22315 + "'); not supported yet — requires schema metadata to " 22316 + "discriminate alias from base column", rc)); 22317 } 22318 List<ColumnRef> built = collectColumnRefs(key, provider); 22319 if (built.isEmpty()) { 22320 throw new SemanticIRBuildException( 22321 Diagnostic.error(DiagnosticCode.WINDOW_OVER_ORDER_BY_ITEM_UNUSABLE, 22322 "result column " + rc + " has OVER ORDER BY item '" 22323 + key + "' with no resolvable column refs", rc)); 22324 } 22325 refs.addAll(built); 22326 } 22327 return new ArrayList<>(refs); 22328 } 22329 22330 /** 22331 * Slice 13: reject any window function ({@code FUNC(...) OVER (...)}) 22332 * appearing in a {@link TParseTreeNode} subtree. Used by the 22333 * WHERE / GROUP BY / JOIN ON guards before the visitor would 22334 * otherwise descend into the OVER clause and leak PARTITION BY / 22335 * OVER ORDER BY refs into the wrong column-ref bucket. Mirrors 22336 * {@link #rejectHavingWindowFunction} (slice 10) and 22337 * {@link #rejectOrderByWindowFunction} (slice 9). 22338 */ 22339 /** 22340 * Slice 85 — admit RETURNING (PG / Oracle) and OUTPUT (SQL Server) 22341 * projections on INSERT / UPDATE / DELETE statements. Returns the 22342 * list of {@link OutputColumn}s for the {@code returningColumns} 22343 * slot on the DML's {@link StatementGraph}, and appends one 22344 * {@link LineageEdge} per source column ref to {@code lineage}: 22345 * <pre> 22346 * from = LineageRef.statementOutput(dmlIdx, returningColumns[i].name) 22347 * to = LineageRef.tableColumn(targetQName, sourceColumnName) 22348 * </pre> 22349 * (consumer ← producer direction; mirrors slice-78 INSERT's 22350 * {@code target ← source} convention but with the DML's own output 22351 * as the consumer and the target table's column as the producer.) 22352 * 22353 * <p>At most one of {@code ret} and {@code out} is non-null. When 22354 * both are null (no RETURNING / OUTPUT clause), returns an empty 22355 * list and emits no edges. 22356 * 22357 * <p>Reject ordering (codex round-3 Q2 BLOCKING fix — two-pass): 22358 * <ol> 22359 * <li>Pass 1, statement-level: empty projection list → 22360 * {@link DiagnosticCode#RETURNING_EMPTY_PROJECTION}.</li> 22361 * <li>Pass 1.5, OUTPUT-only DML-kind / pseudo-table mismatch scan: 22362 * INSERT with any {@code DELETED.col} → 22363 * {@link DiagnosticCode#OUTPUT_DELETED_ON_INSERT_NOT_SUPPORTED}; 22364 * DELETE with any {@code INSERTED.col} → 22365 * {@link DiagnosticCode#OUTPUT_INSERTED_ON_DELETE_NOT_SUPPORTED}. 22366 * Fires on the first matching column regardless of position.</li> 22367 * <li>Pass 2, per-column (in SQL declaration order): 22368 * <ul> 22369 * <li>{@code *} → {@link DiagnosticCode#RETURNING_STAR_NOT_SUPPORTED}</li> 22370 * <li>any subquery → 22371 * {@link DiagnosticCode#RETURNING_HAS_SUBQUERY_NOT_SUPPORTED}</li> 22372 * <li>any window function over a base ref → reuses 22373 * {@link DiagnosticCode#CLAUSE_WINDOW_FUNCTION_LEAK} via 22374 * {@link #rejectWindowFunctionInScope}</li> 22375 * <li>any aggregate function over a base ref → 22376 * {@link DiagnosticCode#RETURNING_HAS_AGGREGATE_NOT_SUPPORTED} 22377 * (aggregates are not legal in DML RETURNING / OUTPUT per 22378 * spec — fires defensively when parser admits them)</li> 22379 * </ul> 22380 * </li> 22381 * </ol> 22382 * 22383 * <p>OUTPUT_INTO_NOT_SUPPORTED is rejected at the caller (before 22384 * any FROM walk / SET / WHERE processing) so multi-violation shapes 22385 * route through the cheaper structural code first. 22386 * 22387 * @param ret RETURNING clause; null when this DML uses OUTPUT or 22388 * no projection at all 22389 * @param out OUTPUT clause; null when this DML uses RETURNING or 22390 * no projection at all 22391 * @param dmlKind "INSERT" / "UPDATE" / "DELETE" — only relevant for 22392 * the pseudo-table mismatch scan (UPDATE admits both 22393 * INSERTED and DELETED; INSERT admits only INSERTED; 22394 * DELETE admits only DELETED) 22395 * @param targetQName the target table's qualified name; used as the 22396 * {@code to} endpoint of every emitted LineageEdge 22397 * @param fromSideAliasToStmtIndex slice-123 alias→statement-index map 22398 * keyed lowercase for SUBQUERY-kind FROM-side relations 22399 * that own a producing {@link StatementGraph} 22400 * (slice-84 USING-(SELECT) sub / slice-106 USING-CTE on 22401 * DELETE; slice-82/83/105 FROM-subquery / CTE-as-relation 22402 * on UPDATE). When a RETURNING/OUTPUT source ref resolves 22403 * to such a relation, the emitted edge's {@code to} 22404 * endpoint is the producer's 22405 * {@code STATEMENT_OUTPUT(idx, col)} rather than the 22406 * fictitious {@code TABLE_COLUMN(<alias>, col)}. Pass an 22407 * empty map to keep the slice-85 TABLE_COLUMN behaviour 22408 * (INSERT, MERGE) 22409 * @param provider name-binding provider; same instance used for 22410 * SET RHS / WHERE / JOIN ON ref collection so 22411 * FROM-side relation refs (slice-82 joined UPDATE, 22412 * slice-84 joined DELETE) resolve correctly 22413 * @param dmlIdx the DML statement's position in 22414 * {@link SemanticProgram#getStatements()}; used as the 22415 * {@code statementIndex} on the {@code from} endpoint 22416 * @param lineage in/out: collected edges are appended here 22417 * @param anchor parse-tree anchor for diagnostics 22418 */ 22419 private static List<OutputColumn> buildReturningColumns( 22420 TReturningClause ret, 22421 TOutputClause out, 22422 String dmlKind, 22423 String targetQName, 22424 String targetAlias, 22425 TTable targetTable, 22426 List<RelationSource> fromSideRelations, 22427 Map<String, Integer> fromSideAliasToStmtIndex, 22428 NameBindingProvider provider, 22429 int dmlIdx, 22430 List<LineageEdge> lineage, 22431 TParseTreeNode anchor) { 22432 if (ret == null && out == null) { 22433 return Collections.emptyList(); 22434 } 22435 // Oracle host-variable form: `RETURNING col INTO :v` — AST shape 22436 // is columnValueList + variableList populated, resultExprList null. 22437 // Slice 88 admits it: extract column exprs from columnValueList, 22438 // discard variableList (bind sinks have no semantic IR relevance). 22439 // The still-unsupported degenerate case (resultExprList=null AND 22440 // columnValueList=null) keeps RETURNING_INTO_NOT_SUPPORTED so the 22441 // code stays declared-not-unreachable per the slice-71/72/82 precedent. 22442 boolean isOracleInto = (ret != null && ret.getResultExprList() == null 22443 && ret.getColumnValueList() != null); 22444 if (ret != null && ret.getResultExprList() == null && !isOracleInto) { 22445 throw new SemanticIRBuildException(Diagnostic.error( 22446 DiagnosticCode.RETURNING_INTO_NOT_SUPPORTED, 22447 "Oracle `RETURNING col INTO :host_var` with no column list " 22448 + "is not supported; admits the standard INTO form only", 22449 anchor)); 22450 } 22451 // Extract the source column list. 22452 TResultColumnList items = null; 22453 TExpressionList intoExprs = null; 22454 if (isOracleInto) { 22455 intoExprs = ret.getColumnValueList(); 22456 } else if (ret != null) { 22457 items = ret.getResultExprList(); 22458 } else { 22459 items = out.getSelectItemList(); 22460 } 22461 int colCount = isOracleInto 22462 ? (intoExprs == null ? 0 : intoExprs.size()) 22463 : (items == null ? 0 : items.size()); 22464 // Pass 1: empty projection list (defensive — the parser usually 22465 // refuses to produce an empty list, but a malformed AST should 22466 // surface a clean diagnostic). 22467 if (colCount == 0) { 22468 throw new SemanticIRBuildException(Diagnostic.error( 22469 DiagnosticCode.RETURNING_EMPTY_PROJECTION, 22470 dmlKind + (ret != null ? " RETURNING" : " OUTPUT") 22471 + " clause has no projection columns", 22472 anchor)); 22473 } 22474 // Pass 1.5: OUTPUT-only DML-kind / pseudo-table mismatch scan 22475 // (codex round-1 Q4 BLOCKING — deep-walk all TObjectName leaves 22476 // so compound exprs like `OUTPUT INSERTED.a + DELETED.b` also 22477 // reject deterministically). The parser sets pseudoTableType 22478 // on the fieldAttr for SIMPLE column references but leaves 22479 // it null on the leaf TObjectNames inside compound expressions; 22480 // we detect those by checking the objectToken spelling against 22481 // "INSERTED" / "DELETED". 22482 // The Oracle INTO path skips this scan (no INSERTED/DELETED pseudo-tables). 22483 if (out != null && !isOracleInto) { 22484 final String targetAliasFinal = targetAlias; 22485 final String targetQNameFinal = targetQName; 22486 final List<RelationSource> relsFinal = fromSideRelations; 22487 for (int i = 0; i < items.size(); i++) { 22488 TResultColumn rc = items.getResultColumn(i); 22489 final String dmlKindFinal = dmlKind; 22490 final TResultColumn rcFinal = rc; 22491 scanOutputPseudoTableLeaves(rc.getExpr(), 22492 new TParseTreeVisitor() { 22493 @Override 22494 public void preVisit(TObjectName n) { 22495 EPseudoTableType pt = detectPseudoTable( 22496 n, rcFinal, targetAliasFinal, 22497 targetQNameFinal, relsFinal); 22498 if (pt == EPseudoTableType.deleted 22499 && "INSERT".equals(dmlKindFinal)) { 22500 throw new SemanticIRBuildException(Diagnostic.error( 22501 DiagnosticCode.OUTPUT_DELETED_ON_INSERT_NOT_SUPPORTED, 22502 "INSERT OUTPUT references DELETED." 22503 + bareColumnNameOf(n) 22504 + " but there is no deleted-row " 22505 + "image on INSERT; use INSERTED.* instead", 22506 rcFinal)); 22507 } 22508 if (pt == EPseudoTableType.inserted 22509 && "DELETE".equals(dmlKindFinal)) { 22510 throw new SemanticIRBuildException(Diagnostic.error( 22511 DiagnosticCode.OUTPUT_INSERTED_ON_DELETE_NOT_SUPPORTED, 22512 "DELETE OUTPUT references INSERTED." 22513 + bareColumnNameOf(n) 22514 + " but there is no inserted-row " 22515 + "image on DELETE; use DELETED.* instead", 22516 rcFinal)); 22517 } 22518 } 22519 }); 22520 } 22521 } 22522 // Pass 2: per-column. Build OutputColumns, emit edges. 22523 List<OutputColumn> outputs = new ArrayList<>(colCount); 22524 for (int i = 0; i < colCount; i++) { 22525 // For the Oracle INTO path rc is null — the INTO column list 22526 // carries bare expressions, not TResultColumn wrappers. 22527 TResultColumn rc = isOracleInto ? null 22528 : items.getResultColumn(i); 22529 TExpression expr = isOracleInto 22530 ? intoExprs.getExpression(i) 22531 : (rc == null ? null : rc.getExpr()); 22532 if (expr == null) { 22533 throw new SemanticIRBuildException(Diagnostic.error( 22534 DiagnosticCode.RESULT_COLUMN_NULL_EXPRESSION, 22535 dmlKind + (ret != null ? " RETURNING" : " OUTPUT") 22536 + " column #" + (i + 1) + " has no expression", 22537 rc != null ? rc : anchor)); 22538 } 22539 // Slice 98 — MSSQL MERGE OUTPUT `$action` pseudo-column. 22540 // Returns the merge action string per output row ('INSERT' / 22541 // 'UPDATE' / 'DELETE') — it has no underlying base column. 22542 // Detected case-insensitively because parser tokens come out 22543 // as `$action` regardless of how the user wrote it; bracketed 22544 // `[$action]` is a delimited identifier and is NOT treated 22545 // as the pseudo-column (codex Q1 confirmed YES — slice-98 22546 // detection is literal text equality on the un-bracketed 22547 // spelling). The check is gated on dmlKind="MERGE" so 22548 // INSERT/UPDATE/DELETE OUTPUT (slice 85) are unaffected. 22549 if ("MERGE".equals(dmlKind) 22550 && isMergeActionPseudoColumn(expr)) { 22551 String actionName = (rc != null && rc.getColumnAlias() != null 22552 && !rc.getColumnAlias().toString().isEmpty()) 22553 ? rc.getColumnAlias().toString() 22554 : expr.toString(); 22555 outputs.add(new OutputColumn(actionName, 22556 /*derived=*/ true, 22557 /*aggregate=*/ false, 22558 Collections.<ColumnRef>emptyList())); 22559 // No LineageEdge — $action has no producer column. 22560 continue; 22561 } 22562 // STAR check — bare `RETURNING *` parses as 22563 // simple_object_name_t with toString="*"; qualified star 22564 // forms like `RETURNING t.*` / `OUTPUT inserted.*` / 22565 // `OUTPUT deleted.*` parse as simple_object_name_t with 22566 // partToken (and getColumnNameOnly()) equal to "*" 22567 // (codex round-4 BLOCKING fix). 22568 // Slice 90: standard RETURNING star attempts catalog-backed expansion. 22569 // Slice 99: MSSQL MERGE OUTPUT INSERTED.* / DELETED.* 22570 // attempts catalog-backed expansion against the target table. 22571 // Oracle INTO star and non-MERGE OUTPUT star (and bare / 22572 // target-alias / source-alias MERGE OUTPUT star) remain rejected. 22573 if (isStarReference(expr)) { 22574 if (isOracleInto) { 22575 // Oracle INTO star: keep existing reject. 22576 throw new SemanticIRBuildException(Diagnostic.error( 22577 DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED, 22578 dmlKind + " RETURNING INTO * star expansion " 22579 + "is not yet supported; use explicit column names", 22580 rc != null ? rc : expr)); 22581 } 22582 if (out != null) { 22583 // Slice 99 / Slice 100 — MSSQL pseudo-table 22584 // OUTPUT INSERTED.* / DELETED.* routes to catalog- 22585 // backed expansion against the target table. The 22586 // pseudo-table discriminator is the parser-set 22587 // EPseudoTableType.inserted / .deleted flag on the 22588 // star qualifier (slice-85 primary discriminator). 22589 // Slice 99 lifted the reject for dmlKind="MERGE"; 22590 // slice 100 generalises to all DML kinds (INSERT / 22591 // UPDATE / DELETE) — the parser sets pseudoTableType 22592 // identically on non-MERGE OUTPUT stars, and Pass 22593 // 1.5 has already rejected cross-direction 22594 // mismatches (INSERT OUTPUT DELETED.* / 22595 // DELETE OUTPUT INSERTED.*) before this branch. 22596 // OUTPUT *, t.*, s.* (no pseudo-table marker) still 22597 // reject — they're either ambiguous (bare *) or 22598 // refer to non-pseudo relations. 22599 EPseudoTableType pseudo = EPseudoTableType.none; 22600 TObjectName starObj = expr.getObjectOperand(); 22601 if (starObj != null 22602 && starObj.getPseudoTableType() != null) { 22603 pseudo = starObj.getPseudoTableType(); 22604 } 22605 if (pseudo == EPseudoTableType.inserted 22606 || pseudo == EPseudoTableType.deleted) { 22607 expandOutputPseudoTableStarColumns( 22608 expr, rc, pseudo, dmlKind, 22609 targetTable, targetQName, 22610 provider, dmlIdx, lineage, anchor, outputs); 22611 continue; 22612 } 22613 // Non-pseudo OUTPUT star (bare *, target-alias *, 22614 // source-alias *): keep existing reject. 22615 throw new SemanticIRBuildException(Diagnostic.error( 22616 DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED, 22617 dmlKind + " OUTPUT * star expansion is not " 22618 + "yet supported; use explicit column names", 22619 rc != null ? rc : expr)); 22620 } 22621 // Standard RETURNING star: attempt catalog-backed expansion. 22622 // On success, the helper adds to `outputs` and `lineage` in place 22623 // and we `continue` past the normal single-column build below. 22624 expandReturningStarColumns( 22625 expr, rc, dmlKind, targetTable, targetAlias, targetQName, 22626 fromSideRelations, provider, dmlIdx, lineage, anchor, outputs); 22627 continue; 22628 } 22629 // Subquery / aggregate / window — guarded by !isOracleInto 22630 // because Oracle's INTO column list forbids nested queries and 22631 // aggregates at the grammar level; skip the checks to avoid 22632 // false rejects on unusual AST shapes. 22633 if (!isOracleInto) { 22634 if (containsAnySubqueryExpression(expr)) { 22635 throw new SemanticIRBuildException(Diagnostic.error( 22636 DiagnosticCode.RETURNING_HAS_SUBQUERY_NOT_SUPPORTED, 22637 dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT") 22638 + " column #" + (i + 1) + " contains a subquery; " 22639 + "slice 85 admits scalar expressions over base columns only", 22640 rc)); 22641 } 22642 rejectWindowFunctionInScope(expr, 22643 dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT")); 22644 if (isAggregateFunction(expr)) { 22645 throw new SemanticIRBuildException(Diagnostic.error( 22646 DiagnosticCode.RETURNING_HAS_AGGREGATE_NOT_SUPPORTED, 22647 dmlKind + " " + (ret != null ? "RETURNING" : "OUTPUT") 22648 + " column #" + (i + 1) + " contains an aggregate " 22649 + "function; aggregates are not legal in DML " 22650 + "RETURNING / OUTPUT projection per SQL spec", 22651 rc)); 22652 } 22653 } 22654 // Name extraction. 22655 // INTO path: no alias possible, use expr.toString() directly. 22656 // Normal path: use the projection-side helper which strips 22657 // INSERTED./DELETED. qualifiers on OUTPUT pseudo-table refs. 22658 String outName = isOracleInto 22659 ? expr.toString() 22660 : returningOutputName(rc, expr, dmlKind, ret != null); 22661 if (outName == null || outName.isEmpty()) { 22662 throw new SemanticIRBuildException(Diagnostic.error( 22663 DiagnosticCode.RESULT_COLUMN_NO_NAME, 22664 dmlKind + " RETURNING INTO column #" + (i + 1) 22665 + " has no resolvable name", 22666 anchor)); 22667 } 22668 // Source collection via manual walker (slice-89 fix registers 22669 // RETURNING refs in Resolver2 allColumnReferences for DELETE/UPDATE; 22670 // INSERT RETURNING lacks an InsertScope so Resolver2 path is partial). 22671 // rc=null is safe for the INTO path: synthRefForReturningLeaf 22672 // only dereferences rc inside the `if (isOutput)` guard, 22673 // which is false for all RETURNING (non-OUTPUT) paths. 22674 List<ColumnRef> sources = collectReturningSourceRefs( 22675 expr, rc, out != null && !isOracleInto, 22676 targetAlias, targetQName, fromSideRelations); 22677 boolean derived = expr.getExpressionType() 22678 != EExpressionType.simple_object_name_t; 22679 outputs.add(new OutputColumn(outName, derived, 22680 /*aggregate=*/ false, sources)); 22681 // Emit one LineageEdge per source column ref. 22682 // Edge direction (consumer ← producer; slice-85 convention 22683 // documented on getReturningColumns()): 22684 // from = STATEMENT_OUTPUT(dmlIdx, returningName) 22685 // to = TABLE_COLUMN(<producer-qualified-name>, <colName>) 22686 // The producer qualified-name is: 22687 // - target table qname when the source ref's relationAlias 22688 // is INSERTED / DELETED (MSSQL OUTPUT pseudo-tables both 22689 // ultimately reference the physical target row) 22690 // - target table qname when the source ref's relationAlias 22691 // matches the target alias 22692 // - FROM-side relation's binding qualifiedName when the 22693 // ref's relationAlias matches a FROM-side relation 22694 // - the relationAlias verbatim otherwise (defensive) 22695 for (ColumnRef src : sources) { 22696 String srcCol = src.getColumnName(); 22697 if (srcCol == null || srcCol.isEmpty()) continue; 22698 String alias = src.getRelationAlias(); 22699 // Slice 123 — when the source ref binds to a SUBQUERY-kind 22700 // FROM-side relation that owns its own producing 22701 // StatementGraph (slice-84 USING-(SELECT) sub / slice-106 22702 // USING-CTE on DELETE; slice-82/83/105 FROM-subquery or 22703 // CTE-as-relation on UPDATE), emit the canonical cross-stmt 22704 // STATEMENT_OUTPUT → STATEMENT_OUTPUT edge into the producer 22705 // rather than the fictitious TABLE_COLUMN(<alias>, col). 22706 // Base TABLE-kind FROM-side relations (slice-82/84 joined 22707 // RETURNING, pinned by Slice85Test §B/§C) and target / 22708 // INSERTED / DELETED refs fall through to the slice-85 22709 // TABLE_COLUMN path via resolveReturningEdgeTarget. 22710 Integer subIdx = returningSubquerySourceIndex(alias, 22711 targetAlias, targetQName, fromSideRelations, 22712 fromSideAliasToStmtIndex); 22713 if (subIdx != null) { 22714 lineage.add(new LineageEdge( 22715 LineageRef.statementOutput(dmlIdx, outName), 22716 LineageRef.statementOutput(subIdx, srcCol))); 22717 } else { 22718 String to = resolveReturningEdgeTarget(alias, 22719 targetAlias, targetQName, fromSideRelations); 22720 lineage.add(new LineageEdge( 22721 LineageRef.statementOutput(dmlIdx, outName), 22722 LineageRef.tableColumn(to, srcCol))); 22723 } 22724 } 22725 } 22726 return outputs; 22727 } 22728 22729 /** 22730 * Slice 98 helper — true when an expression is the MSSQL MERGE 22731 * OUTPUT {@code $action} pseudo-column. 22732 * 22733 * <p>Detection rule: the expression is a 22734 * {@code simple_object_name_t} and its {@code toString()} matches 22735 * {@code "$action"} case-insensitively. Bracketed delimited 22736 * identifiers like {@code [$action]} parse with the brackets in 22737 * the token string, so they are NOT matched here — a column 22738 * actually named {@code $action} (delimited) is treated as a 22739 * normal target column, not the pseudo-column (codex Q1 confirmed). 22740 * 22741 * <p>Caller gates the check on {@code dmlKind == "MERGE"} so the 22742 * slice-85 INSERT/UPDATE/DELETE OUTPUT path is unaffected. 22743 */ 22744 private static boolean isMergeActionPseudoColumn(TExpression expr) { 22745 if (expr == null) return false; 22746 if (expr.getExpressionType() != EExpressionType.simple_object_name_t) { 22747 return false; 22748 } 22749 String text = expr.toString(); 22750 return text != null && "$action".equalsIgnoreCase(text); 22751 } 22752 22753 /** 22754 * Slice 85 helper — true when an expression is a star reference 22755 * (bare {@code *} or qualified {@code t.*} / {@code INSERTED.*} / 22756 * {@code DELETED.*}). Covers both forms: bare star has 22757 * {@code expr.toString()=="*"}; qualified star is a 22758 * simple_object_name_t whose leaf TObjectName has 22759 * {@code partToken=="*"} (codex round-4 BLOCKING fix — 22760 * qualified stars were previously slipping past the bare-only 22761 * check and producing bogus ColumnRefs). 22762 */ 22763 private static boolean isStarReference(TExpression expr) { 22764 if (expr == null) return false; 22765 if (expr.getExpressionType() != EExpressionType.simple_object_name_t) { 22766 return false; 22767 } 22768 if ("*".equals(expr.toString())) return true; 22769 TObjectName n = expr.getObjectOperand(); 22770 if (n == null) return false; 22771 if (n.getPartToken() != null && "*".equals(n.getPartToken().toString())) { 22772 return true; 22773 } 22774 String colOnly = n.getColumnNameOnly(); 22775 return colOnly != null && "*".equals(colOnly); 22776 } 22777 22778 /** 22779 * Slice 90 helper — expand a standard {@code RETURNING *} / 22780 * {@code RETURNING t.*} star into per-column 22781 * {@link OutputColumn} entries using catalog metadata from 22782 * {@link #lookupRelationColumnNames(TTable, NameBindingProvider)}. 22783 * 22784 * <p>Called only for standard PG/Oracle RETURNING (not MSSQL OUTPUT, 22785 * not Oracle INTO). Adds expanded columns to {@code outputs} and 22786 * matching {@link LineageEdge}s to {@code lineage} in place. 22787 * 22788 * <p>Qualifier matching mirrors Slice 59 SELECT star semantics: 22789 * alias-only — the qualifier must equal the target's effective alias, 22790 * not the schema-qualified name. For INSERT without alias the effective 22791 * alias is the bare table name, so {@code RETURNING employees.*} matches 22792 * {@code INSERT INTO schema.employees}. 22793 * 22794 * <p>Throws {@link SemanticIRBuildException} on any failure: 22795 * <ul> 22796 * <li>{@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED} — no 22797 * catalog metadata available for the target relation;</li> 22798 * <li>{@link DiagnosticCode#RETURNING_STAR_NOT_SUPPORTED} — the 22799 * qualifier matches a FROM-side relation alias but FROM-side 22800 * star expansion is deferred to a future slice;</li> 22801 * <li>{@link DiagnosticCode#RETURNING_STAR_QUALIFIER_UNKNOWN} — the 22802 * qualifier does not match the target alias or any FROM-side 22803 * relation alias.</li> 22804 * </ul> 22805 */ 22806 private static void expandReturningStarColumns( 22807 TExpression expr, 22808 TResultColumn rc, 22809 String dmlKind, 22810 TTable targetTable, 22811 String targetAlias, 22812 String targetQName, 22813 List<RelationSource> fromSideRelations, 22814 NameBindingProvider provider, 22815 int dmlIdx, 22816 List<LineageEdge> lineage, 22817 TParseTreeNode anchor, 22818 List<OutputColumn> outputs) { 22819 // Extract qualifier: empty for bare `*`, table/alias name for `t.*`. 22820 String qualifier = ""; 22821 TObjectName n = (expr != null) ? expr.getObjectOperand() : null; 22822 if (n != null) { 22823 String q = n.getTableString(); 22824 if (q != null && !q.isEmpty()) qualifier = q; 22825 } 22826 // Rendered star form for use in diagnostic messages. 22827 String starForm = qualifier.isEmpty() ? "*" : qualifier + ".*"; 22828 // Determine which relation to expand. 22829 // Rule (Slice 90, mirrors Slice 59 correlation-name semantics): 22830 // effective alias only — bare name without alias counts as alias. 22831 boolean matchesTarget = qualifier.isEmpty() 22832 || qualifier.equalsIgnoreCase(targetAlias); 22833 if (matchesTarget) { 22834 // Attempt catalog-backed expansion of the target table. 22835 List<String> cols = lookupRelationColumnNames(targetTable, provider); 22836 if (cols == null || cols.isEmpty()) { 22837 throw new SemanticIRBuildException(Diagnostic.error( 22838 DiagnosticCode.RETURNING_STAR_CATALOG_REQUIRED, 22839 dmlKind + " RETURNING " + starForm 22840 + " requires catalog metadata for target '" 22841 + targetQName + "' to expand; supply a Catalog via " 22842 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", 22843 rc != null ? rc : anchor)); 22844 } 22845 for (String colName : cols) { 22846 ColumnRef ref = new ColumnRef(targetAlias, colName); 22847 outputs.add(new OutputColumn(colName, /*derived=*/ false, 22848 /*aggregate=*/ false, Collections.singletonList(ref))); 22849 lineage.add(new LineageEdge( 22850 LineageRef.statementOutput(dmlIdx, colName), 22851 LineageRef.tableColumn(targetQName, colName))); 22852 } 22853 return; 22854 } 22855 // Qualifier doesn't match target. Check FROM-side relations. 22856 for (RelationSource rs : fromSideRelations) { 22857 if (qualifier.equalsIgnoreCase(rs.getAlias())) { 22858 // Known FROM-side relation, but expansion is deferred. 22859 throw new SemanticIRBuildException(Diagnostic.error( 22860 DiagnosticCode.RETURNING_STAR_NOT_SUPPORTED, 22861 dmlKind + " RETURNING " + starForm + " — " 22862 + "star expansion for FROM-side/USING relations " 22863 + "is deferred to a future slice; use explicit " 22864 + "column names for FROM-side RETURNING refs", 22865 rc != null ? rc : anchor)); 22866 } 22867 } 22868 // Qualifier is truly unknown (doesn't match target or any FROM-side relation). 22869 throw new SemanticIRBuildException(Diagnostic.error( 22870 DiagnosticCode.RETURNING_STAR_QUALIFIER_UNKNOWN, 22871 dmlKind + " RETURNING " + starForm + " — qualifier '" 22872 + qualifier + "' does not match the DML target alias '" 22873 + targetAlias + "' or any FROM-side relation; " 22874 + "use the target's effective alias for RETURNING star expansion", 22875 rc != null ? rc : anchor)); 22876 } 22877 22878 /** 22879 * Slice 99 / Slice 100 helper — expand MSSQL pseudo-table 22880 * {@code OUTPUT INSERTED.*} / {@code OUTPUT DELETED.*} into 22881 * per-column {@link OutputColumn} entries using catalog metadata 22882 * from {@link #lookupRelationColumnNames(TTable, NameBindingProvider)}. 22883 * 22884 * <p>Slice 99 originally introduced this helper for MSSQL MERGE 22885 * OUTPUT. Slice 100 generalised it to all DML kinds (INSERT / UPDATE 22886 * / DELETE / MERGE): the parser sets {@code pseudoTableType=inserted/deleted} 22887 * on the star qualifier identically for non-MERGE DML, so the 22888 * expansion is mechanically the same — only the catalog-missing 22889 * message text varies by {@code dmlKind} per the slice-80 22890 * message-text-discrimination contract. 22891 * 22892 * <p>Mirrors the slice-90 standard-RETURNING star design with two 22893 * differences: 22894 * <ul> 22895 * <li>The pseudo-table qualifier ({@code INSERTED} / {@code DELETED}) 22896 * is normalized to UPPERCASE on 22897 * {@link ColumnRef#getRelationAlias()} regardless of the SQL 22898 * case, matching slice-85 22899 * {@code synthRefForReturningLeaf}'s 22900 * {@code new ColumnRef("INSERTED", ...)} convention.</li> 22901 * <li>The catalog lookup target is the DML target table (the 22902 * pseudo-table rows physically reference target rows), not a 22903 * FROM-side relation.</li> 22904 * </ul> 22905 * 22906 * <p>Catalog miss reuses {@link DiagnosticCode#RETURNING_STAR_CATALOG_REQUIRED} 22907 * (slice-90 code) with a discriminating message text formatted as 22908 * {@code "<dmlKind> OUTPUT <pseudoLabel>.* requires catalog metadata 22909 * for target '<qname>' ..."}. 22910 * 22911 * <p>Adds expanded columns to {@code outputs} and matching 22912 * {@link LineageEdge}s to {@code lineage} in place. 22913 * 22914 * @param dmlKind one of {@code "INSERT"}, {@code "UPDATE"}, 22915 * {@code "DELETE"}, {@code "MERGE"} — feeds the 22916 * catalog-missing message text only; expansion is 22917 * identical regardless of kind because the parser 22918 * sets {@code pseudoTableType} on the star qualifier 22919 * uniformly. 22920 */ 22921 private static void expandOutputPseudoTableStarColumns( 22922 TExpression expr, 22923 TResultColumn rc, 22924 EPseudoTableType pseudoTable, 22925 String dmlKind, 22926 TTable targetTable, 22927 String targetQName, 22928 NameBindingProvider provider, 22929 int dmlIdx, 22930 List<LineageEdge> lineage, 22931 TParseTreeNode anchor, 22932 List<OutputColumn> outputs) { 22933 // Slice-85 convention: relationAlias is normalized to UPPERCASE. 22934 String pseudoLabel = (pseudoTable == EPseudoTableType.inserted) 22935 ? "INSERTED" 22936 : "DELETED"; 22937 List<String> cols = lookupRelationColumnNames(targetTable, provider); 22938 if (cols == null || cols.isEmpty()) { 22939 throw new SemanticIRBuildException(Diagnostic.error( 22940 DiagnosticCode.RETURNING_STAR_CATALOG_REQUIRED, 22941 dmlKind + " OUTPUT " + pseudoLabel + ".* requires catalog " 22942 + "metadata for target '" + targetQName 22943 + "' to expand; supply a Catalog via " 22944 + "SqlSemanticAnalyzer.analyze(sql, vendor, catalog)", 22945 rc != null ? rc : anchor)); 22946 } 22947 for (String colName : cols) { 22948 ColumnRef ref = new ColumnRef(pseudoLabel, colName); 22949 outputs.add(new OutputColumn(colName, /*derived=*/ false, 22950 /*aggregate=*/ false, Collections.singletonList(ref))); 22951 // INSERTED / DELETED both physically reference the target 22952 // row image; lineage edge target is the target qname. 22953 lineage.add(new LineageEdge( 22954 LineageRef.statementOutput(dmlIdx, colName), 22955 LineageRef.tableColumn(targetQName, colName))); 22956 } 22957 } 22958 22959 /** 22960 * Slice 85 helper — pull the pseudo-table type for an OUTPUT 22961 * column (SQL Server). Returns {@link EPseudoTableType#none} for 22962 * RETURNING (PG / Oracle) columns and for OUTPUT columns whose 22963 * top-level expression doesn't carry an INSERTED / DELETED 22964 * qualifier. For compound expressions, the parser sets the 22965 * pseudo-table type on the fieldAttr only for SIMPLE column 22966 * references; compound shapes must be deep-walked separately 22967 * via {@link #scanOutputPseudoTableLeaves}. 22968 */ 22969 private static EPseudoTableType pseudoTableOf(TResultColumn rc) { 22970 if (rc == null) return EPseudoTableType.none; 22971 TObjectName fa = rc.getFieldAttr(); 22972 if (fa == null) return EPseudoTableType.none; 22973 EPseudoTableType pt = fa.getPseudoTableType(); 22974 return (pt == null) ? EPseudoTableType.none : pt; 22975 } 22976 22977 /** 22978 * Slice 85 helper — walk an OUTPUT projection expression and 22979 * invoke {@code visitor} on every TObjectName leaf for the 22980 * pseudo-table mismatch scan. Skips function-name TObjectNames 22981 * via the dbObjectType filter so {@code OUTPUT FUNC(inserted.x)} 22982 * still surfaces the leaf inserted.x ref. 22983 */ 22984 private static void scanOutputPseudoTableLeaves( 22985 TExpression expr, final TParseTreeVisitor visitor) { 22986 if (expr == null) return; 22987 // Collect function-name identities first (codex round-2 Q1 22988 // BLOCKING — dialect-portable structural filter). 22989 final java.util.Set<TObjectName> fnLeaves = 22990 collectFunctionNameLeaves(expr); 22991 // Fast path: leaf simple_object_name_t. 22992 if (expr.getExpressionType() == EExpressionType.simple_object_name_t) { 22993 TObjectName n = expr.getObjectOperand(); 22994 if (n != null && !isFunctionNameObjectName(n, fnLeaves)) { 22995 visitor.preVisit(n); 22996 } 22997 return; 22998 } 22999 // Compound expression — walk for TObjectName leaves. 23000 expr.acceptChildren(new TParseTreeVisitor() { 23001 int nestedSelectDepth = 0; 23002 @Override 23003 public void preVisit(TSelectSqlStatement s) { nestedSelectDepth++; } 23004 @Override 23005 public void postVisit(TSelectSqlStatement s) { nestedSelectDepth--; } 23006 @Override 23007 public void preVisit(TObjectName node) { 23008 if (nestedSelectDepth > 0) return; 23009 if (isFunctionNameObjectName(node, fnLeaves)) return; 23010 visitor.preVisit(node); 23011 } 23012 }); 23013 } 23014 23015 /** 23016 * Slice 85 helper — detect the pseudo-table type (INSERTED / 23017 * DELETED) for a TObjectName leaf in an OUTPUT projection, 23018 * honouring both the parser-set fieldAttr.pseudoTableType (for 23019 * simple leaf refs where the parser ran its qualifier swap) AND 23020 * the objectToken spelling (for compound expressions where the 23021 * parser left pseudoTableType=none on leaf TObjectNames). 23022 * 23023 * <p>Codex round-2 Q3 BLOCKING fix — MSSQL permits "INSERTED" / 23024 * "DELETED" as real identifiers via ColId, so the text-match 23025 * fallback fires ONLY when no FROM-side relation alias / 23026 * qualifiedName / bare-component matches the qualifier. With a 23027 * real table named "INSERTED" in scope, the text-match is 23028 * suppressed and the leaf surfaces as a normal column ref. 23029 */ 23030 private static EPseudoTableType detectPseudoTable(TObjectName n, 23031 TResultColumn rc, 23032 String targetAlias, 23033 String targetQName, 23034 List<RelationSource> fromSideRelations) { 23035 if (n == null) return EPseudoTableType.none; 23036 // Direct: parser-set pseudoTableType. 23037 if (n.getPseudoTableType() != null 23038 && n.getPseudoTableType() != EPseudoTableType.none) { 23039 return n.getPseudoTableType(); 23040 } 23041 // Indirect: if this leaf is the result column's fieldAttr, 23042 // read from the fieldAttr's pseudoTableType (slice-78 23043 // TOutputClause.doParse sets it there for simple refs). 23044 if (rc != null && rc.getFieldAttr() == n) { 23045 EPseudoTableType pt = pseudoTableOf(rc); 23046 if (pt != null && pt != EPseudoTableType.none) return pt; 23047 } 23048 // Compound expression leaf — the parser leaves 23049 // pseudoTableType=none on the leaf TObjectNames but the 23050 // objectToken spelling is preserved. Text-match 23051 // INSERTED / DELETED case-insensitively, but only when no 23052 // real FROM-side relation shadows the pseudo name (codex 23053 // round-2 Q3 BLOCKING). 23054 if (n.getObjectToken() != null && n.getPartToken() != null) { 23055 String obj = n.getObjectToken().toString(); 23056 if (obj == null) return EPseudoTableType.none; 23057 boolean shadowedByRealRelation = 23058 qualifierMatchesAnyRelation(obj, targetAlias, 23059 targetQName, fromSideRelations); 23060 if (shadowedByRealRelation) return EPseudoTableType.none; 23061 if ("INSERTED".equalsIgnoreCase(obj)) return EPseudoTableType.inserted; 23062 if ("DELETED".equalsIgnoreCase(obj)) return EPseudoTableType.deleted; 23063 } 23064 return EPseudoTableType.none; 23065 } 23066 23067 /** 23068 * Slice 85 helper — true when {@code qualifier} matches some 23069 * real relation in scope (target alias / qualified name / bare 23070 * component, or any FROM-side relation alias / qualified name / 23071 * bare component). Used by {@link #detectPseudoTable} to 23072 * suppress the INSERTED / DELETED text-match when a real table 23073 * by that name is in scope. 23074 */ 23075 private static boolean qualifierMatchesAnyRelation(String qualifier, 23076 String targetAlias, String targetQName, 23077 List<RelationSource> fromSideRelations) { 23078 if (qualifier == null || qualifier.isEmpty()) return false; 23079 if (targetAlias != null && targetAlias.equalsIgnoreCase(qualifier)) { 23080 return true; 23081 } 23082 if (targetQName != null 23083 && (targetQName.equalsIgnoreCase(qualifier) 23084 || bareLastDotComponent(targetQName) 23085 .equalsIgnoreCase(qualifier))) { 23086 return true; 23087 } 23088 if (fromSideRelations != null) { 23089 for (RelationSource rs : fromSideRelations) { 23090 String a = rs.getAlias(); 23091 if (a != null && a.equalsIgnoreCase(qualifier)) return true; 23092 String qn = rs.getBinding() == null ? null 23093 : rs.getBinding().getQualifiedName(); 23094 if (qn != null 23095 && (qn.equalsIgnoreCase(qualifier) 23096 || bareLastDotComponent(qn) 23097 .equalsIgnoreCase(qualifier))) { 23098 return true; 23099 } 23100 } 23101 } 23102 return false; 23103 } 23104 23105 /** 23106 * Slice 85 helper — collect identities of every TObjectName that 23107 * is a function-name in the given expression tree (codex round-2 23108 * Q1 BLOCKING — {@code EDbObjectType.function} is unreliable 23109 * across dialects: Oracle builtins surface as {@code constant}, 23110 * MSSQL XML methods as {@code method}). The walker uses 23111 * {@link IdentityHashMap}-style reference equality so the 23112 * column-ref walker can structurally skip function-name leaves 23113 * regardless of dbType. 23114 */ 23115 private static java.util.Set<TObjectName> collectFunctionNameLeaves( 23116 TExpression expr) { 23117 final java.util.Set<TObjectName> set = java.util.Collections.newSetFromMap( 23118 new IdentityHashMap<TObjectName, Boolean>()); 23119 if (expr == null) return set; 23120 expr.acceptChildren(new TParseTreeVisitor() { 23121 @Override 23122 public void preVisit(TFunctionCall fn) { 23123 TObjectName name = fn.getFunctionName(); 23124 if (name != null) set.add(name); 23125 } 23126 }); 23127 return set; 23128 } 23129 23130 /** 23131 * Slice 85 helper — true when this TObjectName is in the 23132 * function-name set collected for the current expression 23133 * (codex round-2 Q1 BLOCKING fix — structural identity rather 23134 * than dbType). {@code functionNameLeaves} may be null (empty 23135 * set semantics) when the caller doesn't have the set in hand. 23136 */ 23137 private static boolean isFunctionNameObjectName(TObjectName n, 23138 java.util.Set<TObjectName> functionNameLeaves) { 23139 if (n == null) return false; 23140 if (functionNameLeaves != null && functionNameLeaves.contains(n)) { 23141 return true; 23142 } 23143 // Best-effort fallback: dbType check catches the 23144 // single-leaf simple_object_name_t function case (rare — 23145 // those normally arrive as TFunctionCall). Kept defensively. 23146 EDbObjectType t = n.getDbObjectType(); 23147 return t == EDbObjectType.function; 23148 } 23149 23150 /** 23151 * Slice 85 helper — best-effort spelling of the bare column name 23152 * for an OUTPUT pseudo-table ref like INSERTED.foo. Used only in 23153 * diagnostic message text. 23154 */ 23155 private static String safePseudoColumn(TResultColumn rc) { 23156 if (rc == null) return "<unknown>"; 23157 TObjectName fa = rc.getFieldAttr(); 23158 if (fa == null) return "<unknown>"; 23159 if (fa.getPartToken() != null) return fa.getPartToken().toString(); 23160 if (fa.getPropertyToken() != null) return fa.getPropertyToken().toString(); 23161 return rc.toString(); 23162 } 23163 23164 /** 23165 * Slice 85 helper — derive the OutputColumn name for a RETURNING / 23166 * OUTPUT projection column. Uses the explicit alias when present, 23167 * else the bare column name (for OUTPUT INSERTED.col / DELETED.col 23168 * the bare partToken spelling, stripping the pseudo-table 23169 * qualifier). Falls back to {@code expr.toString()} for derived 23170 * expressions without alias (e.g. {@code RETURNING a + 1} → name 23171 * = "a + 1"). 23172 */ 23173 private static String returningOutputName(TResultColumn rc, 23174 TExpression expr, 23175 String dmlKind, 23176 boolean isReturning) { 23177 String alias = rc.getColumnAlias(); 23178 if (alias != null && !alias.isEmpty()) { 23179 return alias; 23180 } 23181 // OUTPUT pseudo-table ref without alias — strip the qualifier 23182 // so the OutputColumn.name carries the bare column spelling. 23183 if (!isReturning) { 23184 EPseudoTableType pt = pseudoTableOf(rc); 23185 if (pt != EPseudoTableType.none) { 23186 TObjectName fa = rc.getFieldAttr(); 23187 if (fa != null && fa.getPartToken() != null) { 23188 return fa.getPartToken().toString(); 23189 } 23190 } 23191 } 23192 // RETURNING bare column or derived expression — fall back to 23193 // the expression's toString(). For simple_object_name_t this is 23194 // the verbatim bare or qualified column spelling; for 23195 // arithmetic / function expressions it is the rendered text. 23196 String s = expr.toString(); 23197 if (s == null || s.isEmpty()) { 23198 throw new SemanticIRBuildException(Diagnostic.error( 23199 DiagnosticCode.RESULT_COLUMN_NO_NAME, 23200 dmlKind + (isReturning ? " RETURNING" : " OUTPUT") 23201 + " column has no resolvable name", 23202 rc)); 23203 } 23204 return s; 23205 } 23206 23207 /** 23208 * Slice 85 helper — collect ColumnRefs from a RETURNING / OUTPUT 23209 * projection expression. Slice 89 fixed TReturningClause.acceptChildren() 23210 * to descend into children so Resolver2 now registers RETURNING refs in 23211 * allColumnReferences for DELETE/UPDATE (INSERT RETURNING lacks InsertScope 23212 * so Resolver2 coverage there is partial). This walker remains the 23213 * authoritative source for Semantic IR because it maps qualifier tokens 23214 * directly onto the DML's known relation set: 23215 * 23216 * <ul> 23217 * <li>OUTPUT pseudo-table ref ({@code INSERTED.col} / 23218 * {@code DELETED.col}, detected via 23219 * {@code fieldAttr.pseudoTableType}) → ColumnRef with 23220 * uppercase {@code "INSERTED"} / {@code "DELETED"} as the 23221 * relationAlias, preserving temporal phase (codex round-2 23222 * Q2 BLOCKING).</li> 23223 * <li>Qualified ref matching a FROM-side relation's alias 23224 * (slice-82 joined UPDATE / slice-84 joined DELETE) 23225 * → ColumnRef with that alias.</li> 23226 * <li>Qualified ref matching the target table's effective alias 23227 * or its qualified name → ColumnRef with the target alias.</li> 23228 * <li>Unqualified ref → ColumnRef with the target alias 23229 * (default scope).</li> 23230 * <li>Qualified ref matching nothing known → ColumnRef with the 23231 * parser's qualifier verbatim. Lineage consumers can spot 23232 * the unresolved relation via the relationAlias they see.</li> 23233 * </ul> 23234 * 23235 * <p>Match policy: case-insensitive on alias / qualified name, 23236 * to match the slice-83 codex Q3 advisory and stay forgiving of 23237 * dialect-specific identifier casing. 23238 */ 23239 private static List<ColumnRef> collectReturningSourceRefs( 23240 TExpression expr, 23241 TResultColumn rc, 23242 boolean isOutput, 23243 String targetAlias, 23244 String targetQName, 23245 List<RelationSource> fromSideRelations) { 23246 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 23247 // Collect function-name identities first (codex round-2 Q1 23248 // BLOCKING — dialect-portable structural filter; see 23249 // {@link #collectFunctionNameLeaves} javadoc for the 23250 // dbObjectType unreliability rationale). 23251 final java.util.Set<TObjectName> fnLeaves = collectFunctionNameLeaves(expr); 23252 // Fast path: simple_object_name_t leaf — handle directly so 23253 // OUTPUT INSERTED.col / DELETED.col resolves through fieldAttr. 23254 if (expr.getExpressionType() == EExpressionType.simple_object_name_t) { 23255 ColumnRef r = synthRefForReturningLeaf( 23256 rc, expr.getObjectOperand(), isOutput, 23257 targetAlias, targetQName, fromSideRelations, fnLeaves); 23258 if (r != null) refs.add(r); 23259 return new ArrayList<>(refs); 23260 } 23261 // Compound expression — walk for TObjectName leaves. We don't 23262 // descend into nested subqueries (already rejected upstream) 23263 // and don't try to resolve fieldAttr for compound expressions 23264 // (pseudo-table qualifier inside arithmetic / function args is 23265 // a rare shape; slice 85 surfaces the bare column ref against 23266 // the target alias as a best-effort). 23267 expr.acceptChildren(new TParseTreeVisitor() { 23268 int nestedSelectDepth = 0; 23269 @Override 23270 public void preVisit(TSelectSqlStatement nested) { 23271 nestedSelectDepth++; 23272 } 23273 @Override 23274 public void postVisit(TSelectSqlStatement nested) { 23275 nestedSelectDepth--; 23276 } 23277 @Override 23278 public void preVisit(TObjectName node) { 23279 if (nestedSelectDepth > 0) return; 23280 // Codex round-1 Q1 / round-2 Q1 BLOCKING — skip 23281 // function-name leaves via structural identity. 23282 if (isFunctionNameObjectName(node, fnLeaves)) return; 23283 ColumnRef r = synthRefForReturningLeaf( 23284 rc, node, isOutput, 23285 targetAlias, targetQName, fromSideRelations, fnLeaves); 23286 if (r != null) refs.add(r); 23287 } 23288 }); 23289 return new ArrayList<>(refs); 23290 } 23291 23292 /** 23293 * Slice 85 helper — build one ColumnRef for a TObjectName leaf in 23294 * a RETURNING / OUTPUT projection. Returns null when the node is 23295 * not a column-name reference (e.g. a function name token). 23296 */ 23297 private static ColumnRef synthRefForReturningLeaf( 23298 TResultColumn rc, 23299 TObjectName node, 23300 boolean isOutput, 23301 String targetAlias, 23302 String targetQName, 23303 List<RelationSource> fromSideRelations, 23304 java.util.Set<TObjectName> functionNameLeaves) { 23305 if (node == null) return null; 23306 // Codex round-1 Q1 / round-2 Q1 BLOCKING — skip function-name 23307 // TObjectNames via structural identity (the "UPPER" leaf in 23308 // `RETURNING UPPER(name)`). 23309 if (isFunctionNameObjectName(node, functionNameLeaves)) return null; 23310 String colName = bareColumnNameOf(node); 23311 if (colName == null || colName.isEmpty()) return null; 23312 // OUTPUT pseudo-table ref (INSERTED / DELETED). Detect via 23313 // fieldAttr on the result column (parser surfaces it there 23314 // for simple refs) OR objectToken spelling (compound exprs; 23315 // codex round-1 Q4 BLOCKING — same deep detection as 23316 // detectPseudoTable). Both INSERTED and DELETED ultimately 23317 // reference the target table's row; only the temporal phase 23318 // surfaces on ColumnRef.relationAlias. 23319 if (isOutput) { 23320 EPseudoTableType pt = detectPseudoTable(node, rc, 23321 targetAlias, targetQName, fromSideRelations); 23322 if (pt == EPseudoTableType.inserted) { 23323 return new ColumnRef("INSERTED", partColumnNameOf(node, colName)); 23324 } 23325 if (pt == EPseudoTableType.deleted) { 23326 return new ColumnRef("DELETED", partColumnNameOf(node, colName)); 23327 } 23328 } 23329 // Qualifier resolution: bare or qualified. 23330 String qualifier = qualifierOf(node); 23331 if (qualifier == null || qualifier.isEmpty()) { 23332 return new ColumnRef(targetAlias, colName); 23333 } 23334 // Codex round-2 Q2 + round-3 BLOCKING fix — single-pass 23335 // count-all-candidates matcher. A relation "matches" the 23336 // qualifier if any of (alias, qualifiedName, bare-last-dot 23337 // component of qualifiedName) compares case-insensitive- 23338 // equal. Multiple matches (e.g. unaliased `FROM s1.t s2.t` 23339 // both have effectiveAlias "t" via TTable.getName() fallback, 23340 // both have bareComponent "t") are ambiguous and fall through 23341 // to the verbatim qualifier path so consumers see the 23342 // ambiguity rather than a silent order-dependent pick. 23343 int totalMatches = 0; 23344 // Single-match accumulators (one each — only valid when 23345 // totalMatches == 1): 23346 RelationSource matchedRelation = null; 23347 boolean matchedIsTarget = false; 23348 if (fromSideRelations != null) { 23349 for (RelationSource rs : fromSideRelations) { 23350 if (relationCandidateMatch(rs, qualifier)) { 23351 totalMatches++; 23352 matchedRelation = rs; 23353 } 23354 } 23355 } 23356 if (targetCandidateMatch(targetAlias, targetQName, qualifier)) { 23357 totalMatches++; 23358 matchedIsTarget = true; 23359 } 23360 if (totalMatches == 1) { 23361 if (matchedIsTarget) { 23362 return new ColumnRef( 23363 targetAlias != null ? targetAlias : targetQName, colName); 23364 } 23365 String a = matchedRelation.getAlias(); 23366 String qn = matchedRelation.getBinding() == null ? null 23367 : matchedRelation.getBinding().getQualifiedName(); 23368 return new ColumnRef((a != null && !a.isEmpty()) ? a : qn, colName); 23369 } 23370 // Zero matches or ambiguous — pass through verbatim. Lineage 23371 // consumers can spot the unresolved / ambiguous relation. 23372 return new ColumnRef(qualifier, colName); 23373 } 23374 23375 /** 23376 * Slice 85 helper — true when a FROM-side relation is a match 23377 * candidate for the qualifier under any of: alias, full 23378 * qualifiedName, or bare last-dot component of qualifiedName 23379 * (case-insensitive). Caller uses {@link #synthRefForReturningLeaf}'s 23380 * single-pass count-then-pick policy to disambiguate. 23381 */ 23382 private static boolean relationCandidateMatch(RelationSource rs, 23383 String qualifier) { 23384 if (rs == null || qualifier == null || qualifier.isEmpty()) return false; 23385 String a = rs.getAlias(); 23386 if (a != null && a.equalsIgnoreCase(qualifier)) return true; 23387 String qn = rs.getBinding() == null ? null 23388 : rs.getBinding().getQualifiedName(); 23389 if (qn == null) return false; 23390 return qn.equalsIgnoreCase(qualifier) 23391 || bareLastDotComponent(qn).equalsIgnoreCase(qualifier); 23392 } 23393 23394 /** 23395 * Slice 85 helper — true when the target table is a candidate 23396 * match for the qualifier (alias / qualifiedName / bare 23397 * component, case-insensitive). 23398 */ 23399 private static boolean targetCandidateMatch(String targetAlias, 23400 String targetQName, 23401 String qualifier) { 23402 if (qualifier == null || qualifier.isEmpty()) return false; 23403 if (targetAlias != null && targetAlias.equalsIgnoreCase(qualifier)) { 23404 return true; 23405 } 23406 if (targetQName != null 23407 && (targetQName.equalsIgnoreCase(qualifier) 23408 || bareLastDotComponent(targetQName) 23409 .equalsIgnoreCase(qualifier))) { 23410 return true; 23411 } 23412 return false; 23413 } 23414 23415 /** 23416 * Slice 85 helper — strip everything up to and including the 23417 * last dot in a qualified name. Returns the input unchanged when 23418 * no dot is present. 23419 */ 23420 private static String bareLastDotComponent(String qname) { 23421 if (qname == null) return ""; 23422 int dot = qname.lastIndexOf('.'); 23423 return (dot < 0) ? qname : qname.substring(dot + 1); 23424 } 23425 23426 /** 23427 * Slice 85 helper — map a ColumnRef.relationAlias to the qualified 23428 * table name used on the LineageEdge {@code to} endpoint. INSERTED 23429 * / DELETED both collapse to the target's qualified name; FROM-side 23430 * aliases route to their bound qualifiedName; target alias / qname 23431 * stays on the target; unknown aliases pass through verbatim. 23432 */ 23433 private static String resolveReturningEdgeTarget( 23434 String alias, String targetAlias, String targetQName, 23435 List<RelationSource> fromSideRelations) { 23436 if ("INSERTED".equals(alias) || "DELETED".equals(alias)) { 23437 return targetQName; 23438 } 23439 if (targetAlias != null && targetAlias.equalsIgnoreCase(alias)) { 23440 return targetQName; 23441 } 23442 if (targetQName != null && targetQName.equalsIgnoreCase(alias)) { 23443 return targetQName; 23444 } 23445 if (fromSideRelations != null) { 23446 for (RelationSource rs : fromSideRelations) { 23447 if (rs.getAlias() != null 23448 && rs.getAlias().equalsIgnoreCase(alias)) { 23449 String qn = rs.getBinding() == null ? null 23450 : rs.getBinding().getQualifiedName(); 23451 return (qn != null && !qn.isEmpty()) ? qn : alias; 23452 } 23453 } 23454 } 23455 return alias != null ? alias : targetQName; 23456 } 23457 23458 /** 23459 * Slice 123 — resolve a RETURNING/OUTPUT source ref's relation alias to 23460 * the statement index of a SUBQUERY-kind FROM-side relation that owns 23461 * its own producing {@link StatementGraph}, so the slice-85 walker can 23462 * emit a cross-stmt {@code STATEMENT_OUTPUT(dmlIdx, ret) → 23463 * STATEMENT_OUTPUT(subOrCteIdx, col)} edge instead of the fictitious 23464 * {@code TABLE_COLUMN(<alias>, col)} edge. 23465 * 23466 * <p>Returns the producing statement index iff ALL of: 23467 * <ul> 23468 * <li>{@code fromSideAliasToStmtIndex} is non-empty and {@code alias} 23469 * is non-empty;</li> 23470 * <li>{@code alias} is NOT an OUTPUT pseudo-table ({@code INSERTED} / 23471 * {@code DELETED}) and does NOT match the target alias or qname — 23472 * those stay on the slice-85 {@code TABLE_COLUMN(targetQName)} 23473 * path (mirrors {@link #resolveReturningEdgeTarget}'s priority);</li> 23474 * <li>a {@link RelationSource} in {@code fromSideRelations} matches the 23475 * alias (case-insensitive) AND is {@link RelationKind#SUBQUERY}-kind 23476 * (mirrors the slice-83 {@link #emitUpdateSubquerySourceEdges} 23477 * filter — base TABLE-kind FROM relations are never promoted);</li> 23478 * <li>the alias has an entry in {@code fromSideAliasToStmtIndex} 23479 * (keyed lowercase).</li> 23480 * </ul> 23481 * Otherwise returns {@code null} and the caller keeps the TABLE_COLUMN 23482 * edge. No producer-column publication check (consistent with 23483 * {@link #emitUpdateSubquerySourceEdges}). 23484 * 23485 * <p>Invariant — the {@link RelationKind#SUBQUERY} filter and the map are 23486 * consistent by construction, so the filter never spuriously suppresses a 23487 * real producer. Every alias in {@code fromSideAliasToStmtIndex} comes from 23488 * one of three sources, all of which add only SUBQUERY-kind relations for 23489 * the matching alias: 23490 * <ul> 23491 * <li>{@link #buildDeleteCombinedAliasToSubIdx} / 23492 * {@link #buildUpdateCombinedAliasToSubIdx} (DELETE / UPDATE), whose 23493 * two entry sources are (a) slice-84/83 FROM-subquery aliases — 23494 * always SUBQUERY-kind relations — and (b) slice-106/105 23495 * CTE-as-FROM-relation aliases, which {@code buildDeleteRelation} / 23496 * {@code buildUpdateRelation} record as SUBQUERY-kind (NOT 23497 * {@link RelationKind#CTE});</li> 23498 * <li>slice-124 MERGE {@code aliasToSubIdx}, whose entries are the 23499 * USING alias for the USING-(SELECT) subquery branch and for the 23500 * USING-as-CTE branch — both add a SUBQUERY-kind relation. The 23501 * USING-as-CTE branch also registers an inert bare-CTE-name key with 23502 * no matching relation in {@code fromSideRelations}, so it is never 23503 * reached by the per-alias relation match below.</li> 23504 * </ul> 23505 * The filter is kept as defense-in-depth (a stale TABLE-kind entry would 23506 * never be promoted) and for parity with the slice-83 23507 * {@code emitUpdateSubquerySourceEdges}, which carries the identical 23508 * SUBQUERY-kind guard. 23509 */ 23510 private static Integer returningSubquerySourceIndex( 23511 String alias, String targetAlias, String targetQName, 23512 List<RelationSource> fromSideRelations, 23513 Map<String, Integer> fromSideAliasToStmtIndex) { 23514 if (alias == null || alias.isEmpty()) return null; 23515 if (fromSideAliasToStmtIndex == null 23516 || fromSideAliasToStmtIndex.isEmpty()) { 23517 return null; 23518 } 23519 // Pseudo-tables and target self-refs are never cross-stmt sources. 23520 if ("INSERTED".equals(alias) || "DELETED".equals(alias)) return null; 23521 if (targetAlias != null && targetAlias.equalsIgnoreCase(alias)) { 23522 return null; 23523 } 23524 if (targetQName != null && targetQName.equalsIgnoreCase(alias)) { 23525 return null; 23526 } 23527 if (fromSideRelations == null) return null; 23528 for (RelationSource rs : fromSideRelations) { 23529 if (rs.getAlias() == null 23530 || !rs.getAlias().equalsIgnoreCase(alias)) { 23531 continue; 23532 } 23533 if (rs.getBinding() == null 23534 || rs.getBinding().getKind() != RelationKind.SUBQUERY) { 23535 return null; 23536 } 23537 return fromSideAliasToStmtIndex.get( 23538 alias.toLowerCase(Locale.ROOT)); 23539 } 23540 return null; 23541 } 23542 23543 /** 23544 * Slice 85 helper — extract the bare column name from a TObjectName 23545 * leaf, honouring partToken / propertyToken / objectToken in the 23546 * order set by the parser. Returns null when no column-name token 23547 * is present. 23548 */ 23549 private static String bareColumnNameOf(TObjectName node) { 23550 // partToken is the column name in `qualifier.col` form; 23551 // for bare `col`, the parser may put it on objectToken. 23552 if (node.getPartToken() != null) { 23553 return node.getPartToken().toString(); 23554 } 23555 if (node.getColumnNameOnly() != null 23556 && !node.getColumnNameOnly().isEmpty()) { 23557 return node.getColumnNameOnly(); 23558 } 23559 if (node.getObjectToken() != null) { 23560 return node.getObjectToken().toString(); 23561 } 23562 return null; 23563 } 23564 23565 /** 23566 * Slice 85 helper — qualifier (table alias or schema-table) of a 23567 * column-name reference. Returns null for bare references. 23568 */ 23569 private static String qualifierOf(TObjectName node) { 23570 // For `t.col`, parser populates objectToken=t, partToken=col. 23571 if (node.getPartToken() != null && node.getObjectToken() != null) { 23572 return node.getObjectToken().toString(); 23573 } 23574 return null; 23575 } 23576 23577 /** 23578 * Slice 85 helper — pseudo-table partToken column name for OUTPUT 23579 * INSERTED.col / DELETED.col. Falls back to the bare column name 23580 * when partToken is null (e.g. raw bare reference). 23581 */ 23582 private static String partColumnNameOf(TObjectName node, String fallbackColName) { 23583 if (node.getPartToken() != null) { 23584 return node.getPartToken().toString(); 23585 } 23586 return fallbackColName; 23587 } 23588 23589 private static void rejectWindowFunctionInScope(gudusoft.gsqlparser.nodes.TParseTreeNode root, 23590 String clauseLabel) { 23591 if (root == null) return; 23592 final boolean[] found = {false}; 23593 root.acceptChildren(new TParseTreeVisitor() { 23594 @Override 23595 public void preVisit(TFunctionCall fn) { 23596 if (found[0]) return; 23597 if (fn.getWindowDef() != null) found[0] = true; 23598 } 23599 }); 23600 if (found[0]) { 23601 throw new SemanticIRBuildException( 23602 Diagnostic.error(DiagnosticCode.CLAUSE_WINDOW_FUNCTION_LEAK, 23603 clauseLabel + " contains a window function (OVER (...)); " 23604 + "window functions are not allowed in " + clauseLabel 23605 + " per standard SQL", root)); 23606 } 23607 } 23608 23609 private static String effectiveOutputName(TResultColumn rc) { 23610 String alias = rc.getColumnAlias(); 23611 if (alias != null && !alias.isEmpty()) { 23612 return alias; 23613 } 23614 String colName = rc.getColumnNameOnly(); 23615 if (colName != null && !colName.isEmpty()) { 23616 return colName; 23617 } 23618 throw new SemanticIRBuildException( 23619 Diagnostic.error(DiagnosticCode.RESULT_COLUMN_NO_NAME, 23620 "result column " + rc + " has neither alias nor column name", rc)); 23621 } 23622 23623 private static List<ColumnRef> buildFilterColumnRefs(TSelectSqlStatement select, 23624 NameBindingProvider provider, 23625 boolean allowPredicateSubqueries, 23626 List<StatementGraph> stmtsForExtraction, 23627 List<LineageEdge> lineageForExtraction, 23628 Map<String, Integer> cteMapForExtraction, 23629 PredicateClauseContext whereClauseContext, 23630 List<SemiJoinFact> semiFactsOut, 23631 Set<String> outerRelationAliases) { 23632 TWhereClause where = select.getWhereClause(); 23633 if (where == null || where.getCondition() == null) { 23634 return new ArrayList<>(); 23635 } 23636 Set<TExpression> extractedWhereRoots = 23637 Collections.<TExpression>emptySet(); 23638 if (containsAnySubquery(where)) { 23639 if (!allowPredicateSubqueries) { 23640 // Slice 112 — non-outer SELECTs (FROM-subquery, scalar 23641 // projection subquery body, predicate body) keep the 23642 // slice-80 blanket reject. The outermost SELECT path 23643 // (slice 112) and set-op branch path (slice 113) thread 23644 // {@code allowPredicateSubqueries=true} plus the live 23645 // extraction context so the slice-23+ walker can lift 23646 // uncorrelated predicate-subquery wrappers. Inner 23647 // contexts also have earlier preflight rejecters 23648 // ({@code rejectSubqueriesInFromSubqueryBodyClauses} for 23649 // FROM-subquery bodies, {@code rejectSubqueriesInPredicateBodyClauses} 23650 // for slice-23 predicate bodies); this remains the 23651 // fallback path for any unanticipated nested SELECTs 23652 // that bypass those preflights. 23653 throw new SemanticIRBuildException( 23654 Diagnostic.error(DiagnosticCode.WHERE_HAS_SUBQUERY_NOT_SUPPORTED, 23655 "WHERE clause contains a subquery; subqueries in WHERE " 23656 + "are not supported yet in nested SELECTs", 23657 select)); 23658 } 23659 // Slice 112 / 113 — outer SELECT WHERE and set-op branch 23660 // WHERE lift the slice-80 blanket subquery reject by 23661 // routing uncorrelated predicate-subquery wrappers 23662 // (IN-SELECT / EXISTS / NOT EXISTS / scalar comparison / 23663 // ANY-ALL-SOME) through the slice-23+ JOIN-ON extraction 23664 // pipeline refactored by slice 110 to take a 23665 // PredicateClauseContext. Slice 112 added the SELECT_WHERE 23666 // constant for outer SELECT WHERE; slice 113 adds the 23667 // SET_OP_BRANCH_WHERE constant for nested set-op branch 23668 // WHERE — both reuse the same SELECT_WHERE_* DiagnosticCode 23669 // family (a branch IS a SELECT, only nested) and differ 23670 // only in the {@code clauseLabel} for diagnostic messages. 23671 // Each extracted wrapper lands as its own 23672 // <predicate_subquery_<i>> StatementGraph BEFORE the host 23673 // outer SELECT or set-op branch in {@code stmts} (selectIdx 23674 // = stmts.size() naturally accounts for them — slice-83 23675 // dynamic-index pattern, slice 110/111 precedent). 23676 // 23677 // Remaining non-subquery refs flow into filterColumnRefs 23678 // via collectColumnRefsSkipping. Window functions in 23679 // non-subquery subtrees still reject via 23680 // rejectWindowFunctionInScopeSkipping. The {@code provider} 23681 // already carries withCteContext / withInScopeRelationColumns 23682 // from the outer build chain (slice-65 withUsingScope 23683 // preserves both facets), so the predicate body's inner 23684 // FROM cte routes through RelationKind.CTE and the body's 23685 // own lineage edge becomes STATEMENT_OUTPUT(predicateIdx, 23686 // col) -> STATEMENT_OUTPUT(cteIdx, col) instead of 23687 // TABLE_COLUMN (slice 110/111 precedent). 23688 extractedWhereRoots = 23689 extractUncorrelatedPredicateSubqueriesFromClause( 23690 where.getCondition(), provider, 23691 stmtsForExtraction, lineageForExtraction, 23692 cteMapForExtraction, 23693 whereClauseContext, 23694 /*correlationScope=*/ null, 23695 semiFactsOut, 23696 outerRelationAliases); 23697 rejectAnyRemainingSubqueriesFromClause( 23698 where.getCondition(), extractedWhereRoots, 23699 whereClauseContext); 23700 } 23701 // Slice 13: reject window functions in WHERE before 23702 // collectColumnRefs descends into OVER (...) and leaks 23703 // PARTITION BY / OVER ORDER BY refs into filterColumnRefs. 23704 // Slice 112 — skip extracted predicate-subquery subtrees so 23705 // inner window functions do not leak into the outer reject 23706 // (mirrors the slice-110/111 UPDATE/DELETE WHERE behaviour). 23707 rejectWindowFunctionInScopeSkipping(where, "WHERE clause", 23708 extractedWhereRoots); 23709 return collectColumnRefsSkipping(where, provider, extractedWhereRoots); 23710 } 23711 23712 /** 23713 * Slice 65 — shared visitor body that emits either the merged-key 23714 * source list (when {@code node} is an unqualified reference to a 23715 * USING merged key in the current SELECT's 23716 * {@link UsingScope}) or the resolver2-bound {@link ColumnRef}. 23717 * 23718 * <p>Used by every visitor that walks expression subtrees and 23719 * collects column refs ({@link #collectColumnRefs}, 23720 * {@link #collectColumnRefsSkippingExtended}, the derived FILTER / 23721 * WITHIN-GROUP-excluding variants). Each visitor remains 23722 * responsible for its own skip-depth and nested-SELECT-depth 23723 * tracking; only the column-emit body is shared. 23724 * 23725 * <p>Behavior: 23726 * <ul> 23727 * <li>If the node is not a column, name is null/empty/star → no-op.</li> 23728 * <li>If the node is unqualified AND its name matches a USING 23729 * key in {@code provider.getUsingScope()} AND that scope 23730 * reports the reference as ambiguous (two disconnected 23731 * classes, or a catalog-proven out-of-class same-named 23732 * relation) → throw {@link SemanticIRBuildException}.</li> 23733 * <li>Otherwise if the unqualified name matches a USING key 23734 * unambiguously → emit each {@link ColumnRef} from the 23735 * merged source list (FROM-ordered, deduped per relation).</li> 23736 * <li>Otherwise → delegate to 23737 * {@link NameBindingProvider#bindColumn} and emit the bound 23738 * {@link ColumnRef}; any non-EXACT_MATCH binding records a 23739 * reject (caller throws after collecting all rejects).</li> 23740 * </ul> 23741 * 23742 * <p>The qualifier check is the SQL-written prefix 23743 * ({@link TObjectName#getTableString()}); when present the 23744 * merged-key path is skipped so {@code a.k} continues to resolve 23745 * to {@code (a, k)} regardless of {@code k}'s USING-key status. 23746 */ 23747 private static void appendMergedOrBoundColumnRef( 23748 TObjectName node, 23749 NameBindingProvider provider, 23750 LinkedHashSet<ColumnRef> refsOut, 23751 List<String> rejectsOut, 23752 boolean[] sawQualifiedRejectOut) { 23753 if (node.getDbObjectType() != EDbObjectType.column) return; 23754 String name = node.getColumnNameOnly(); 23755 if (name == null || "*".equals(name)) return; 23756 UsingScope scope = provider.getUsingScope(); 23757 String qualifier = node.getTableString(); 23758 if ((qualifier == null || qualifier.isEmpty()) && scope.has(name)) { 23759 if (scope.isAmbiguous(name)) { 23760 throw new SemanticIRBuildException( 23761 Diagnostic.error(DiagnosticCode.UNQUALIFIED_COLUMN_AMBIGUOUS, 23762 "unqualified reference to '" + name + "' is ambiguous: " 23763 + scope.ambiguityReason(name) 23764 + "; qualify with a table alias", null)); 23765 } 23766 for (ColumnRef ref : scope.mergedSourcesFor(name)) { 23767 refsOut.add(ref); 23768 } 23769 return; 23770 } 23771 ColumnBinding binding = provider.bindColumn(node); 23772 boolean unqualified = (qualifier == null || qualifier.isEmpty()); 23773 // Catalog-less NATURAL JOIN degrade (GSP R6): an unqualified 23774 // reference that fails to bind exactly, when the FROM clause carries 23775 // a catalog-less NATURAL join, is a reference to the merged NATURAL 23776 // key — unambiguous under NATURAL semantics. Resolve it to the 23777 // deterministic left-side relation instead of failing the whole 23778 // analysis. Only fires for NATURAL FROM clauses; plain-join 23779 // unqualified ambiguity still rejects below. 23780 if (unqualified 23781 && (binding == null || binding.getStatus() != ResolutionStatus.EXACT_MATCH) 23782 && scope.hasNaturalDegrade()) { 23783 refsOut.add(new ColumnRef(scope.naturalDegradeFallbackAlias(), name)); 23784 return; 23785 } 23786 if (binding == null) { 23787 rejectsOut.add(node + "[no binding]"); 23788 if (!unqualified && sawQualifiedRejectOut != null) { 23789 sawQualifiedRejectOut[0] = true; 23790 } 23791 return; 23792 } 23793 if (binding.getStatus() != ResolutionStatus.EXACT_MATCH) { 23794 rejectsOut.add(node + "[" + binding.getStatus() + "]"); 23795 if (!unqualified && sawQualifiedRejectOut != null) { 23796 sawQualifiedRejectOut[0] = true; 23797 } 23798 return; 23799 } 23800 // Slice 164 (S3): attach the source-text span of the originating 23801 // TObjectName. Slice 165 (S4): attach the GAP-5 resolution from the 23802 // binding's final table. Both are excluded from ColumnRef identity, 23803 // so the LinkedHashSet dedupe and emission order are unaffected. 23804 refsOut.add(new ColumnRef(binding.getRelationAlias(), binding.getColumnName(), 23805 null, SourceSpan.of(node), resolutionOf(binding))); 23806 } 23807 23808 /** 23809 * Slice 165 (S4) — map a {@link ColumnBinding}'s final table to an 23810 * explicit {@link ColumnResolution} (GAP 5). A non-null final-table 23811 * qualified name yields RESOLVED; a null one yields UNRESOLVED (the 23812 * resolver did not produce a base table — never fabricated). This path 23813 * only sees EXACT_MATCH bindings; non-exact / null bindings are 23814 * rejected upstream, so strict diagnostics are not loosened. 23815 */ 23816 static ColumnResolution resolutionOf(ColumnBinding binding) { 23817 String finalTableQn = binding.getFinalTableQualifiedName(); 23818 return (finalTableQn != null && !finalTableQn.isEmpty()) 23819 ? ColumnResolution.resolved(finalTableQn) 23820 : ColumnResolution.UNRESOLVED; 23821 } 23822 23823 /** 23824 * Visit every column-typed {@link TObjectName} reachable from the given 23825 * subtree, ask the provider to bind it, and return de-duplicated 23826 * {@link ColumnRef}s. Any non-EXACT_MATCH binding aborts the build. 23827 * 23828 * <p>Slice 65: when the provider carries a non-empty 23829 * {@link UsingScope}, unqualified references that match a USING 23830 * merged key are expanded to the merged source list (one ref per 23831 * relation in the equivalence class) before delegating to 23832 * {@link NameBindingProvider#bindColumn}. See 23833 * {@link #appendMergedOrBoundColumnRef}. 23834 */ 23835 private static List<ColumnRef> collectColumnRefs(gudusoft.gsqlparser.nodes.TParseTreeNode root, 23836 final NameBindingProvider provider) { 23837 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 23838 final List<String> rejects = new ArrayList<>(); 23839 // True if any reject is a QUALIFIED column miss (e.g. b.id pointing at 23840 // the wrong side). Such a miss is a genuine error and must stay fatal; 23841 // only all-unqualified rejects are eligible for the join-graph degrade. 23842 final boolean[] sawQualifiedReject = {false}; 23843 root.acceptChildren(new TParseTreeVisitor() { 23844 int nestedSelectDepth = 0; 23845 23846 @Override 23847 public void preVisit(TSelectSqlStatement nested) { 23848 nestedSelectDepth++; 23849 } 23850 23851 @Override 23852 public void postVisit(TSelectSqlStatement nested) { 23853 nestedSelectDepth--; 23854 } 23855 23856 @Override 23857 public void preVisit(TObjectName node) { 23858 if (nestedSelectDepth > 0) return; 23859 appendMergedOrBoundColumnRef(node, provider, refs, rejects, 23860 sawQualifiedReject); 23861 } 23862 }); 23863 if (!rejects.isEmpty()) { 23864 rejectNonExactBindings(rejects, provider, !sawQualifiedReject[0]); 23865 } 23866 return new ArrayList<>(refs); 23867 } 23868 23869 /** 23870 * Tolerant variant of {@link #collectColumnRefs} for the MySQL 23871 * self-reference DELETE path (slice 92 Codex P1 fix). 23872 * 23873 * <p>The MySQL parser populates {@code stmt.tables} with 3 entries for 23874 * {@code DELETE T1 FROM T1 WHERE id = 1} (target + {@code joins[0]} + 23875 * {@code referenceJoins[0]}). Resolver2's {@code inferredCandidates} 23876 * then sees 3 candidates for any unqualified column ref and marks the 23877 * binding as NOT_FOUND, which {@link #collectColumnRefs} rejects as 23878 * {@code COLUMN_BINDING_NON_EXACT}. 23879 * 23880 * <p>This variant emits EXACT_MATCH bindings verbatim and falls back 23881 * to the SQL-written qualifier (or {@code null} for unqualified refs) 23882 * for non-exact bindings instead of throwing. Subquery children are 23883 * not descended into (matches the strict collector's behaviour). 23884 */ 23885 /** 23886 * @param fallbackRelationAlias used when the binding is non-exact and the 23887 * SQL-written qualifier is absent; for the MySQL self-reference path 23888 * this is {@code targetQName} so unqualified refs like 23889 * {@code WHERE id = 1} emit {@code ColumnRef(targetName, "id")} 23890 * instead of crashing on the non-null constraint on 23891 * {@link ColumnRef#ColumnRef(String, String)}. 23892 */ 23893 private static List<ColumnRef> collectColumnRefsTolerant( 23894 gudusoft.gsqlparser.nodes.TParseTreeNode root, 23895 final NameBindingProvider provider, 23896 final String fallbackRelationAlias) { 23897 return collectColumnRefsTolerant(root, provider, fallbackRelationAlias, 23898 Collections.<TExpression>emptySet()); 23899 } 23900 23901 /** 23902 * Slice 111 — variant of the slice-92 tolerant collector that also 23903 * skips any descendants of {@code skipRoots} (extracted predicate 23904 * subquery wrappers). Mirrors the 23905 * {@link #collectColumnRefsSkipping} skipping behavior so DELETE 23906 * WHERE-side IN-SELECT / EXISTS / scalar-comparison wrappers 23907 * extracted by 23908 * {@link #extractUncorrelatedPredicateSubqueriesFromClause} are 23909 * not double-collected as outer filter refs on the MySQL self-ref 23910 * DELETE path. For the non-self-ref DELETE path the 23911 * {@link #collectColumnRefsSkipping} helper handles the same job; 23912 * this helper exists only for the slice-92 path which needs the 23913 * tolerant binding behavior to survive Resolver2's 23914 * NOT_FOUND / NON_EXACT bindings on unqualified self-ref refs. 23915 */ 23916 private static List<ColumnRef> collectColumnRefsTolerant( 23917 gudusoft.gsqlparser.nodes.TParseTreeNode root, 23918 final NameBindingProvider provider, 23919 final String fallbackRelationAlias, 23920 final Set<TExpression> skipRoots) { 23921 final LinkedHashSet<ColumnRef> refs = new LinkedHashSet<>(); 23922 // Root fast path: if root IS a skipped TExpression subtree, return empty. 23923 if (root instanceof TExpression && skipRoots.contains(root)) { 23924 return new ArrayList<>(refs); 23925 } 23926 root.acceptChildren(new TParseTreeVisitor() { 23927 int nestedSelectDepth = 0; 23928 int skipDepth = 0; 23929 23930 @Override 23931 public void preVisit(TExpression e) { 23932 if (skipRoots.contains(e)) skipDepth++; 23933 } 23934 23935 @Override 23936 public void postVisit(TExpression e) { 23937 if (skipRoots.contains(e) && skipDepth > 0) skipDepth--; 23938 } 23939 23940 @Override 23941 public void preVisit(TSelectSqlStatement nested) { 23942 nestedSelectDepth++; 23943 } 23944 23945 @Override 23946 public void postVisit(TSelectSqlStatement nested) { 23947 nestedSelectDepth--; 23948 } 23949 23950 @Override 23951 public void preVisit(TObjectName node) { 23952 if (skipDepth > 0) return; 23953 if (nestedSelectDepth > 0) return; 23954 if (node.getDbObjectType() != EDbObjectType.column) return; 23955 String name = node.getColumnNameOnly(); 23956 if (name == null || "*".equals(name)) return; 23957 ColumnBinding binding = provider.bindColumn(node); 23958 if (binding != null 23959 && binding.getStatus() == ResolutionStatus.EXACT_MATCH) { 23960 refs.add(new ColumnRef( 23961 binding.getRelationAlias(), binding.getColumnName())); 23962 } else { 23963 // Non-exact or null binding: prefer SQL-written qualifier; 23964 // fall back to the single delete-target name so the 23965 // ColumnRef non-null constraint is satisfied. 23966 String qualifier = node.getTableString(); 23967 String alias = (qualifier != null && !qualifier.isEmpty()) 23968 ? qualifier : fallbackRelationAlias; 23969 refs.add(new ColumnRef(alias, name)); 23970 } 23971 } 23972 }); 23973 return new ArrayList<>(refs); 23974 } 23975 23976 /** 23977 * Thrown when the input falls outside current builder scope or a 23978 * binding fails. Slice 67 attached a {@link Diagnostic} to every 23979 * throw site so external callers can pattern-match on 23980 * {@link DiagnosticCode} rather than parsing message text. The 23981 * legacy {@code (String)} constructor was removed in slice 67; 23982 * use one of the {@link Diagnostic#error} factories and the 23983 * {@link #SemanticIRBuildException(Diagnostic)} constructor. 23984 */ 23985 public static final class SemanticIRBuildException extends RuntimeException { 23986 private final List<Diagnostic> diagnostics; 23987 23988 public SemanticIRBuildException(Diagnostic diagnostic) { 23989 this(Collections.singletonList( 23990 java.util.Objects.requireNonNull(diagnostic, "diagnostic")), 23991 diagnostic.getMessage()); 23992 } 23993 23994 /** 23995 * Construct a rejection with its primary diagnostic first, followed by 23996 * any related diagnostics emitted atomically by the same build step. 23997 */ 23998 public static SemanticIRBuildException withDiagnostics( 23999 List<Diagnostic> diagnostics) { 24000 return new SemanticIRBuildException( 24001 diagnostics, primaryMessage(diagnostics)); 24002 } 24003 24004 private SemanticIRBuildException(List<Diagnostic> diagnostics, 24005 String primaryMessage) { 24006 super(primaryMessage); 24007 List<Diagnostic> copy = new ArrayList<>(diagnostics); 24008 for (Diagnostic diagnostic : copy) { 24009 java.util.Objects.requireNonNull(diagnostic, 24010 "diagnostics must not contain null"); 24011 } 24012 this.diagnostics = Collections.unmodifiableList(copy); 24013 } 24014 24015 /** 24016 * @return the structured diagnostic for this rejection. Always 24017 * non-null after slice 67. 24018 */ 24019 public Diagnostic getDiagnostic() { 24020 for (Diagnostic diagnostic : diagnostics) { 24021 if (diagnostic.getSeverity() == Severity.ERROR) { 24022 return diagnostic; 24023 } 24024 } 24025 return diagnostics.get(0); 24026 } 24027 24028 /** @return immutable complete diagnostic list; never empty. */ 24029 public List<Diagnostic> getDiagnostics() { 24030 return diagnostics; 24031 } 24032 24033 private static String primaryMessage(List<Diagnostic> diagnostics) { 24034 java.util.Objects.requireNonNull(diagnostics, "diagnostics"); 24035 if (diagnostics.isEmpty()) { 24036 throw new IllegalArgumentException("diagnostics must not be empty"); 24037 } 24038 Diagnostic first = null; 24039 for (Diagnostic diagnostic : diagnostics) { 24040 Diagnostic nonNull = java.util.Objects.requireNonNull( 24041 diagnostic, "diagnostics must not contain null"); 24042 if (first == null) { 24043 first = nonNull; 24044 } 24045 if (nonNull.getSeverity() == Severity.ERROR) { 24046 return nonNull.getMessage(); 24047 } 24048 } 24049 return first.getMessage(); 24050 } 24051 } 24052}