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