001package gudusoft.gsqlparser.resolver2.namespace; 002 003import gudusoft.gsqlparser.EExpressionType; 004import gudusoft.gsqlparser.nodes.TResultColumn; 005import gudusoft.gsqlparser.nodes.TResultColumnList; 006import gudusoft.gsqlparser.nodes.TTable; 007import gudusoft.gsqlparser.resolver2.ColumnLevel; 008import gudusoft.gsqlparser.resolver2.matcher.INameMatcher; 009import gudusoft.gsqlparser.resolver2.model.ColumnSource; 010import gudusoft.gsqlparser.sqlenv.TSQLEnv; 011import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 012 013import java.util.ArrayDeque; 014import java.util.ArrayList; 015import java.util.Collections; 016import java.util.Deque; 017import java.util.HashSet; 018import java.util.LinkedHashMap; 019import java.util.List; 020import java.util.Map; 021import java.util.Set; 022 023/** 024 * Namespace representing a subquery. 025 * Provides columns from the subquery's SELECT list. 026 * 027 * Example: 028 * FROM (SELECT id, name FROM users) AS t 029 * ^^^^^^^^^^^^^^^^^^^^^^^^ 030 * SubqueryNamespace exposes columns: id, name 031 */ 032public class SubqueryNamespace extends AbstractNamespace { 033 034 private final TSelectSqlStatement subquery; 035 private final String alias; 036 037 /** Inferred columns from star push-down */ 038 private Map<String, ColumnSource> inferredColumns; 039 040 /** Track inferred column names for getInferredColumns() */ 041 private Set<String> inferredColumnNames; 042 043 /** Traced columns from qualified references (e.g., table1.x in ON conditions) */ 044 private Map<String, TTable> tracedColumnTables; 045 046 /** Whether this namespace was created from a TABLE function */ 047 private final boolean fromTableFunction; 048 049 /** 050 * Cache for nested SubqueryNamespace objects to avoid repeated creation. 051 * Key: TSelectSqlStatement, Value: validated SubqueryNamespace 052 */ 053 private Map<TSelectSqlStatement, SubqueryNamespace> nestedSubqueryCache; 054 055 /** 056 * Cache for nested TableNamespace objects to avoid repeated creation. 057 * Key: TTable, Value: validated TableNamespace 058 */ 059 private Map<TTable, TableNamespace> nestedTableCache; 060 061 /** The TTable that wraps this subquery (for legacy sync) */ 062 private TTable sourceTable; 063 064 /** TSQLEnv for looking up table metadata during star column resolution */ 065 private TSQLEnv sqlEnv; 066 067 /** 068 * Memoized result of the qualified-star SELECT-list scan (see 069 * {@link #scanQualifiedStarMatchTable()}). Both a found table and a null 070 * result are cached, and the cache never needs invalidation because the 071 * scanned value is a pure function of parse-time-immutable state. 072 */ 073 private boolean qualifiedStarScanned; 074 private TTable qualifiedStarMatchTable; 075 076 public SubqueryNamespace(TSelectSqlStatement subquery, 077 String alias, 078 INameMatcher nameMatcher) { 079 this(subquery, alias, nameMatcher, false); 080 } 081 082 public SubqueryNamespace(TSelectSqlStatement subquery, 083 String alias, 084 INameMatcher nameMatcher, 085 boolean fromTableFunction) { 086 super(subquery, nameMatcher); 087 this.subquery = subquery; 088 this.alias = alias; 089 this.fromTableFunction = fromTableFunction; 090 } 091 092 public SubqueryNamespace(TSelectSqlStatement subquery, String alias) { 093 super(subquery); 094 this.subquery = subquery; 095 this.alias = alias; 096 this.fromTableFunction = false; 097 } 098 099 /** 100 * Gets or creates a cached nested SubqueryNamespace that inherits the guessColumnStrategy from this namespace. 101 * This ensures config-based isolation propagates through nested resolution. 102 * The namespace is cached to avoid repeated creation and validation for the same subquery. 103 * Also propagates sqlEnv for metadata lookup in star column resolution. 104 * 105 * @param subquery The subquery statement 106 * @param alias The alias for the subquery (not used as cache key, only for display) 107 * @return A validated SubqueryNamespace (cached or newly created) 108 */ 109 private SubqueryNamespace getOrCreateNestedNamespace(TSelectSqlStatement subquery, String alias) { 110 if (subquery == null) { 111 return null; 112 } 113 // Initialize cache lazily 114 if (nestedSubqueryCache == null) { 115 nestedSubqueryCache = new java.util.HashMap<>(); 116 } 117 // Check cache first 118 SubqueryNamespace cached = nestedSubqueryCache.get(subquery); 119 if (cached != null) { 120 return cached; 121 } 122 // Create new namespace 123 SubqueryNamespace nested = new SubqueryNamespace(subquery, alias, nameMatcher); 124 // Propagate guessColumnStrategy for config-based isolation 125 if (this.guessColumnStrategy >= 0) { 126 nested.setGuessColumnStrategy(this.guessColumnStrategy); 127 } 128 // Propagate sqlEnv for metadata lookup during star column resolution 129 if (this.sqlEnv != null) { 130 nested.setSqlEnv(this.sqlEnv); 131 } 132 // Validate and cache 133 nested.validate(); 134 nestedSubqueryCache.put(subquery, nested); 135 return nested; 136 } 137 138 /** 139 * Gets or creates a cached TableNamespace. 140 * The namespace is cached to avoid repeated creation and validation for the same table. 141 * Passes sqlEnv to enable metadata lookup for star column resolution. 142 * 143 * @param table The table 144 * @return A validated TableNamespace (cached or newly created) 145 */ 146 private TableNamespace getOrCreateTableNamespace(TTable table) { 147 if (table == null) { 148 return null; 149 } 150 // Initialize cache lazily 151 if (nestedTableCache == null) { 152 nestedTableCache = new java.util.HashMap<>(); 153 } 154 // Check cache first 155 TableNamespace cached = nestedTableCache.get(table); 156 if (cached != null) { 157 return cached; 158 } 159 // Create new namespace with sqlEnv for metadata lookup 160 TableNamespace tableNs = new TableNamespace(table, nameMatcher, sqlEnv); 161 // Validate and cache 162 tableNs.validate(); 163 nestedTableCache.put(table, tableNs); 164 return tableNs; 165 } 166 167 /** 168 * Creates a nested SubqueryNamespace that inherits the guessColumnStrategy from this namespace. 169 * This method is deprecated - use getOrCreateNestedNamespace for caching support. 170 * 171 * @param subquery The subquery statement 172 * @param alias The alias for the subquery 173 * @return A new SubqueryNamespace with inherited guessColumnStrategy and sqlEnv 174 * @deprecated Use getOrCreateNestedNamespace instead 175 */ 176 @Deprecated 177 private SubqueryNamespace createNestedNamespace(TSelectSqlStatement subquery, String alias) { 178 SubqueryNamespace nested = new SubqueryNamespace(subquery, alias, nameMatcher); 179 // Propagate guessColumnStrategy for config-based isolation 180 if (this.guessColumnStrategy >= 0) { 181 nested.setGuessColumnStrategy(this.guessColumnStrategy); 182 } 183 // Propagate sqlEnv for metadata lookup 184 if (this.sqlEnv != null) { 185 nested.setSqlEnv(this.sqlEnv); 186 } 187 return nested; 188 } 189 190 /** 191 * Returns true if this namespace was created from a TABLE function's subquery. 192 */ 193 public boolean isFromTableFunction() { 194 return fromTableFunction; 195 } 196 197 /** 198 * Set the TTable that wraps this subquery. 199 * Used by ScopeBuilder for legacy sync support. 200 * 201 * @param sourceTable the TTable that contains this subquery 202 */ 203 public void setSourceTable(TTable sourceTable) { 204 this.sourceTable = sourceTable; 205 } 206 207 @Override 208 public TTable getSourceTable() { 209 return sourceTable; 210 } 211 212 /** 213 * Set the TSQLEnv for metadata lookup. 214 * Used when resolving star columns to find which columns exist in underlying tables. 215 * 216 * @param sqlEnv the SQL environment containing table metadata 217 */ 218 public void setSqlEnv(TSQLEnv sqlEnv) { 219 this.sqlEnv = sqlEnv; 220 } 221 222 /** 223 * Get the TSQLEnv used for metadata lookup. 224 * 225 * @return the SQL environment, or null if not set 226 */ 227 public TSQLEnv getSqlEnv() { 228 return sqlEnv; 229 } 230 231 @Override 232 public String getDisplayName() { 233 return alias != null ? alias : "<subquery>"; 234 } 235 236 /** Cache-after-completion memo of {@link #computeFinalTable()} (see that method's proof). */ 237 private boolean finalTableComputed; 238 private TTable finalTableValue; 239 240 @Override 241 public TTable getFinalTable() { 242 // Memoized. The flag is set ONLY after computeFinalTable() returns, so a 243 // re-entrant call during computation (e.g. a cyclic recursive CTE) still 244 // recomputes exactly as baseline did — no new guard, no behavior change; 245 // if baseline would not terminate, this does not terminate on the first 246 // call either. No invalidation is needed because the result is a pure 247 // function of parse-time-immutable state (see computeFinalTable()). 248 if (finalTableComputed) { 249 return finalTableValue; 250 } 251 TTable result = computeFinalTable(); 252 finalTableValue = result; 253 finalTableComputed = true; 254 return result; 255 } 256 257 /** 258 * Determine the underlying physical table a subquery resolves to. 259 * 260 * <p><b>Purity theorem (why {@link #getFinalTable()} may memoize this with no 261 * invalidation).</b> Under resolver2's operating contract — single-threaded 262 * resolution over a parser-produced AST that is frozen for the duration of the 263 * run, with a matcher that is a pure function of its arguments — this method is 264 * a pure function of parse-time-immutable state, so its result never changes 265 * across resolution passes.</p> 266 * 267 * <p>Proof sketch (verified by adversarial review):</p> 268 * <ul> 269 * <li>This method and its whole call graph ({@link #findTableFromQualifiedStar}, 270 * {@link #scanQualifiedStarMatchTable}, {@link #traceTableThroughCTE}, 271 * {@link #findFirstPhysicalTableFromJoin}, {@link #findTableByAliasOrName}, 272 * and nested {@link #getFinalTable}) read ONLY immutable AST: 273 * {@code subquery.tables}/{@code getRelations()}, {@code getTableType()}, 274 * {@code isCTEName()}/{@code getCTE()}, {@code getSubquery()}, 275 * {@code getJoinExpr()}/{@code getLeftTable()}, alias/name. They read NONE 276 * of the mutable namespace fields (inferredColumns, tracedColumnTables, 277 * columnSources, sourceTable, sqlEnv).</li> 278 * <li>Resolver2 never mutates those AST properties during a run: it does not 279 * call setCTE/setCTEName/setTableType/setSubquery, nor add/remove/reorder 280 * {@code tables}/{@code relations}/result columns. Pass 2+ mutates only 281 * resolution/inference state, which this method never reads.</li> 282 * <li>The qualified-star scan's only side effect (marking redundant newline 283 * tokens deleted in toString) is idempotent after the first call.</li> 284 * </ul> 285 * 286 * <p>The contract's preconditions (frozen AST, deterministic matcher, single 287 * thread) are NOT new: resolver2 already depends on them — an impure matcher or 288 * a mid-resolution AST mutation would corrupt resolution regardless of this 289 * cache. Because {@code TTable}/{@code TResultColumnList} remain publicly 290 * mutable, this is a resolver-internal optimization, not an API-wide theorem.</p> 291 */ 292 private TTable computeFinalTable() { 293 // Try to find the underlying table from the FROM clause 294 // This works for both star subqueries (SELECT *) and explicit column subqueries 295 if (subquery != null && subquery.tables != null && subquery.tables.size() > 0) { 296 // First check if there's a qualified star column (e.g., ta.* or tb.*) 297 // If so, we should return the table matching that alias 298 TTable qualifiedStarTable = findTableFromQualifiedStar(); 299 if (qualifiedStarTable != null) { 300 return qualifiedStarTable; 301 } 302 303 // For single-table subqueries, return that table 304 // For multi-table (JOIN/comma) subqueries, only return if we can determine source 305 TTable firstTable = subquery.tables.getTable(0); 306 if (firstTable != null) { 307 // If it's a physical table, return it - BUT only if we can identify the source 308 if (firstTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 309 // For multi-table subqueries without qualified star, we can't determine 310 // which table the column comes from. Don't blindly return first table. 311 if (subquery.tables.size() > 1 && qualifiedStarTable == null) { 312 return null; 313 } 314 // Check if it's a CTE reference - if so, trace through to physical table 315 if (firstTable.isCTEName() && firstTable.getCTE() != null) { 316 return traceTableThroughCTE(firstTable.getCTE()); 317 } 318 return firstTable; 319 } 320 // If it's a subquery, recursively trace (use cached namespace) 321 if (firstTable.getSubquery() != null) { 322 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 323 firstTable.getSubquery(), 324 firstTable.getAliasName() 325 ); 326 return nestedNs != null ? nestedNs.getFinalTable() : null; 327 } 328 // If it's a join, get the first base table from the join 329 if (firstTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 330 return findFirstPhysicalTableFromJoin(firstTable); 331 } 332 } 333 } 334 return null; 335 } 336 337 /** 338 * Find the table referenced by a qualified star column (e.g., ta.* -> table_a). 339 * Returns null if there's no qualified star or if the star is unqualified (*). 340 */ 341 private TTable findTableFromQualifiedStar() { 342 // The winning table (first qualified-star column whose prefix resolves to 343 // a table) is a pure function of parse-time-immutable state, so it is 344 // memoized in scanQualifiedStarMatchTable(). The subsequent CTE/subquery 345 // TRACING stays dynamic (it may go through nested namespaces whose final 346 // table can depend on accumulated resolution state), so it is NOT cached. 347 TTable matchingTable = scanQualifiedStarMatchTable(); 348 if (matchingTable != null) { 349 // Check for CTE reference first - trace through to physical table 350 if (matchingTable.isCTEName() && matchingTable.getCTE() != null) { 351 return traceTableThroughCTE(matchingTable.getCTE()); 352 } 353 // If the matching table is itself a subquery, trace through it (use cached namespace) 354 if (matchingTable.getSubquery() != null) { 355 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 356 matchingTable.getSubquery(), 357 matchingTable.getAliasName() 358 ); 359 return nestedNs != null ? nestedNs.getFinalTable() : null; 360 } 361 return matchingTable; 362 } 363 return null; 364 } 365 366 /** 367 * Scan the SELECT list for the first qualified star (e.g. {@code ta.*}) whose 368 * prefix resolves to a table, and return that table (the value baseline acted 369 * on before tracing). Memoized: baseline re-ran this scan on every 370 * getFinalTable() call across every resolution pass — O(passes x columns x 371 * toString) — but the result depends only on immutable state, so it is 372 * computed once per namespace. 373 * 374 * <p>Provably identical to the original inline loop:</p> 375 * <ul> 376 * <li>Same predicate: the EXACT {@code resultCol.toString().trim()} test 377 * (ends with {@code "*"} and contains {@code "."}), same prefix 378 * extraction, same {@link #findTableByAliasOrName}.</li> 379 * <li>"First non-null match wins" reproduces SubqueryNamespace's baseline, 380 * which returned unconditionally at the first non-null match. (This does 381 * NOT hold for CTENamespace, which only stops at a terminal-eligible 382 * match; CTENamespace is intentionally left unmemoized.)</li> 383 * <li>Purity holds under resolver2's operating contract: it never mutates a 384 * subquery's result-column list or a table's alias/name; INameMatcher.matches 385 * is a pure function of its arguments (all shipped matchers are); and 386 * TParseTreeNode.toString()'s only side effect (marking redundant newline 387 * tokens deleted) is idempotent after the first call, so computing the 388 * scan once leaves the same token state as baseline's repeated calls.</li> 389 * </ul> 390 */ 391 private TTable scanQualifiedStarMatchTable() { 392 if (qualifiedStarScanned) { 393 return qualifiedStarMatchTable; 394 } 395 TTable found = null; 396 if (subquery != null && subquery.getResultColumnList() != null) { 397 TResultColumnList selectList = subquery.getResultColumnList(); 398 for (int i = 0; i < selectList.size(); i++) { 399 TResultColumn resultCol = selectList.getResultColumn(i); 400 if (resultCol == null) continue; 401 402 String colStr = resultCol.toString().trim(); 403 // Check if it's a qualified star (contains . before *) 404 if (colStr.endsWith("*") && colStr.contains(".")) { 405 // Extract the table alias/name prefix (e.g., "ta" from "ta.*") 406 int dotIndex = colStr.lastIndexOf('.'); 407 if (dotIndex > 0) { 408 String tablePrefix = colStr.substring(0, dotIndex).trim(); 409 TTable matchingTable = findTableByAliasOrName(tablePrefix); 410 if (matchingTable != null) { 411 found = matchingTable; 412 break; 413 } 414 } 415 } 416 } 417 } 418 qualifiedStarMatchTable = found; 419 qualifiedStarScanned = true; 420 return found; 421 } 422 423 /** 424 * Find a table in the FROM clause by alias or table name. 425 * Also searches through JOIN expressions to find tables like PRD2_ODW.USI 426 * that are joined via LEFT OUTER JOIN. 427 */ 428 private TTable findTableByAliasOrName(String nameOrAlias) { 429 if (subquery == null || nameOrAlias == null) { 430 return null; 431 } 432 433 // Use getRelations() to get ALL tables including those in JOINs 434 java.util.ArrayList<TTable> relations = subquery.getRelations(); 435 if (relations != null) { 436 for (TTable table : relations) { 437 if (table == null) continue; 438 439 // For JOIN type tables, search inside the join expression 440 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 441 TTable found = findTableInJoinExpr(table.getJoinExpr(), nameOrAlias); 442 if (found != null) { 443 return found; 444 } 445 continue; 446 } 447 448 // Check alias first 449 String alias = table.getAliasName(); 450 if (alias != null && nameMatcher.matches(alias, nameOrAlias)) { 451 return table; 452 } 453 454 // Check table name (for physical tables) 455 if (table.getTableName() != null) { 456 String tableName = table.getTableName().toString(); 457 if (nameMatcher.matches(tableName, nameOrAlias)) { 458 return table; 459 } 460 // Also check just the table name part (without schema) 461 String shortName = table.getName(); 462 if (shortName != null && nameMatcher.matches(shortName, nameOrAlias)) { 463 return table; 464 } 465 } 466 } 467 } 468 469 // Fallback to subquery.tables if getRelations() didn't find it 470 if (subquery.tables != null) { 471 for (int i = 0; i < subquery.tables.size(); i++) { 472 TTable table = subquery.tables.getTable(i); 473 if (table == null) continue; 474 475 // Check alias first 476 String alias = table.getAliasName(); 477 if (alias != null && nameMatcher.matches(alias, nameOrAlias)) { 478 return table; 479 } 480 481 // Check table name 482 if (table.getTableName() != null) { 483 String tableName = table.getTableName().toString(); 484 if (nameMatcher.matches(tableName, nameOrAlias)) { 485 return table; 486 } 487 } 488 } 489 } 490 return null; 491 } 492 493 /** 494 * Find a table within a JOIN expression by alias or table name. 495 * Recursively searches both left and right sides of the join. 496 */ 497 private TTable findTableInJoinExpr(gudusoft.gsqlparser.nodes.TJoinExpr joinExpr, String nameOrAlias) { 498 if (joinExpr == null || nameOrAlias == null) { 499 return null; 500 } 501 502 // Check left table 503 TTable leftTable = joinExpr.getLeftTable(); 504 if (leftTable != null) { 505 TTable found = matchTableByAliasOrName(leftTable, nameOrAlias); 506 if (found != null) { 507 return found; 508 } 509 // Recursively check if left is also a join 510 if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join && leftTable.getJoinExpr() != null) { 511 found = findTableInJoinExpr(leftTable.getJoinExpr(), nameOrAlias); 512 if (found != null) { 513 return found; 514 } 515 } 516 } 517 518 // Check right table 519 TTable rightTable = joinExpr.getRightTable(); 520 if (rightTable != null) { 521 TTable found = matchTableByAliasOrName(rightTable, nameOrAlias); 522 if (found != null) { 523 return found; 524 } 525 // Recursively check if right is also a join 526 if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join && rightTable.getJoinExpr() != null) { 527 found = findTableInJoinExpr(rightTable.getJoinExpr(), nameOrAlias); 528 if (found != null) { 529 return found; 530 } 531 } 532 } 533 534 return null; 535 } 536 537 /** 538 * Check if a table matches the given alias or name. 539 */ 540 private TTable matchTableByAliasOrName(TTable table, String nameOrAlias) { 541 if (table == null || nameOrAlias == null) { 542 return null; 543 } 544 545 // Check alias first 546 String alias = table.getAliasName(); 547 if (alias != null && nameMatcher.matches(alias, nameOrAlias)) { 548 return table; 549 } 550 551 // Check table name (for physical tables) 552 if (table.getTableName() != null) { 553 String tableName = table.getTableName().toString(); 554 if (nameMatcher.matches(tableName, nameOrAlias)) { 555 return table; 556 } 557 // Also check just the table name part (without schema) 558 // For "PRD2_ODW.USI", getName() returns "USI" 559 String shortName = table.getName(); 560 if (shortName != null && nameMatcher.matches(shortName, nameOrAlias)) { 561 return table; 562 } 563 } 564 565 return null; 566 } 567 568 /** 569 * Find the first physical table from a JOIN expression. 570 * Recursively traverses left side of joins. 571 */ 572 private TTable findFirstPhysicalTableFromJoin(TTable joinTable) { 573 if (joinTable == null) { 574 return null; 575 } 576 577 gudusoft.gsqlparser.nodes.TJoinExpr joinExpr = joinTable.getJoinExpr(); 578 if (joinExpr != null && joinExpr.getLeftTable() != null) { 579 TTable leftTable = joinExpr.getLeftTable(); 580 581 if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 582 // Check if it's a CTE reference - trace through if so 583 if (leftTable.isCTEName() && leftTable.getCTE() != null) { 584 return traceTableThroughCTE(leftTable.getCTE()); 585 } 586 return leftTable; 587 } 588 if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 589 return findFirstPhysicalTableFromJoin(leftTable); 590 } 591 // If it's a subquery, trace through it (use cached namespace) 592 if (leftTable.getSubquery() != null) { 593 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 594 leftTable.getSubquery(), 595 leftTable.getAliasName() 596 ); 597 return nestedNs != null ? nestedNs.getFinalTable() : null; 598 } 599 } 600 return null; 601 } 602 603 /** 604 * Trace through a CTE to find its underlying physical table. 605 * This handles CTE chains like: CTE1 -> CTE2 -> CTE3 -> physical_table 606 */ 607 private TTable traceTableThroughCTE(gudusoft.gsqlparser.nodes.TCTE cteNode) { 608 if (cteNode == null || cteNode.getSubquery() == null) { 609 return null; 610 } 611 612 gudusoft.gsqlparser.stmt.TSelectSqlStatement cteSubquery = cteNode.getSubquery(); 613 614 // Handle UNION in the CTE 615 if (cteSubquery.isCombinedQuery()) { 616 // For UNION, trace the left branch 617 gudusoft.gsqlparser.stmt.TSelectSqlStatement leftStmt = cteSubquery.getLeftStmt(); 618 if (leftStmt != null && leftStmt.tables != null && leftStmt.tables.size() > 0) { 619 cteSubquery = leftStmt; 620 } 621 } 622 623 if (cteSubquery.tables == null || cteSubquery.tables.size() == 0) { 624 return null; 625 } 626 627 TTable firstTable = cteSubquery.tables.getTable(0); 628 if (firstTable == null) { 629 return null; 630 } 631 632 // If it's a physical table (not CTE), we found it 633 if (firstTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname && !firstTable.isCTEName()) { 634 return firstTable; 635 } 636 637 // If it's another CTE reference, continue tracing 638 if (firstTable.isCTEName() && firstTable.getCTE() != null) { 639 return traceTableThroughCTE(firstTable.getCTE()); 640 } 641 642 // If it's a subquery, trace through it (use cached namespace) 643 if (firstTable.getSubquery() != null) { 644 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 645 firstTable.getSubquery(), 646 firstTable.getAliasName() 647 ); 648 return nestedNs != null ? nestedNs.getFinalTable() : null; 649 } 650 651 // If it's a join, get the first base table 652 if (firstTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 653 return findFirstPhysicalTableFromJoin(firstTable); 654 } 655 656 return null; 657 } 658 659 @Override 660 public List<TTable> getAllFinalTables() { 661 // For non-UNION subqueries, return the single final table 662 // Multiple tables only make sense for UNION queries where a column 663 // can come from multiple branches (handled by UnionNamespace) 664 TTable finalTable = getFinalTable(); 665 if (finalTable != null) { 666 return Collections.singletonList(finalTable); 667 } 668 669 return Collections.emptyList(); 670 } 671 672 @Override 673 protected void doValidate() { 674 // Extract columns from SELECT list 675 columnSources = new LinkedHashMap<>(); 676 677 TResultColumnList selectList = subquery.getResultColumnList(); 678 if (selectList == null) { 679 return; 680 } 681 682 for (int i = 0; i < selectList.size(); i++) { 683 TResultColumn resultCol = selectList.getResultColumn(i); 684 685 // Determine column name 686 String colName = getColumnName(resultCol); 687 if (colName == null) { 688 // Unnamed expression, use ordinal 689 colName = "col_" + (i + 1); 690 } 691 692 // Find the source table for this column (if it's a qualified column reference) 693 TTable sourceTable = findSourceTableForResultColumn(resultCol); 694 695 // Create column source with override table if found 696 ColumnSource source; 697 if (sourceTable != null) { 698 source = new ColumnSource( 699 this, 700 colName, 701 resultCol, 702 1.0, // Definite - from SELECT list 703 "subquery_select_list_qualified", 704 sourceTable // Override table for proper tracing 705 ); 706 } else { 707 source = new ColumnSource( 708 this, 709 colName, 710 resultCol, 711 1.0, // Definite - from SELECT list 712 "subquery_select_list" 713 ); 714 } 715 716 columnSources.put(colName, source); 717 } 718 } 719 720 /** 721 * Find the source table for a result column's expression. 722 * For qualified column references like SUBS_CUST.FIRST_TP_ID, this returns 723 * the SUBS_CUST table from the FROM clause. 724 * For star columns like SUBSCR.*, this returns the SUBSCR table. 725 * 726 * <p>IMPORTANT: For subquery aliases, we return the subquery's TTable directly 727 * and do NOT trace through. The column should be attributed to the subquery 728 * alias (e.g., SUBS_CUST.FIRST_TP_ID), not to tables inside the subquery.</p> 729 * 730 * @param resultCol The result column to analyze 731 * @return The source table, or null if not determinable 732 */ 733 private TTable findSourceTableForResultColumn(TResultColumn resultCol) { 734 if (resultCol == null || resultCol.getExpr() == null) { 735 return null; 736 } 737 738 gudusoft.gsqlparser.nodes.TExpression expr = resultCol.getExpr(); 739 740 // Handle simple column reference (e.g., SUBS_CUST.FIRST_TP_ID or FIRST_TP_ID) 741 if (expr.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) { 742 gudusoft.gsqlparser.nodes.TObjectName objName = expr.getObjectOperand(); 743 if (objName != null) { 744 String tablePrefix = objName.getTableString(); 745 if (tablePrefix != null && !tablePrefix.isEmpty()) { 746 // Qualified reference - find the table 747 TTable table = findTableByAliasOrName(tablePrefix); 748 if (table != null) { 749 // Return the table directly - don't trace through subqueries 750 // This keeps columns attributed to their immediate source 751 // (e.g., SUBS_CUST.FIRST_TP_ID stays as SUBS_CUST.FIRST_TP_ID) 752 return table; 753 } 754 } 755 } 756 } 757 758 // Handle star column (e.g., SUBSCR.* or *) 759 String colStr = resultCol.toString().trim(); 760 if (colStr.endsWith("*") && colStr.contains(".")) { 761 // Qualified star - find the table 762 int dotIndex = colStr.lastIndexOf('.'); 763 if (dotIndex > 0) { 764 String tablePrefix = colStr.substring(0, dotIndex).trim(); 765 TTable table = findTableByAliasOrName(tablePrefix); 766 if (table != null) { 767 // For star columns, we DO trace through to get all underlying columns 768 // This is different from regular columns because star needs to expand (use cached namespace) 769 if (table.getSubquery() != null) { 770 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 771 table.getSubquery(), table.getAliasName()); 772 return nestedNs != null ? nestedNs.getFinalTable() : null; 773 } 774 return table; 775 } 776 } 777 } 778 779 return null; 780 } 781 782 /** 783 * Extract column name from TResultColumn. 784 * Handles aliases and expression columns. 785 */ 786 private String getColumnName(TResultColumn resultCol) { 787 // Check for alias 788 if (resultCol.getAliasClause() != null && 789 resultCol.getAliasClause().getAliasName() != null) { 790 return resultCol.getAliasClause().getAliasName().toString(); 791 } 792 793 // Check for simple column reference 794 if (resultCol.getExpr() != null) { 795 gudusoft.gsqlparser.nodes.TExpression expr = resultCol.getExpr(); 796 // Check if it's a simple object reference 797 if (expr.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) { 798 gudusoft.gsqlparser.nodes.TObjectName objName = expr.getObjectOperand(); 799 if (objName != null) { 800 return objName.getColumnNameOnly(); 801 } 802 } 803 } 804 805 // Complex expression - no name 806 return null; 807 } 808 809 @Override 810 public ColumnLevel hasColumn(String columnName) { 811 ensureValidated(); 812 813 // Check in SELECT list (explicit columns) — matcher-aware. 814 if (containsColumnByMatcher(columnSources, columnName)) { 815 return ColumnLevel.EXISTS; 816 } 817 818 // Check in inferred columns (from star push-down). The map is raw- 819 // keyed (= ColumnSource.exposedName); the matcher-aware helper 820 // applies per-dialect rules including SQL Server COLLATION_BASED. 821 if (containsColumnByMatcher(inferredColumns, columnName)) { 822 return ColumnLevel.EXISTS; 823 } 824 825 // If subquery has SELECT *, unknown columns MAYBE exist 826 // They need to be resolved through star push-down 827 if (hasStarColumn()) { 828 return ColumnLevel.MAYBE; 829 } 830 831 return ColumnLevel.NOT_EXISTS; 832 } 833 834 public TSelectSqlStatement getSubquery() { 835 return subquery; 836 } 837 838 @Override 839 public TSelectSqlStatement getSelectStatement() { 840 return subquery; 841 } 842 843 @Override 844 public boolean hasStarColumn() { 845 if (subquery == null || subquery.getResultColumnList() == null) { 846 return false; 847 } 848 849 TResultColumnList selectList = subquery.getResultColumnList(); 850 for (int i = 0; i < selectList.size(); i++) { 851 TResultColumn resultCol = selectList.getResultColumn(i); 852 if (isStarResultColumn(resultCol)) { 853 return true; 854 } 855 } 856 return false; 857 } 858 859 private static boolean isStarResultColumn(TResultColumn resultCol) { 860 if (resultCol == null) { 861 return false; 862 } 863 if (resultCol.getExceptColumnList() != null 864 || (resultCol.getReplaceExprAsIdentifiers() != null 865 && !resultCol.getReplaceExprAsIdentifiers().isEmpty()) 866 || (resultCol.getExprAsIdentifiers() != null 867 && !resultCol.getExprAsIdentifiers().isEmpty())) { 868 return true; 869 } 870 if (resultCol.getExpr() != null 871 && resultCol.getExpr().getExpressionType() == EExpressionType.simple_object_name_t 872 && resultCol.getExpr().getObjectOperand() != null) { 873 String starText = resultCol.getExpr().getObjectOperand().toString(); 874 if (starText != null) { 875 starText = starText.trim(); 876 if ("*".equals(starText) || starText.endsWith(".*")) { 877 return true; 878 } 879 } 880 } 881 String text = resultCol.toString(); 882 if (text == null) { 883 return false; 884 } 885 text = text.trim(); 886 return "*".equals(text) || text.endsWith(".*"); 887 } 888 889 /** 890 * Slice S4 (plan §5.5): a derived subquery's projection is authoritative 891 * once validated AND at least one named (non-star) column exists. A 892 * subquery whose only projection is {@code SELECT *} is reported as 893 * METADATA_UNAVAILABLE here — S10 refines this when the underlying tables' 894 * star expansion fully resolves into named columns. 895 */ 896 @Override 897 public MetadataState getMetadataState() { 898 ensureValidated(); 899 if (columnSources != null && !columnSources.isEmpty() && !hasStarColumn()) { 900 return MetadataState.FOUND; 901 } 902 return MetadataState.METADATA_UNAVAILABLE; 903 } 904 905 /** 906 * Binding-diagnostic view of the derived-table output schema. 907 * 908 * <p>This deliberately avoids the legacy resolution fallbacks in 909 * {@link #resolveColumn(String)}. Binding diagnostics need the SQL-visible 910 * derived projection: named SELECT-list columns and already inferred star 911 * output columns are visible; hidden base-table columns are not. When the 912 * projection includes a star and the requested column is not already known, 913 * the output is not fully authoritative, so callers must not report a 914 * missing-output error.</p> 915 */ 916 public ColumnLevel hasAuthoritativeOutputColumn(String columnName) { 917 ensureValidated(); 918 919 if (columnName == null || columnName.isEmpty()) { 920 return ColumnLevel.MAYBE; 921 } 922 923 if (containsColumnByMatcher(columnSources, columnName)) { 924 return ColumnLevel.EXISTS; 925 } 926 927 if (containsColumnByMatcher(inferredColumns, columnName)) { 928 return ColumnLevel.EXISTS; 929 } 930 931 if (hasStarColumn()) { 932 return ColumnLevel.MAYBE; 933 } 934 935 if (columnSources != null && !columnSources.isEmpty()) { 936 return ColumnLevel.NOT_EXISTS; 937 } 938 939 return ColumnLevel.MAYBE; 940 } 941 942 /** 943 * Get the first star column (TResultColumn) from this subquery's SELECT list. 944 * Used to track the definition node for columns inferred from the star. 945 * 946 * @return The star column, or null if no star column exists 947 */ 948 public TResultColumn getStarColumn() { 949 if (subquery == null || subquery.getResultColumnList() == null) { 950 return null; 951 } 952 953 TResultColumnList selectList = subquery.getResultColumnList(); 954 for (int i = 0; i < selectList.size(); i++) { 955 TResultColumn resultCol = selectList.getResultColumn(i); 956 if (isStarResultColumn(resultCol)) { 957 return resultCol; 958 } 959 } 960 return null; 961 } 962 963 /** 964 * Check if this subquery has an unqualified star with multiple tables. 965 * In this case, columns are ambiguous and should NOT be auto-resolved. 966 * 967 * Example: 968 * SELECT * FROM table_a, table_c -- ambiguous, columns could come from either table 969 * SELECT ta.* FROM table_a ta, table_c tc -- NOT ambiguous, star is qualified 970 * SELECT * FROM table_a JOIN table_c ON ... -- ambiguous, columns from both tables 971 * 972 * Uses getRelations() and TJoinExpr to properly count tables in JOINs. 973 */ 974 public boolean hasAmbiguousStar() { 975 if (subquery == null || subquery.getResultColumnList() == null) { 976 return false; 977 } 978 979 // Count all sources (tables, subqueries, joins) using getRelations() 980 int sourceCount = 0; 981 java.util.ArrayList<TTable> relations = subquery.getRelations(); 982 if (relations != null) { 983 for (TTable table : relations) { 984 if (table == null) continue; 985 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 986 // For JOIN type, count tables from the JoinExpr 987 List<TTable> joinTables = new ArrayList<>(); 988 collectPhysicalTablesFromJoinExpr(table.getJoinExpr(), joinTables); 989 sourceCount += joinTables.size(); 990 } else if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 991 sourceCount++; 992 } else if (table.getSubquery() != null) { 993 // Subqueries also count as sources 994 sourceCount++; 995 } 996 } 997 } 998 999 // If only one source, not ambiguous 1000 if (sourceCount <= 1) { 1001 return false; 1002 } 1003 1004 // Check for unqualified star (just "*" without table prefix) 1005 TResultColumnList selectList = subquery.getResultColumnList(); 1006 for (int i = 0; i < selectList.size(); i++) { 1007 TResultColumn resultCol = selectList.getResultColumn(i); 1008 if (resultCol == null) continue; 1009 1010 String colStr = resultCol.toString().trim(); 1011 // Check if it's an unqualified star (just "*", not "ta.*") 1012 if (colStr.equals("*")) { 1013 return true; 1014 } 1015 } 1016 1017 return false; 1018 } 1019 1020 /** 1021 * Count the number of "real" tables in the FROM clause, excluding implicit lateral 1022 * derived tables (Teradata feature where references to undeclared tables in WHERE 1023 * clause create implicit table references). 1024 * 1025 * @return The count of real tables (excluding implicit lateral derived tables) 1026 */ 1027 private int countRealTablesInFromClause() { 1028 if (subquery == null || subquery.tables == null) { 1029 return 0; 1030 } 1031 int count = 0; 1032 for (int i = 0; i < subquery.tables.size(); i++) { 1033 TTable table = subquery.tables.getTable(i); 1034 if (table != null && 1035 table.getEffectType() != gudusoft.gsqlparser.ETableEffectType.tetImplicitLateralDerivedTable) { 1036 count++; 1037 } 1038 } 1039 return count; 1040 } 1041 1042 /** 1043 * Get the single real table from the FROM clause when there's exactly one. 1044 * This is used as a fallback for star column push-down when resolveColumnInFromScope() 1045 * can't find the column (e.g., no SQLEnv metadata). 1046 * 1047 * @return The single real table, or null if there's not exactly one 1048 */ 1049 private TTable getSingleRealTableFromFromClause() { 1050 if (subquery == null || subquery.tables == null) { 1051 return null; 1052 } 1053 TTable result = null; 1054 for (int i = 0; i < subquery.tables.size(); i++) { 1055 TTable table = subquery.tables.getTable(i); 1056 if (table != null && 1057 table.getEffectType() != gudusoft.gsqlparser.ETableEffectType.tetImplicitLateralDerivedTable) { 1058 if (result != null) { 1059 // Multiple tables - can't determine which one 1060 return null; 1061 } 1062 result = table; 1063 } 1064 } 1065 // If the single table is itself a subquery, trace through it (use cached namespace) 1066 if (result != null && result.getSubquery() != null) { 1067 SubqueryNamespace nestedNs = getOrCreateNestedNamespace(result.getSubquery(), result.getAliasName()); 1068 return nestedNs != null ? nestedNs.getFinalTable() : null; 1069 } 1070 1071 // If the single table is a CTE reference, trace through the CTE to find the physical table 1072 // This is critical for star column push-down when SELECT * FROM cte_name 1073 if (result != null && result.isCTEName() && result.getCTE() != null) { 1074 gudusoft.gsqlparser.nodes.TCTE cte = result.getCTE(); 1075 if (cte.getSubquery() != null) { 1076 // Create a temporary CTENamespace to trace through 1077 CTENamespace cteNs = new CTENamespace( 1078 cte, 1079 cte.getTableName() != null ? cte.getTableName().toString() : result.getName(), 1080 cte.getSubquery(), 1081 nameMatcher 1082 ); 1083 cteNs.setReferencingTable(result); 1084 cteNs.validate(); 1085 TTable tracedTable = cteNs.getFinalTable(); 1086 // Return the physical table if found, otherwise return the CTE table as fallback 1087 return tracedTable != null ? tracedTable : result; 1088 } 1089 } 1090 1091 return result; 1092 } 1093 1094 @Override 1095 public boolean supportsDynamicInference() { 1096 // Support dynamic inference if this subquery has SELECT * 1097 return hasStarColumn(); 1098 } 1099 1100 @Override 1101 public boolean addInferredColumn(String columnName, double confidence, String evidence) { 1102 if (columnName == null || columnName.isEmpty()) { 1103 return false; 1104 } 1105 1106 // Initialize maps if needed 1107 if (inferredColumns == null) { 1108 inferredColumns = new LinkedHashMap<>(); 1109 } 1110 if (inferredColumnNames == null) { 1111 inferredColumnNames = new HashSet<>(); 1112 } 1113 1114 // Slice S1: dedupe through the matcher-aware helper so per-vendor 1115 // identifier rules govern whether two case-only-different inputs are 1116 // the same column. Without this, BigQuery / MySQL / SQL Server would 1117 // accept "MyCol" and "MYCOL" as two separate inferred entries and 1118 // downstream lookups would non-deterministically pick one of them. 1119 // Codex round 2: storage uses the raw (exposedName) key — two 1120 // matcher-distinct identifiers that happen to normalize equally 1121 // (Postgres "mycol" vs unquoted MYCOL) keep separate entries. 1122 if (containsColumnByMatcher(columnSources, columnName)) { 1123 return false; 1124 } 1125 if (containsColumnByMatcher(inferredColumns, columnName)) { 1126 return false; 1127 } 1128 1129 // For star columns (SELECT *), trace the column to its underlying table. 1130 // This is critical for Teradata UPDATE...FROM...SET syntax where columns 1131 // reference subqueries with SELECT *. 1132 // 1133 // IMPORTANT: Only trace when there's exactly ONE real table (excluding implicit 1134 // lateral derived tables). If there are multiple tables, the column source 1135 // is ambiguous unless the star is qualified (handled elsewhere). 1136 TTable overrideTable = null; 1137 if (hasStarColumn() && !hasAmbiguousStar()) { 1138 // Count real tables (excluding implicit lateral derived tables) 1139 int realTableCount = countRealTablesInFromClause(); 1140 if (realTableCount == 1) { 1141 ColumnSource fromSource = resolveColumnInFromScope(columnName); 1142 if (fromSource != null) { 1143 // Get the table from the underlying source 1144 overrideTable = fromSource.getFinalTable(); 1145 if (overrideTable == null && fromSource.getSourceNamespace() instanceof TableNamespace) { 1146 // Try to get table directly from TableNamespace 1147 overrideTable = ((TableNamespace) fromSource.getSourceNamespace()).getTable(); 1148 } 1149 } 1150 1151 // Fallback: If resolveColumnInFromScope couldn't find the column (no SQLEnv metadata), 1152 // but we have exactly one table with SELECT *, assume the column comes from that table. 1153 // This is the expected behavior for star column push-down in most real-world scenarios 1154 // where the column name is not provable but is inferred from the context. 1155 if (overrideTable == null) { 1156 overrideTable = getSingleRealTableFromFromClause(); 1157 } 1158 } 1159 } 1160 1161 // Create inferred column source with overrideTable if found 1162 // Note: Do NOT set the star column as definitionNode here. 1163 // The definitionNode is used by the formatter to attribute columns to tables. 1164 // Star-inferred columns should be attributed based on the overrideTable, not the star column. 1165 // The legacy sourceColumn for star-inferred columns should remain null since they don't 1166 // have a direct 1:1 relationship with a specific TResultColumn. 1167 ColumnSource source = new ColumnSource( 1168 this, 1169 columnName, 1170 null, // No definition node for inferred columns 1171 confidence, 1172 evidence, 1173 overrideTable 1174 ); 1175 1176 inferredColumns.put(columnName, source); 1177 inferredColumnNames.add(columnName); 1178 return true; 1179 } 1180 1181 @Override 1182 public Set<String> getInferredColumns() { 1183 if (inferredColumnNames == null) { 1184 return java.util.Collections.emptySet(); 1185 } 1186 return java.util.Collections.unmodifiableSet(inferredColumnNames); 1187 } 1188 1189 @Override 1190 public ColumnSource resolveColumn(String columnName) { 1191 ensureValidated(); 1192 1193 // First check explicit columns from this subquery's SELECT list 1194 ColumnSource source = super.resolveColumn(columnName); 1195 if (source != null) { 1196 return source; 1197 } 1198 1199 // Then check inferred columns. Slice S1 + codex round 2: the map is 1200 // raw-keyed (= ColumnSource.exposedName), so the exact-match probe 1201 // is O(1) for the same identifier queried again. Matcher loop walks 1202 // values via getExposedName() so quote state is preserved. 1203 if (inferredColumns != null) { 1204 ColumnSource exact = inferredColumns.get(columnName); 1205 if (exact != null) { 1206 return exact; 1207 } 1208 for (ColumnSource entry : inferredColumns.values()) { 1209 String exposed = entry != null ? entry.getExposedName() : null; 1210 if (exposed != null && nameMatcher.matches(exposed, columnName)) { 1211 return entry; 1212 } 1213 } 1214 } 1215 1216 // For ambiguous star cases, try to find the column in explicit subquery sources 1217 // This handles cases like: SELECT * FROM table_a ta, (SELECT col1 FROM table_c) tc 1218 // where col1 can be uniquely traced to tc -> table_c 1219 if (hasAmbiguousStar()) { 1220 ColumnSource explicitSource = findColumnInExplicitSources(columnName); 1221 if (explicitSource != null) { 1222 return explicitSource; 1223 } 1224 1225 // Try to trace the column through qualified references in the FROM clause 1226 // This handles JOINs where ON condition uses qualified names like table1.x 1227 ColumnSource tracedSource = traceColumnThroughQualifiedReferences(columnName); 1228 if (tracedSource != null) { 1229 return tracedSource; 1230 } 1231 1232 // Try to resolve the column using sqlenv metadata from the FROM clause tables 1233 // This uses TableNamespace.resolveColumn() which has access to sqlenv 1234 ColumnSource fromScopeSource = resolveColumnInFromScope(columnName); 1235 if (fromScopeSource != null) { 1236 return fromScopeSource; 1237 } 1238 1239 // Column not found in any explicit source, and we have multiple physical tables 1240 // Apply GUESS_COLUMN_STRATEGY to pick a table or leave unresolved 1241 return applyGuessColumnStrategy(columnName); 1242 } 1243 1244 // If hasColumn returns MAYBE (we have SELECT *), auto-infer this column 1245 // This enables on-demand inference during resolution 1246 if (hasStarColumn()) { 1247 // Add as inferred column with moderate confidence 1248 // The confidence is lower because we're inferring from outer reference 1249 boolean added = addInferredColumn(columnName, 0.8, "auto_inferred_from_outer_reference"); 1250 if (added && inferredColumns != null) { 1251 ColumnSource inferredSource = inferredColumns.get(columnName); 1252 if (inferredSource != null) { 1253 return inferredSource; 1254 } 1255 } 1256 } 1257 1258 return null; 1259 } 1260 1261 /** 1262 * Resolve a column in the FROM scope (child namespaces). 1263 * This finds where a column originates from in the FROM clause tables/subqueries. 1264 * 1265 * <p>Unlike resolveColumn() which returns columns from THIS subquery's SELECT list, 1266 * this method looks into the FROM clause to find the underlying definition.</p> 1267 * 1268 * <p>IMPORTANT: For TableNamespace, this only returns columns that exist in 1269 * actual metadata (from DDL or SQLEnv). It does NOT use inferred columns. 1270 * This is critical for multi-table star resolution where we need to know 1271 * which table actually has the column based on metadata, not inference.</p> 1272 * 1273 * @param columnName The column name to find 1274 * @return ColumnSource from the FROM clause, or null if not found 1275 */ 1276 public ColumnSource resolveColumnInFromScope(String columnName) { 1277 if (subquery == null || subquery.tables == null || columnName == null) { 1278 return null; 1279 } 1280 1281 // Search through tables in the FROM clause 1282 for (int i = 0; i < subquery.tables.size(); i++) { 1283 TTable table = subquery.tables.getTable(i); 1284 if (table == null) continue; 1285 1286 // For subqueries, look in their column sources (use cached namespace) 1287 if (table.getSubquery() != null) { 1288 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 1289 table.getSubquery(), table.getAliasName()); 1290 if (nestedNs != null) { 1291 ColumnSource source = nestedNs.resolveColumn(columnName); 1292 if (source != null) { 1293 return source; 1294 } 1295 } 1296 } 1297 1298 // For physical tables, create a TableNamespace and resolve (use cached namespace) 1299 // IMPORTANT: Only return column if the table has actual metadata (DDL or SQLEnv) 1300 // This prevents inferring columns for ambiguous star resolution 1301 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1302 TableNamespace tableNs = getOrCreateTableNamespace(table); 1303 if (tableNs != null && tableNs.hasMetadata()) { 1304 ColumnSource source = tableNs.resolveColumn(columnName); 1305 if (source != null) { 1306 return source; 1307 } 1308 } 1309 } 1310 1311 // For joins, recursively search 1312 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1313 ColumnSource source = resolveColumnInJoin(table.getJoinExpr(), columnName); 1314 if (source != null) { 1315 return source; 1316 } 1317 } 1318 } 1319 1320 return null; 1321 } 1322 1323 /** 1324 * Resolve a column within a JOIN expression. 1325 * Only returns columns from tables with actual metadata (DDL or SQLEnv). 1326 */ 1327 private ColumnSource resolveColumnInJoin(gudusoft.gsqlparser.nodes.TJoinExpr joinExpr, String columnName) { 1328 if (joinExpr == null) return null; 1329 1330 // Check left table (use cached namespaces) 1331 TTable leftTable = joinExpr.getLeftTable(); 1332 if (leftTable != null) { 1333 if (leftTable.getSubquery() != null) { 1334 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 1335 leftTable.getSubquery(), leftTable.getAliasName()); 1336 if (nestedNs != null) { 1337 ColumnSource source = nestedNs.resolveColumn(columnName); 1338 if (source != null) { 1339 return source; 1340 } 1341 } 1342 } else if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1343 TableNamespace tableNs = getOrCreateTableNamespace(leftTable); 1344 // Only return if table has metadata - don't use inferred columns 1345 if (tableNs != null && tableNs.hasMetadata()) { 1346 ColumnSource source = tableNs.resolveColumn(columnName); 1347 if (source != null) { 1348 return source; 1349 } 1350 } 1351 } else if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1352 ColumnSource source = resolveColumnInJoin(leftTable.getJoinExpr(), columnName); 1353 if (source != null) { 1354 return source; 1355 } 1356 } 1357 } 1358 1359 // Check right table (use cached namespaces) 1360 TTable rightTable = joinExpr.getRightTable(); 1361 if (rightTable != null) { 1362 if (rightTable.getSubquery() != null) { 1363 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 1364 rightTable.getSubquery(), rightTable.getAliasName()); 1365 if (nestedNs != null) { 1366 ColumnSource source = nestedNs.resolveColumn(columnName); 1367 if (source != null) { 1368 return source; 1369 } 1370 } 1371 } else if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1372 TableNamespace tableNs = getOrCreateTableNamespace(rightTable); 1373 // Only return if table has metadata - don't use inferred columns 1374 if (tableNs != null && tableNs.hasMetadata()) { 1375 ColumnSource source = tableNs.resolveColumn(columnName); 1376 if (source != null) { 1377 return source; 1378 } 1379 } 1380 } else if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1381 ColumnSource source = resolveColumnInJoin(rightTable.getJoinExpr(), columnName); 1382 if (source != null) { 1383 return source; 1384 } 1385 } 1386 } 1387 1388 return null; 1389 } 1390 1391 /** 1392 * Apply GUESS_COLUMN_STRATEGY to pick a table for an ambiguous column. 1393 * Used when a star column could come from multiple tables. 1394 * 1395 * For NOT_PICKUP strategy, returns a ColumnSource with all candidate tables 1396 * stored so end users can access them via getCandidateTables(). 1397 * 1398 * @param columnName The column name to resolve 1399 * @return ColumnSource (may have multiple candidate tables for NOT_PICKUP) 1400 */ 1401 private ColumnSource applyGuessColumnStrategy(String columnName) { 1402 // Get the strategy from instance field (which falls back to TBaseType if not set) 1403 int strategy = getGuessColumnStrategy(); 1404 1405 // Collect all physical tables from the FROM clause 1406 List<TTable> physicalTables = new ArrayList<>(); 1407 java.util.ArrayList<TTable> relations = subquery.getRelations(); 1408 if (relations != null) { 1409 for (TTable table : relations) { 1410 if (table == null) continue; 1411 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1412 collectPhysicalTablesFromJoinExpr(table.getJoinExpr(), physicalTables); 1413 } else if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1414 physicalTables.add(table); 1415 } 1416 } 1417 } 1418 1419 if (physicalTables.isEmpty()) { 1420 return null; 1421 } 1422 1423 if (strategy == gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY_NOT_PICKUP) { 1424 // Don't pick any table - return null to treat column as "missed" 1425 // This is the expected behavior for ambiguous star columns with NOT_PICKUP strategy 1426 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1427 System.out.println("[GUESS_COLUMN_STRATEGY] Column '" + columnName + 1428 "' is ambiguous across " + physicalTables.size() + " tables (NOT_PICKUP strategy - treating as missed)"); 1429 } 1430 return null; 1431 } 1432 1433 // Pick the table based on strategy 1434 TTable pickedTable; 1435 String evidence; 1436 if (strategy == gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY_NEAREST) { 1437 // Pick the first table (nearest in FROM clause order) 1438 pickedTable = physicalTables.get(0); 1439 evidence = "guess_strategy_nearest"; 1440 } else if (strategy == gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY_FARTHEST) { 1441 // Pick the last table (farthest in FROM clause order) 1442 pickedTable = physicalTables.get(physicalTables.size() - 1); 1443 evidence = "guess_strategy_farthest"; 1444 } else { 1445 // Unknown strategy - don't pick 1446 return null; 1447 } 1448 1449 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1450 System.out.println("[GUESS_COLUMN_STRATEGY] Column '" + columnName + 1451 "' picked from table '" + pickedTable.getName() + "' using " + evidence); 1452 } 1453 1454 // Create ColumnSource with the picked table 1455 return new ColumnSource( 1456 this, 1457 columnName, 1458 null, 1459 0.7, // Lower confidence since it's a guess 1460 evidence, 1461 pickedTable // Override table for getFinalTable() 1462 ); 1463 } 1464 1465 /** 1466 * Find a column in explicit subquery sources within the FROM clause. 1467 * This is used for ambiguous star cases where we try to find the column 1468 * in subqueries that have explicit columns before giving up. 1469 */ 1470 private ColumnSource findColumnInExplicitSources(String columnName) { 1471 if (subquery == null || subquery.tables == null) { 1472 return null; 1473 } 1474 1475 ColumnSource foundSource = null; 1476 List<TTable> physicalTables = new ArrayList<>(); 1477 1478 for (int i = 0; i < subquery.tables.size(); i++) { 1479 TTable table = subquery.tables.getTable(i); 1480 if (table == null) continue; 1481 1482 // For join tables, collect physical tables from within the join 1483 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1484 collectPhysicalTablesFromJoin(table, physicalTables); 1485 continue; 1486 } 1487 1488 // Check if this is a subquery with explicit columns 1489 if (table.getSubquery() != null) { 1490 TResultColumnList resultCols = table.getSubquery().getResultColumnList(); 1491 if (resultCols != null) { 1492 for (int j = 0; j < resultCols.size(); j++) { 1493 TResultColumn rc = resultCols.getResultColumn(j); 1494 String rcName = getResultColumnName(rc); 1495 if (rcName != null && nameMatcher.matches(rcName, columnName)) { 1496 // Found in this subquery - trace to its final table (use cached namespace) 1497 SubqueryNamespace nestedNs = getOrCreateNestedNamespace( 1498 table.getSubquery(), table.getAliasName()); 1499 if (nestedNs != null) { 1500 ColumnSource nestedSource = nestedNs.resolveColumn(columnName); 1501 if (nestedSource != null) { 1502 if (foundSource != null) { 1503 // Found in multiple sources - ambiguous 1504 return null; 1505 } 1506 foundSource = nestedSource; 1507 } 1508 } 1509 } 1510 } 1511 } 1512 } else if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1513 // Physical table - add to list 1514 physicalTables.add(table); 1515 } 1516 } 1517 1518 // If found in an explicit subquery source, return it 1519 if (foundSource != null) { 1520 return foundSource; 1521 } 1522 1523 // If column not found in any explicit source, and there's exactly one physical table, 1524 // infer from that table (use cached namespace) 1525 if (physicalTables.size() == 1) { 1526 // Create inferred column source from the single physical table 1527 TableNamespace tableNs = getOrCreateTableNamespace(physicalTables.get(0)); 1528 return tableNs != null ? tableNs.resolveColumn(columnName) : null; 1529 } 1530 1531 return null; 1532 } 1533 1534 /** 1535 * Trace a column through qualified references in the subquery. 1536 * This looks for qualified column references (like table1.x) in the subquery 1537 * that match the requested column name, and returns a ColumnSource from the 1538 * corresponding table. 1539 * 1540 * Uses getRelations() and TJoinExpr to properly traverse JOIN structures. 1541 */ 1542 private ColumnSource traceColumnThroughQualifiedReferences(String columnName) { 1543 if (subquery == null || columnName == null) { 1544 return null; 1545 } 1546 1547 // Debug logging 1548 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1549 System.out.println("TRACE: traceColumnThroughQualifiedReferences called for column: " + columnName); 1550 } 1551 1552 // Collect all physical tables using getRelations() - the proper way to get all tables 1553 List<TTable> physicalTables = new ArrayList<>(); 1554 java.util.ArrayList<TTable> relations = subquery.getRelations(); 1555 if (relations != null) { 1556 for (TTable table : relations) { 1557 if (table == null) continue; 1558 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1559 // For JOIN type, collect tables from the JoinExpr 1560 collectPhysicalTablesFromJoinExpr(table.getJoinExpr(), physicalTables); 1561 } else if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1562 physicalTables.add(table); 1563 } 1564 } 1565 } 1566 1567 if (physicalTables.isEmpty()) { 1568 return null; 1569 } 1570 1571 // Look for qualified column references in JOIN ON conditions using TJoinExpr 1572 TTable matchedTable = null; 1573 int matchCount = 0; 1574 1575 // Check WHERE clause 1576 if (subquery.getWhereClause() != null && subquery.getWhereClause().getCondition() != null) { 1577 TTable found = findTableFromQualifiedColumnInExpression( 1578 subquery.getWhereClause().getCondition(), columnName, physicalTables); 1579 if (found != null) { 1580 matchedTable = found; 1581 matchCount++; 1582 } 1583 } 1584 1585 // Check JOIN conditions using getRelations() and TJoinExpr 1586 if (relations != null) { 1587 for (TTable table : relations) { 1588 if (table != null && table.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1589 gudusoft.gsqlparser.nodes.TJoinExpr joinExpr = table.getJoinExpr(); 1590 if (joinExpr != null) { 1591 TTable found = findTableFromQualifiedColumnInJoinExpr(joinExpr, columnName, physicalTables); 1592 if (found != null && (matchedTable == null || found != matchedTable)) { 1593 if (matchedTable != null && found != matchedTable) { 1594 matchCount++; 1595 } else { 1596 matchedTable = found; 1597 matchCount = 1; 1598 } 1599 } 1600 } 1601 } 1602 } 1603 } 1604 1605 // If found in exactly one table, return column source with SubqueryNamespace as source 1606 // The overrideTable ensures getFinalTable() returns the traced table 1607 if (matchCount == 1 && matchedTable != null) { 1608 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1609 System.out.println("TRACE: Found column " + columnName + " in table: " + matchedTable.getName()); 1610 } 1611 // Store the traced table mapping for reference 1612 if (tracedColumnTables == null) { 1613 tracedColumnTables = new java.util.LinkedHashMap<>(); 1614 } 1615 tracedColumnTables.put(columnName, matchedTable); 1616 1617 // Return a ColumnSource with this SubqueryNamespace as source (for priority) 1618 // and the traced table as overrideTable (for correct getFinalTable()) 1619 ColumnSource source = new ColumnSource( 1620 this, 1621 columnName, 1622 null, 1623 0.95, // High confidence - traced through qualified reference 1624 "traced_through_qualified_ref", 1625 matchedTable // Override table for getFinalTable() 1626 ); 1627 return source; 1628 } 1629 1630 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1631 System.out.println("TRACE: Column " + columnName + " NOT found in qualified references, matchCount=" + matchCount); 1632 } 1633 return null; 1634 } 1635 1636 /** 1637 * Collect physical tables from a TJoinExpr recursively. 1638 */ 1639 private void collectPhysicalTablesFromJoinExpr(gudusoft.gsqlparser.nodes.TJoinExpr joinExpr, List<TTable> physicalTables) { 1640 if (joinExpr == null) return; 1641 1642 // Process left table 1643 TTable leftTable = joinExpr.getLeftTable(); 1644 if (leftTable != null) { 1645 if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join && leftTable.getJoinExpr() != null) { 1646 collectPhysicalTablesFromJoinExpr(leftTable.getJoinExpr(), physicalTables); 1647 } else if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1648 physicalTables.add(leftTable); 1649 } 1650 } 1651 1652 // Process right table 1653 TTable rightTable = joinExpr.getRightTable(); 1654 if (rightTable != null) { 1655 if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join && rightTable.getJoinExpr() != null) { 1656 collectPhysicalTablesFromJoinExpr(rightTable.getJoinExpr(), physicalTables); 1657 } else if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1658 physicalTables.add(rightTable); 1659 } 1660 } 1661 } 1662 1663 /** 1664 * Find table from qualified column references in a TJoinExpr and its nested joins. 1665 */ 1666 private TTable findTableFromQualifiedColumnInJoinExpr( 1667 gudusoft.gsqlparser.nodes.TJoinExpr joinExpr, 1668 String columnName, 1669 List<TTable> physicalTables) { 1670 if (joinExpr == null) return null; 1671 1672 // Check ON condition of this join 1673 if (joinExpr.getOnCondition() != null) { 1674 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1675 System.out.println("TRACE: Checking TJoinExpr ON condition: " + joinExpr.getOnCondition()); 1676 } 1677 TTable found = findTableFromQualifiedColumnInExpression( 1678 joinExpr.getOnCondition(), columnName, physicalTables); 1679 if (found != null) return found; 1680 } 1681 1682 // Recursively check nested joins on the left side 1683 TTable leftTable = joinExpr.getLeftTable(); 1684 if (leftTable != null && leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join 1685 && leftTable.getJoinExpr() != null) { 1686 TTable found = findTableFromQualifiedColumnInJoinExpr(leftTable.getJoinExpr(), columnName, physicalTables); 1687 if (found != null) return found; 1688 } 1689 1690 // Recursively check nested joins on the right side 1691 TTable rightTable = joinExpr.getRightTable(); 1692 if (rightTable != null && rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join 1693 && rightTable.getJoinExpr() != null) { 1694 TTable found = findTableFromQualifiedColumnInJoinExpr(rightTable.getJoinExpr(), columnName, physicalTables); 1695 if (found != null) return found; 1696 } 1697 1698 return null; 1699 } 1700 1701 /** 1702 * Find table from qualified column references in an expression. 1703 * Looks for patterns like table1.column_name and returns the matching table. 1704 * Uses iterative DFS to avoid StackOverflowError for deeply nested expression chains. 1705 */ 1706 private TTable findTableFromQualifiedColumnInExpression( 1707 gudusoft.gsqlparser.nodes.TExpression expr, 1708 String columnName, 1709 List<TTable> physicalTables) { 1710 if (expr == null) return null; 1711 1712 Deque<gudusoft.gsqlparser.nodes.TExpression> stack = new ArrayDeque<>(); 1713 stack.push(expr); 1714 while (!stack.isEmpty()) { 1715 gudusoft.gsqlparser.nodes.TExpression current = stack.pop(); 1716 if (current == null) continue; 1717 1718 // Check if this expression is a qualified column reference 1719 if (current.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) { 1720 gudusoft.gsqlparser.nodes.TObjectName objName = current.getObjectOperand(); 1721 if (objName != null) { 1722 String colName = objName.getColumnNameOnly(); 1723 String tablePrefix = objName.getTableString(); 1724 1725 if (colName != null && nameMatcher.matches(colName, columnName) && 1726 tablePrefix != null && !tablePrefix.isEmpty()) { 1727 // Found a qualified reference to this column - find the matching table 1728 for (TTable table : physicalTables) { 1729 String tableName = table.getName(); 1730 String tableAlias = table.getAliasName(); 1731 1732 if ((tableName != null && nameMatcher.matches(tableName, tablePrefix)) || 1733 (tableAlias != null && nameMatcher.matches(tableAlias, tablePrefix))) { 1734 return table; 1735 } 1736 } 1737 } 1738 } 1739 } 1740 1741 // Push sub-expressions onto stack (right first so left is processed first) 1742 if (current.getRightOperand() != null) stack.push(current.getRightOperand()); 1743 if (current.getLeftOperand() != null) stack.push(current.getLeftOperand()); 1744 } 1745 1746 return null; 1747 } 1748 1749 /** 1750 * Find table from qualified column references in JOIN conditions. 1751 */ 1752 private TTable findTableFromQualifiedColumnInJoin( 1753 TTable joinTable, 1754 String columnName, 1755 List<TTable> physicalTables) { 1756 if (joinTable == null) return null; 1757 1758 gudusoft.gsqlparser.nodes.TJoinExpr joinExpr = joinTable.getJoinExpr(); 1759 if (joinExpr == null) { 1760 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1761 System.out.println("TRACE: findTableFromQualifiedColumnInJoin - joinExpr is null"); 1762 } 1763 return null; 1764 } 1765 1766 // Check the ON condition of this join 1767 if (joinExpr.getOnCondition() != null) { 1768 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1769 System.out.println("TRACE: Checking ON condition for column " + columnName + ": " + joinExpr.getOnCondition()); 1770 } 1771 TTable found = findTableFromQualifiedColumnInExpression( 1772 joinExpr.getOnCondition(), columnName, physicalTables); 1773 if (found != null) return found; 1774 } else { 1775 if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1776 System.out.println("TRACE: findTableFromQualifiedColumnInJoin - ON condition is null"); 1777 } 1778 } 1779 1780 // Recursively check nested joins on the left side 1781 if (joinExpr.getLeftTable() != null && 1782 joinExpr.getLeftTable().getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1783 TTable found = findTableFromQualifiedColumnInJoin( 1784 joinExpr.getLeftTable(), columnName, physicalTables); 1785 if (found != null) return found; 1786 } 1787 1788 // Recursively check nested joins on the right side 1789 if (joinExpr.getRightTable() != null && 1790 joinExpr.getRightTable().getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1791 TTable found = findTableFromQualifiedColumnInJoin( 1792 joinExpr.getRightTable(), columnName, physicalTables); 1793 if (found != null) return found; 1794 } 1795 1796 return null; 1797 } 1798 1799 /** 1800 * Collect all physical tables from a JOIN expression recursively. 1801 */ 1802 private void collectPhysicalTablesFromJoin(TTable joinTable, List<TTable> physicalTables) { 1803 if (joinTable == null) return; 1804 1805 gudusoft.gsqlparser.nodes.TJoinExpr joinExpr = joinTable.getJoinExpr(); 1806 if (joinExpr == null) return; 1807 1808 // Process left side 1809 TTable leftTable = joinExpr.getLeftTable(); 1810 if (leftTable != null) { 1811 if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1812 collectPhysicalTablesFromJoin(leftTable, physicalTables); 1813 } else if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1814 physicalTables.add(leftTable); 1815 } else if (leftTable.getSubquery() != null) { 1816 // For subqueries within joins, we could trace through but for now just note it exists 1817 physicalTables.add(leftTable); 1818 } 1819 } 1820 1821 // Process right side 1822 TTable rightTable = joinExpr.getRightTable(); 1823 if (rightTable != null) { 1824 if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) { 1825 collectPhysicalTablesFromJoin(rightTable, physicalTables); 1826 } else if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) { 1827 physicalTables.add(rightTable); 1828 } else if (rightTable.getSubquery() != null) { 1829 physicalTables.add(rightTable); 1830 } 1831 } 1832 } 1833 1834 /** 1835 * Get the name of a result column (either alias or column name) 1836 */ 1837 private String getResultColumnName(TResultColumn rc) { 1838 if (rc == null) return null; 1839 1840 // Check for alias 1841 if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) { 1842 return rc.getAliasClause().getAliasName().toString(); 1843 } 1844 1845 // Check for star - not a named column 1846 String colStr = rc.toString().trim(); 1847 if (colStr.endsWith("*")) { 1848 return null; 1849 } 1850 1851 // Check for simple column reference 1852 if (rc.getExpr() != null) { 1853 gudusoft.gsqlparser.nodes.TExpression expr = rc.getExpr(); 1854 if (expr.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) { 1855 gudusoft.gsqlparser.nodes.TObjectName objName = expr.getObjectOperand(); 1856 if (objName != null) { 1857 return objName.getColumnNameOnly(); 1858 } 1859 } 1860 } 1861 1862 return null; 1863 } 1864 1865 @Override 1866 public String toString() { 1867 int totalColumns = (columnSources != null ? columnSources.size() : 0) + 1868 (inferredColumns != null ? inferredColumns.size() : 0); 1869 return "SubqueryNamespace(" + getDisplayName() + ", columns=" + totalColumns + 1870 ", inferred=" + (inferredColumns != null ? inferredColumns.size() : 0) + ")"; 1871 } 1872}